@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,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant workspace records --kind <kind file> --since <rev> [--at <rev>]
|
|
3
|
+
* [--json]` (#2673, #2650 C11): what changed in a kind's records between two
|
|
4
|
+
* revisions. It reads the records at `<rev>` and at `--at` (the working tree
|
|
5
|
+
* without it), through the same reader as `records`, and lists new and
|
|
6
|
+
* removed records, state transitions, new verdicts, new supersessions and
|
|
7
|
+
* changed pins. Between a review session's open and close commits, that is
|
|
8
|
+
* what the session did.
|
|
9
|
+
*
|
|
10
|
+
* Records are matched by id. A record whose id could not be read is left
|
|
11
|
+
* out of the comparison, since nothing says which record it was before.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { realpathSync } from "node:fs";
|
|
15
|
+
import { relative } from "node:path";
|
|
16
|
+
import { pinEntries } from "./record-assets";
|
|
17
|
+
import { gitRoot, resolveRevision } from "./record-source";
|
|
18
|
+
import { READ_ERROR_CODES, RecordReadError, type LoadedRecordKind, type ReadErrorCode, type RecordEntry, type RecordKind } from "./records";
|
|
19
|
+
import { readRecordsFor, RECORDS_CONTRACT_VERSION } from "./records-cli";
|
|
20
|
+
import type { ReasonCode } from "./reason-codes";
|
|
21
|
+
|
|
22
|
+
/** `$id` of the JSON Schema for `records --since --json`, shipped beside this file. */
|
|
23
|
+
export const RECORDS_SINCE_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records-since/v1/records-since.schema.json";
|
|
24
|
+
|
|
25
|
+
/** Why a `--since` read failed: any records read error, or a `--since` that names no commit. Closed. */
|
|
26
|
+
export const RECORDS_SINCE_ERROR_CODES = [
|
|
27
|
+
...READ_ERROR_CODES,
|
|
28
|
+
/** `--since` names no commit. */
|
|
29
|
+
"since-rev-unknown",
|
|
30
|
+
] as const satisfies readonly ReasonCode[];
|
|
31
|
+
export type RecordsSinceErrorCode = (typeof RECORDS_SINCE_ERROR_CODES)[number];
|
|
32
|
+
|
|
33
|
+
/** The kinds of change, in the order the output lists them. */
|
|
34
|
+
export const SINCE_CHANGE_KINDS = ["new", "removed", "state", "verdict", "supersession", "pin"] as const;
|
|
35
|
+
export type SinceChangeKind = (typeof SINCE_CHANGE_KINDS)[number];
|
|
36
|
+
|
|
37
|
+
/** The review list on a record that is not a session, when its kind declares none (#2670, #2671). */
|
|
38
|
+
const REVIEWS_FIELD = "reviews";
|
|
39
|
+
|
|
40
|
+
export type SinceChange =
|
|
41
|
+
| { change: "new"; id: string; path: string; state: string | null }
|
|
42
|
+
| { change: "removed"; id: string; path: string; state: string | null }
|
|
43
|
+
| { change: "state"; id: string; from: string | null; to: string | null }
|
|
44
|
+
| { change: "verdict"; id: string; principal: string | null; verdict: string | null; index: number; record?: string; session?: string }
|
|
45
|
+
| { change: "supersession"; id: string; supersedes: string }
|
|
46
|
+
| { change: "pin"; id: string; path: string; from: string | null; to: string | null };
|
|
47
|
+
|
|
48
|
+
export type RecordsSinceDocument =
|
|
49
|
+
| {
|
|
50
|
+
$schema: string;
|
|
51
|
+
contract: number;
|
|
52
|
+
kind: { name: string; schema: string; file: string };
|
|
53
|
+
since: string;
|
|
54
|
+
at: string | null;
|
|
55
|
+
changes: SinceChange[];
|
|
56
|
+
summary: Record<SinceChangeKind, number>;
|
|
57
|
+
}
|
|
58
|
+
| { $schema: string; contract: number; error: { code: RecordsSinceErrorCode; message: string } };
|
|
59
|
+
|
|
60
|
+
export interface RecordsSinceQuery {
|
|
61
|
+
kind: string;
|
|
62
|
+
since: string;
|
|
63
|
+
at?: string;
|
|
64
|
+
cwd: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class SinceError extends Error {
|
|
68
|
+
constructor(
|
|
69
|
+
readonly code: RecordsSinceErrorCode,
|
|
70
|
+
message: string,
|
|
71
|
+
) {
|
|
72
|
+
super(message);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Run the comparison and build the document `--json` prints. Never throws a read error. */
|
|
77
|
+
export async function queryRecordsSince(query: RecordsSinceQuery): Promise<RecordsSinceDocument> {
|
|
78
|
+
const failure = (code: RecordsSinceErrorCode, message: string): RecordsSinceDocument => ({
|
|
79
|
+
$schema: RECORDS_SINCE_OUTPUT_SCHEMA_ID,
|
|
80
|
+
contract: RECORDS_CONTRACT_VERSION,
|
|
81
|
+
error: { code, message },
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
const since = resolveSince(query.cwd, query.since);
|
|
85
|
+
const after = await readRecordsFor({ kind: query.kind, at: query.at, cwd: query.cwd });
|
|
86
|
+
let before: RecordEntry[];
|
|
87
|
+
try {
|
|
88
|
+
before = (await readRecordsFor({ kind: query.kind, at: since, cwd: query.cwd })).result.records;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
// A directory that did not exist yet held no records.
|
|
91
|
+
if (!(err instanceof RecordReadError) || err.code !== "location-missing") throw err;
|
|
92
|
+
before = [];
|
|
93
|
+
}
|
|
94
|
+
const changes = compareRecords(after.loaded.kind, before, after.result.records);
|
|
95
|
+
const summary = Object.fromEntries(SINCE_CHANGE_KINDS.map((k) => [k, changes.filter((c) => c.change === k).length])) as Record<SinceChangeKind, number>;
|
|
96
|
+
return {
|
|
97
|
+
$schema: RECORDS_SINCE_OUTPUT_SCHEMA_ID,
|
|
98
|
+
contract: RECORDS_CONTRACT_VERSION,
|
|
99
|
+
kind: kindView(after.loaded, after.root),
|
|
100
|
+
since,
|
|
101
|
+
at: after.at,
|
|
102
|
+
changes,
|
|
103
|
+
summary,
|
|
104
|
+
};
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (err instanceof SinceError) return failure(err.code, err.message);
|
|
107
|
+
if (err instanceof RecordReadError) return failure(err.code satisfies ReadErrorCode, err.message);
|
|
108
|
+
throw err;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function kindView(loaded: LoadedRecordKind, root: string): { name: string; schema: string; file: string } {
|
|
113
|
+
return { name: loaded.kind.name, schema: loaded.kind.schema.id, file: relative(root, loaded.file).split("\\").join("/") };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The full commit id `rev` names, from the repository holding `cwd`. */
|
|
117
|
+
function resolveSince(cwd: string, rev: string): string {
|
|
118
|
+
let dir = cwd;
|
|
119
|
+
try {
|
|
120
|
+
dir = realpathSync(cwd);
|
|
121
|
+
} catch {
|
|
122
|
+
// Reported by the read that follows, if at all.
|
|
123
|
+
}
|
|
124
|
+
const top = gitRoot(dir);
|
|
125
|
+
if (!top) throw new RecordReadError("not-a-git-repository", "--since reads git objects, and this directory is not in a git repository");
|
|
126
|
+
try {
|
|
127
|
+
return resolveRevision(top, rev);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (err instanceof RecordReadError && err.code === "revision-unknown") {
|
|
130
|
+
throw new SinceError("since-rev-unknown", `--since ${rev} names no commit in this repository`);
|
|
131
|
+
}
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The first record with each id, in path order; records with no id are left out. */
|
|
137
|
+
function byId(records: RecordEntry[]): Map<string, RecordEntry> {
|
|
138
|
+
const out = new Map<string, RecordEntry>();
|
|
139
|
+
for (const r of records) if (r.id !== null && !out.has(r.id)) out.set(r.id, r);
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface Verdict {
|
|
144
|
+
key: string;
|
|
145
|
+
index: number;
|
|
146
|
+
principal: string | null;
|
|
147
|
+
verdict: string | null;
|
|
148
|
+
record?: string;
|
|
149
|
+
session?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A record's verdicts: a session's own verdict list, or the review list on
|
|
154
|
+
* any other record. Two entries are the same verdict when they name the same
|
|
155
|
+
* principal, verdict, date, session and record, so a dissent that later gains
|
|
156
|
+
* `addressed_by` is not a new verdict.
|
|
157
|
+
*/
|
|
158
|
+
function verdicts(kind: RecordKind, r: RecordEntry): Verdict[] {
|
|
159
|
+
const field = kind.session ? kind.session.verdicts : (kind.reviews?.field ?? REVIEWS_FIELD);
|
|
160
|
+
const list = r.data?.[field];
|
|
161
|
+
if (!Array.isArray(list)) return [];
|
|
162
|
+
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
|
|
163
|
+
const out: Verdict[] = [];
|
|
164
|
+
list.forEach((e, index) => {
|
|
165
|
+
if (e === null || typeof e !== "object" || Array.isArray(e)) return;
|
|
166
|
+
const o = e as Record<string, unknown>;
|
|
167
|
+
const principal = str(kind.session ? o.principal : o.reviewer) ?? null;
|
|
168
|
+
const verdict = str(o.verdict) ?? null;
|
|
169
|
+
const record = str(o.record);
|
|
170
|
+
const session = str(o.session);
|
|
171
|
+
out.push({
|
|
172
|
+
key: JSON.stringify([principal, verdict, str(o.on) ?? null, session ?? null, record ?? null]),
|
|
173
|
+
index,
|
|
174
|
+
principal,
|
|
175
|
+
verdict,
|
|
176
|
+
...(record !== undefined ? { record } : {}),
|
|
177
|
+
...(session !== undefined ? { session } : {}),
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** What changed from `before` to `after`, in the order of {@link SINCE_CHANGE_KINDS} and then by id. */
|
|
184
|
+
export function compareRecords(kind: RecordKind, before: RecordEntry[], after: RecordEntry[]): SinceChange[] {
|
|
185
|
+
const old = byId(before);
|
|
186
|
+
const now = byId(after);
|
|
187
|
+
const ids = [...new Set([...old.keys(), ...now.keys()])].sort();
|
|
188
|
+
const out: Record<SinceChangeKind, SinceChange[]> = { new: [], removed: [], state: [], verdict: [], supersession: [], pin: [] };
|
|
189
|
+
for (const id of ids) {
|
|
190
|
+
const a = old.get(id);
|
|
191
|
+
const b = now.get(id);
|
|
192
|
+
if (!a && b) out.new.push({ change: "new", id, path: b.path, state: b.state });
|
|
193
|
+
if (a && !b) out.removed.push({ change: "removed", id, path: a.path, state: a.state });
|
|
194
|
+
if (!b) continue;
|
|
195
|
+
if (a && a.state !== b.state) out.state.push({ change: "state", id, from: a.state, to: b.state });
|
|
196
|
+
|
|
197
|
+
// Verdicts in b beyond the ones a already had, compared as multisets.
|
|
198
|
+
const seen = new Map<string, number>();
|
|
199
|
+
for (const v of a ? verdicts(kind, a) : []) seen.set(v.key, (seen.get(v.key) ?? 0) + 1);
|
|
200
|
+
for (const v of verdicts(kind, b)) {
|
|
201
|
+
const left = seen.get(v.key) ?? 0;
|
|
202
|
+
if (left > 0) {
|
|
203
|
+
seen.set(v.key, left - 1);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
out.verdict.push({
|
|
207
|
+
change: "verdict",
|
|
208
|
+
id,
|
|
209
|
+
principal: v.principal,
|
|
210
|
+
verdict: v.verdict,
|
|
211
|
+
index: v.index,
|
|
212
|
+
...(v.record !== undefined ? { record: v.record } : {}),
|
|
213
|
+
...(v.session !== undefined ? { session: v.session } : {}),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Supersession as derived, so a link that only now takes effect counts.
|
|
218
|
+
if (b.supersededBy !== null && (!a || a.supersededBy !== b.supersededBy)) {
|
|
219
|
+
out.supersession.push({ change: "supersession", id: b.supersededBy, supersedes: id });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (a && kind.pins) {
|
|
223
|
+
const from = new Map(pinEntries(a.data, kind.pins.field).map((p) => [p.path, p.sha256]));
|
|
224
|
+
const to = new Map(pinEntries(b.data, kind.pins.field).map((p) => [p.path, p.sha256]));
|
|
225
|
+
for (const path of [...new Set([...from.keys(), ...to.keys()])].sort()) {
|
|
226
|
+
const x = from.get(path) ?? null;
|
|
227
|
+
const y = to.get(path) ?? null;
|
|
228
|
+
if (x !== y) out.pin.push({ change: "pin", id, path, from: x, to: y });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
out.supersession.sort((x, y) => (x.id < y.id ? -1 : x.id > y.id ? 1 : 0));
|
|
233
|
+
return SINCE_CHANGE_KINDS.flatMap((k) => out[k]);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The text form: one line per change and a summary. */
|
|
237
|
+
export function formatSince(doc: Extract<RecordsSinceDocument, { changes: SinceChange[] }>): string {
|
|
238
|
+
const lines = doc.changes.map((c) => {
|
|
239
|
+
switch (c.change) {
|
|
240
|
+
case "new":
|
|
241
|
+
return `${c.id} new, ${c.state ?? "no state"} (${c.path})`;
|
|
242
|
+
case "removed":
|
|
243
|
+
return `${c.id} removed, was ${c.state ?? "no state"} (${c.path})`;
|
|
244
|
+
case "state":
|
|
245
|
+
return `${c.id} state ${c.from ?? "none"} to ${c.to ?? "none"}`;
|
|
246
|
+
case "verdict":
|
|
247
|
+
return `${c.id} verdict ${c.verdict ?? "-"} by ${c.principal ?? "-"}${c.record ? ` on ${c.record}` : ""}${c.session ? ` in ${c.session}` : ""}`;
|
|
248
|
+
case "supersession":
|
|
249
|
+
return `${c.id} supersedes ${c.supersedes}`;
|
|
250
|
+
case "pin":
|
|
251
|
+
return `${c.id} pin ${c.path} ${c.from ? c.from.slice(0, 12) : "none"} to ${c.to ? c.to.slice(0, 12) : "none"}`;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
const s = doc.summary;
|
|
255
|
+
lines.push(
|
|
256
|
+
`${doc.changes.length} changes since ${doc.since.slice(0, 8)}${doc.at ? ` to ${doc.at.slice(0, 8)}` : " to the working tree"}: ${s.new} new, ${s.removed} removed, ${s.state} state, ${s.verdict} verdicts, ${s.supersession} supersessions, ${s.pin} pins`,
|
|
257
|
+
);
|
|
258
|
+
return lines.join("\n");
|
|
259
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The write contract for `chant workspace records new|amend|review` (#2670):
|
|
3
|
+
* each output schema is a valid draft 2020-12 document under the workspace
|
|
4
|
+
* schema scheme, its closed code list matches the code, and real output,
|
|
5
|
+
* written, dry-run and refused, validates against it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import Ajv2020 from "ajv/dist/2020";
|
|
12
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
13
|
+
import { RECORD_WARNING_CODES } from "./records";
|
|
14
|
+
import amendSchema from "./records-amend.schema.json";
|
|
15
|
+
import newSchema from "./records-new.schema.json";
|
|
16
|
+
import reviewSchema from "./records-review.schema.json";
|
|
17
|
+
import {
|
|
18
|
+
AMEND_ERROR_CODES,
|
|
19
|
+
amendRecord,
|
|
20
|
+
NEW_ERROR_CODES,
|
|
21
|
+
newRecord,
|
|
22
|
+
RECORDS_AMEND_SCHEMA_ID,
|
|
23
|
+
RECORDS_NEW_SCHEMA_ID,
|
|
24
|
+
RECORDS_REVIEW_SCHEMA_ID,
|
|
25
|
+
RECORDS_WRITE_CONTRACT_VERSION,
|
|
26
|
+
REVIEW_ERROR_CODES,
|
|
27
|
+
reviewRecord,
|
|
28
|
+
} from "./records-write";
|
|
29
|
+
|
|
30
|
+
const REPO = join(import.meta.dirname, "..", "..", "..", "..");
|
|
31
|
+
const KIND = "docs/design/decisions/decision.kind.mjs";
|
|
32
|
+
|
|
33
|
+
const ajv = new Ajv2020({ strict: true, allErrors: true });
|
|
34
|
+
|
|
35
|
+
const scratch: string[] = [];
|
|
36
|
+
afterAll(() => {
|
|
37
|
+
for (const d of scratch) rmSync(d, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** A directory holding a copy of the chant repo's decisions, outside any git repository. */
|
|
41
|
+
function copyDecisions(): string {
|
|
42
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-records-write-contract-")));
|
|
43
|
+
scratch.push(root);
|
|
44
|
+
mkdirSync(join(root, "docs", "design"), { recursive: true });
|
|
45
|
+
cpSync(join(REPO, "docs", "design", "decisions"), join(root, "docs", "design", "decisions"), { recursive: true });
|
|
46
|
+
return root;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SCHEMAS = [
|
|
50
|
+
{ name: "records-new", schema: newSchema, id: RECORDS_NEW_SCHEMA_ID, codes: NEW_ERROR_CODES },
|
|
51
|
+
{ name: "records-amend", schema: amendSchema, id: RECORDS_AMEND_SCHEMA_ID, codes: AMEND_ERROR_CODES },
|
|
52
|
+
{ name: "records-review", schema: reviewSchema, id: RECORDS_REVIEW_SCHEMA_ID, codes: REVIEW_ERROR_CODES },
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
function expectValid(schema: object, doc: unknown): void {
|
|
56
|
+
const validate = ajv.getSchema((schema as { $id: string }).$id) ?? ajv.compile(schema);
|
|
57
|
+
expect(validate(doc), JSON.stringify(validate.errors, null, 2)).toBe(true);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe.each(SCHEMAS)("$name output schema", ({ name, schema, id, codes }) => {
|
|
61
|
+
test("is a valid draft 2020-12 document under the workspace scheme, at contract 1", () => {
|
|
62
|
+
expect(schema.$schema).toBe("https://json-schema.org/draft/2020-12/schema");
|
|
63
|
+
expect(ajv.validateSchema(schema)).toBe(true);
|
|
64
|
+
expect(schema.$id).toBe(id);
|
|
65
|
+
expect(id).toBe(`https://intentius.io/chant/schemas/workspace/${name}/v1/${name}.schema.json`);
|
|
66
|
+
expect(RECORDS_WRITE_CONTRACT_VERSION).toBe(1);
|
|
67
|
+
for (const branch of ["result", "failure"] as const) {
|
|
68
|
+
expect(schema.$defs[branch].properties.contract).toEqual({ const: 1 });
|
|
69
|
+
expect(schema.$defs[branch].properties.$schema).toEqual({ const: id });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("lists exactly the error and warning codes the code can return", () => {
|
|
74
|
+
expect(schema.$defs.failure.properties.error.properties.code.enum).toEqual([...codes]);
|
|
75
|
+
expect(schema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("refuses a document mixing a result and an error", () => {
|
|
79
|
+
const validate = ajv.getSchema(id) ?? ajv.compile(schema);
|
|
80
|
+
expect(validate({ $schema: id, contract: 1, error: { code: "record-not-found", message: "x" }, path: "a.md" })).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("real output validates", () => {
|
|
85
|
+
const fields = JSON.stringify({
|
|
86
|
+
schema: 1,
|
|
87
|
+
title: "A decision written through chant",
|
|
88
|
+
state: "proposed",
|
|
89
|
+
area: "D4",
|
|
90
|
+
source: { kind: "workspace", member: "app" },
|
|
91
|
+
question: "Does the write contract hold?",
|
|
92
|
+
options: [{ id: "a", label: "yes", how: null, tradeoff: null }],
|
|
93
|
+
choice: null,
|
|
94
|
+
rejected: [],
|
|
95
|
+
supersedes: [],
|
|
96
|
+
evidence: [],
|
|
97
|
+
decided_by: null,
|
|
98
|
+
decided_on: null,
|
|
99
|
+
reviews: [],
|
|
100
|
+
constrains: ["INTENTIUS/chant#2670"],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("records new, as a dry run against the chant repo's own decisions", async () => {
|
|
104
|
+
const doc = await newRecord({ kind: KIND, fields, dryRun: true, cwd: REPO });
|
|
105
|
+
expectValid(newSchema, doc);
|
|
106
|
+
expect(doc).toMatchObject({ dryRun: true, path: expect.stringMatching(/^docs\/design\/decisions\/ws-\d{3}-a-decision-written-through-chant\.md$/) });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("each command written, dry-run and refused", async () => {
|
|
110
|
+
const root = copyDecisions();
|
|
111
|
+
const made = await newRecord({ kind: KIND, fields, cwd: root });
|
|
112
|
+
expectValid(newSchema, made);
|
|
113
|
+
if ("error" in made) throw new Error(made.error.message);
|
|
114
|
+
expectValid(newSchema, await newRecord({ kind: KIND, fields: "[]", cwd: root }));
|
|
115
|
+
expectValid(newSchema, await newRecord({ kind: "missing.kind.mjs", fields, cwd: root }));
|
|
116
|
+
|
|
117
|
+
for (const dryRun of [true, false]) expectValid(amendSchema, await amendRecord({ kind: KIND, id: made.id, fields: JSON.stringify({ area: "D5" }), dryRun, cwd: root }));
|
|
118
|
+
expectValid(amendSchema, await amendRecord({ kind: KIND, id: "ws-003", fields: JSON.stringify({ title: "x" }), cwd: root }));
|
|
119
|
+
|
|
120
|
+
for (const dryRun of [true, false]) {
|
|
121
|
+
expectValid(reviewSchema, await reviewRecord({ kind: KIND, id: made.id, verdict: "dissent", by: "alice", note: "Not yet.", session: "S-0001", dryRun, cwd: root }));
|
|
122
|
+
}
|
|
123
|
+
expectValid(reviewSchema, await reviewRecord({ kind: KIND, id: made.id, verdict: "dissent", by: "alice", cwd: root }));
|
|
124
|
+
});
|
|
125
|
+
});
|