@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,640 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Record formats beyond Markdown front matter (ws-053, #2664): JSON records,
|
|
3
|
+
* kinds without a lifecycle, a supersedes field of bare ids, schema files a
|
|
4
|
+
* schema `$ref`s, and content-addressed ids.
|
|
5
|
+
*
|
|
6
|
+
* The fixtures are shaped like chud's records (jhgaylor/chud#78: units,
|
|
7
|
+
* evidence named by the hash of its bytes, sessions, driver closures and
|
|
8
|
+
* Markdown contracts, with draft-07 schemas that `$ref` a shared
|
|
9
|
+
* `defs.schema.json`), trimmed to the fields that matter here. Each one reads
|
|
10
|
+
* through a kind file alone: no reader code.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import Ajv2020 from "ajv/dist/2020";
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
20
|
+
import { parseArgs } from "../cli/main";
|
|
21
|
+
import { queryRecords, type RecordsDocument } from "./records-cli";
|
|
22
|
+
import { newRecord, runRecordsWrite } from "./records-write";
|
|
23
|
+
import { loadRecordKind, parseJsonRecord, RECORD_FORMATS, recordTextDigest } from "./records";
|
|
24
|
+
import schema from "./records.schema.json";
|
|
25
|
+
|
|
26
|
+
const REPO = join(import.meta.dirname, "..", "..", "..", "..");
|
|
27
|
+
const validateOutput = new Ajv2020({ strict: true, allErrors: true }).compile(schema);
|
|
28
|
+
|
|
29
|
+
let root: string;
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
root = realpathSync(mkdtempSync(join(tmpdir(), "chant-record-formats-")));
|
|
32
|
+
writeChudModel();
|
|
33
|
+
});
|
|
34
|
+
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
|
35
|
+
|
|
36
|
+
function write(path: string, text: string): void {
|
|
37
|
+
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
38
|
+
writeFileSync(join(root, path), text);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const json = (v: unknown): string => `${JSON.stringify(v, null, 2)}\n`;
|
|
42
|
+
const sha256 = (text: string): string => createHash("sha256").update(text, "utf8").digest("hex");
|
|
43
|
+
|
|
44
|
+
const git = (...args: string[]): string =>
|
|
45
|
+
execFileSync("git", args, {
|
|
46
|
+
cwd: root,
|
|
47
|
+
encoding: "utf-8",
|
|
48
|
+
env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.com", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.com" },
|
|
49
|
+
}).trim();
|
|
50
|
+
|
|
51
|
+
function commit(): string {
|
|
52
|
+
git("init", "-q", "-b", "main");
|
|
53
|
+
git("add", "-A");
|
|
54
|
+
git("commit", "-q", "-m", "records");
|
|
55
|
+
return git("rev-parse", "HEAD");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function query(kind: string, at?: string): Promise<Extract<RecordsDocument, { records: unknown }>> {
|
|
59
|
+
const doc = await queryRecords({ kind: `design/${kind}.kind.mjs`, cwd: root, ...(at ? { at } : {}) });
|
|
60
|
+
expect(validateOutput(doc), JSON.stringify(validateOutput.errors, null, 2)).toBe(true);
|
|
61
|
+
if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
|
|
62
|
+
return doc;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function failure(kind: string): Promise<{ code: string; message: string }> {
|
|
66
|
+
const doc = await queryRecords({ kind: `design/${kind}.kind.mjs`, cwd: root });
|
|
67
|
+
expect(validateOutput(doc), JSON.stringify(validateOutput.errors, null, 2)).toBe(true);
|
|
68
|
+
if (!("error" in doc)) throw new Error("the read worked");
|
|
69
|
+
return doc.error;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const codes = (r: { reasons: Array<{ code: string }> }) => r.reasons.map((x) => x.code);
|
|
73
|
+
const warned = (r: { warnings: Array<{ code: string }> }) => r.warnings.map((x) => x.code);
|
|
74
|
+
|
|
75
|
+
// ── chud's model, as kind files ──────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
const BASE = "https://schemas.example.test/chud-runtime/v0";
|
|
78
|
+
const ref = (name: string) => ({ $ref: `defs.schema.json#/definitions/${name}` });
|
|
79
|
+
|
|
80
|
+
const DEFS = {
|
|
81
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
82
|
+
$id: `${BASE}/defs.schema.json`,
|
|
83
|
+
definitions: {
|
|
84
|
+
datetime: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})$" },
|
|
85
|
+
sha256: { type: "string", pattern: "^[0-9a-f]{64}$" },
|
|
86
|
+
gitId: { type: "string", pattern: "^[0-9a-f]{40}$" },
|
|
87
|
+
text: { type: "string", minLength: 1 },
|
|
88
|
+
contractId: { type: "string", pattern: "^C-\\d{3,}$" },
|
|
89
|
+
unitId: { type: "string", pattern: "^U-\\d{4,}$" },
|
|
90
|
+
driverId: { type: "string", pattern: "^D-\\d{3,}$" },
|
|
91
|
+
sessionId: { type: "string", pattern: "^S-\\d{4,}$" },
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const UNIT_SCHEMA = {
|
|
96
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
97
|
+
$id: `${BASE}/unit.schema.json`,
|
|
98
|
+
type: "object",
|
|
99
|
+
required: ["id", "role", "agent", "scope", "base_commit", "result", "evidence", "outcome", "opened_at"],
|
|
100
|
+
additionalProperties: false,
|
|
101
|
+
properties: {
|
|
102
|
+
id: ref("unitId"),
|
|
103
|
+
role: { enum: ["formalize", "build", "manual", "split"] },
|
|
104
|
+
contract: { type: "object", required: ["id", "sha"], properties: { id: ref("contractId"), sha: ref("sha256") } },
|
|
105
|
+
agent: { type: "object", required: ["name", "model", "runtime"], properties: { name: ref("text"), model: ref("text"), runtime: ref("text") } },
|
|
106
|
+
scope: { type: "array", minItems: 1, items: ref("text") },
|
|
107
|
+
base_commit: ref("gitId"),
|
|
108
|
+
result: { type: "object", required: ["commit", "ref"], properties: { commit: { type: ["string", "null"] }, ref: { type: ["string", "null"] } } },
|
|
109
|
+
// A bare hash, as chud writes today, or a pin chant checks.
|
|
110
|
+
evidence: {
|
|
111
|
+
type: "array",
|
|
112
|
+
items: { anyOf: [ref("sha256"), { type: "object", required: ["path", "sha256"], properties: { path: ref("text"), sha256: ref("sha256") } }] },
|
|
113
|
+
},
|
|
114
|
+
outcome: { enum: ["open", "done", "not_done"] },
|
|
115
|
+
corrects: ref("unitId"),
|
|
116
|
+
opened_at: ref("datetime"),
|
|
117
|
+
closed_at: ref("datetime"),
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const EVIDENCE_SCHEMA = {
|
|
122
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
123
|
+
$id: `${BASE}/evidence.schema.json`,
|
|
124
|
+
type: "object",
|
|
125
|
+
required: ["contract", "check_sha", "commit", "tree", "runner", "ok", "criteria", "output", "at"],
|
|
126
|
+
properties: {
|
|
127
|
+
contract: { type: "object", required: ["id", "sha"], properties: { id: ref("contractId"), sha: ref("sha256") } },
|
|
128
|
+
check_sha: ref("sha256"),
|
|
129
|
+
commit: { type: ["string", "null"] },
|
|
130
|
+
tree: ref("gitId"),
|
|
131
|
+
runner: { type: "object", required: ["kind"], properties: { kind: { enum: ["design-check", "release-check", "upgrade-check"] } } },
|
|
132
|
+
ok: { type: "boolean" },
|
|
133
|
+
criteria: { type: "object", additionalProperties: { enum: ["pass", "fail", "unchecked"] } },
|
|
134
|
+
output: { type: "string", maxLength: 6000 },
|
|
135
|
+
at: ref("datetime"),
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const SESSION_SCHEMA = {
|
|
140
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
141
|
+
$id: `${BASE}/session.schema.json`,
|
|
142
|
+
type: "object",
|
|
143
|
+
required: ["id", "title", "status", "opened_by", "opened_at", "participants", "reviewed", "approvals", "decisions", "comments", "follow_ups"],
|
|
144
|
+
properties: {
|
|
145
|
+
id: ref("sessionId"),
|
|
146
|
+
title: ref("text"),
|
|
147
|
+
status: { enum: ["open", "closed"] },
|
|
148
|
+
opened_by: ref("text"),
|
|
149
|
+
opened_at: ref("datetime"),
|
|
150
|
+
participants: { type: "array" },
|
|
151
|
+
reviewed: { type: "array", minItems: 1 },
|
|
152
|
+
approvals: { type: "array" },
|
|
153
|
+
decisions: { type: "array" },
|
|
154
|
+
comments: { type: "array" },
|
|
155
|
+
follow_ups: { type: "array", items: ref("contractId") },
|
|
156
|
+
closed_at: ref("datetime"),
|
|
157
|
+
seal: ref("sha256"),
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const CLOSURE_SCHEMA = {
|
|
162
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
163
|
+
$id: `${BASE}/driver-closure.schema.json`,
|
|
164
|
+
type: "object",
|
|
165
|
+
required: ["driver", "closed_at", "by"],
|
|
166
|
+
properties: { driver: ref("driverId"), closed_at: ref("datetime"), by: ref("text"), note: { type: "string" } },
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const CONTRACT_SCHEMA = {
|
|
170
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
171
|
+
$id: `${BASE}/contract.schema.json`,
|
|
172
|
+
type: "object",
|
|
173
|
+
required: ["id", "status"],
|
|
174
|
+
properties: {
|
|
175
|
+
id: ref("contractId"),
|
|
176
|
+
status: { enum: ["draft", "approved", "retired"] },
|
|
177
|
+
supersedes: { anyOf: [ref("contractId"), { type: "array", items: ref("contractId") }] },
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** A kind file: data only, the way chud's model package would ship it. */
|
|
182
|
+
function kindFile(kind: Record<string, unknown>): string {
|
|
183
|
+
return `export const recordKind = ${JSON.stringify(kind, null, 2)};\n`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const schemaOf = (name: string) => ({ id: `${BASE}/${name}.schema.json`, path: `schemas/${name}.schema.json`, refs: [{ id: `${BASE}/defs.schema.json`, path: "schemas/defs.schema.json" }] });
|
|
187
|
+
|
|
188
|
+
const KINDS: Record<string, Record<string, unknown>> = {
|
|
189
|
+
unit: {
|
|
190
|
+
name: "unit",
|
|
191
|
+
location: { dir: "units", match: "^U-\\d+\\.json$" },
|
|
192
|
+
format: "json",
|
|
193
|
+
schema: schemaOf("unit"),
|
|
194
|
+
idField: "id",
|
|
195
|
+
stateField: "outcome",
|
|
196
|
+
states: ["open", "done", "not_done"],
|
|
197
|
+
closedStates: ["done", "not_done"],
|
|
198
|
+
pins: { field: "evidence" },
|
|
199
|
+
},
|
|
200
|
+
evidence: {
|
|
201
|
+
name: "evidence",
|
|
202
|
+
location: { dir: "evidence", match: "^[0-9a-f]{64}\\.json$" },
|
|
203
|
+
format: "json",
|
|
204
|
+
schema: schemaOf("evidence"),
|
|
205
|
+
idFrom: "sha256",
|
|
206
|
+
},
|
|
207
|
+
session: {
|
|
208
|
+
name: "session",
|
|
209
|
+
location: { dir: "sessions", match: "^S-\\d+\\.json$" },
|
|
210
|
+
format: "json",
|
|
211
|
+
schema: schemaOf("session"),
|
|
212
|
+
idField: "id",
|
|
213
|
+
stateField: "status",
|
|
214
|
+
states: ["open", "closed"],
|
|
215
|
+
closedStates: ["closed"],
|
|
216
|
+
},
|
|
217
|
+
closure: {
|
|
218
|
+
name: "driver-closure",
|
|
219
|
+
location: { dir: "drivers/closures", match: "^D-\\d+\\.json$" },
|
|
220
|
+
format: "json",
|
|
221
|
+
schema: schemaOf("driver-closure"),
|
|
222
|
+
idField: "driver",
|
|
223
|
+
},
|
|
224
|
+
contract: {
|
|
225
|
+
name: "contract",
|
|
226
|
+
location: { dir: "contracts", match: "^C-\\d+.*\\.md$" },
|
|
227
|
+
format: "markdown-front-matter",
|
|
228
|
+
schema: schemaOf("contract"),
|
|
229
|
+
idField: "id",
|
|
230
|
+
stateField: "status",
|
|
231
|
+
states: ["draft", "approved", "retired"],
|
|
232
|
+
closedStates: ["retired"],
|
|
233
|
+
supersedes: { field: "supersedes" },
|
|
234
|
+
approval: { draft: 0, approved: 1, retired: 1 },
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
function writeChudModel(): void {
|
|
239
|
+
write("design/schemas/defs.schema.json", json(DEFS));
|
|
240
|
+
write("design/schemas/unit.schema.json", json(UNIT_SCHEMA));
|
|
241
|
+
write("design/schemas/evidence.schema.json", json(EVIDENCE_SCHEMA));
|
|
242
|
+
write("design/schemas/session.schema.json", json(SESSION_SCHEMA));
|
|
243
|
+
write("design/schemas/driver-closure.schema.json", json(CLOSURE_SCHEMA));
|
|
244
|
+
write("design/schemas/contract.schema.json", json(CONTRACT_SCHEMA));
|
|
245
|
+
for (const [file, kind] of Object.entries(KINDS)) write(`design/${file}.kind.mjs`, kindFile(kind));
|
|
246
|
+
for (const dir of ["units", "evidence", "sessions", "drivers/closures", "contracts"]) mkdirSync(join(root, "design", dir), { recursive: true });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function unit(id: string, extra: Record<string, unknown> = {}): Record<string, unknown> {
|
|
250
|
+
return {
|
|
251
|
+
id,
|
|
252
|
+
role: "build",
|
|
253
|
+
agent: { name: "builder", model: "m", runtime: "r" },
|
|
254
|
+
scope: ["app/src"],
|
|
255
|
+
base_commit: "a".repeat(40),
|
|
256
|
+
result: { commit: null, ref: null },
|
|
257
|
+
evidence: [],
|
|
258
|
+
outcome: "open",
|
|
259
|
+
opened_at: "2026-09-24T10:00:00.000Z",
|
|
260
|
+
...extra,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const EVIDENCE = {
|
|
265
|
+
contract: { id: "C-001", sha: "b".repeat(64) },
|
|
266
|
+
check_sha: "c".repeat(64),
|
|
267
|
+
commit: null,
|
|
268
|
+
tree: "d".repeat(40),
|
|
269
|
+
runner: { kind: "design-check" },
|
|
270
|
+
ok: true,
|
|
271
|
+
criteria: { renders: "pass" },
|
|
272
|
+
output: "1 passed",
|
|
273
|
+
at: "2026-09-24T10:00:00.000Z",
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/** Write an evidence record named by the hash of its bytes, and return that hash. */
|
|
277
|
+
function writeEvidence(value: Record<string, unknown> = EVIDENCE): string {
|
|
278
|
+
const text = json(value);
|
|
279
|
+
const h = sha256(text);
|
|
280
|
+
write(`design/evidence/${h}.json`, text);
|
|
281
|
+
return h;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const SESSION = {
|
|
285
|
+
id: "S-0001",
|
|
286
|
+
title: "First walk",
|
|
287
|
+
status: "closed",
|
|
288
|
+
opened_by: "alex",
|
|
289
|
+
opened_at: "2026-09-24T10:00:00.000Z",
|
|
290
|
+
participants: [{ name: "alex", roles: ["maintainer"], joined_at: "2026-09-24T10:00:00.000Z" }],
|
|
291
|
+
reviewed: [{ kind: "contract", id: "C-001" }],
|
|
292
|
+
approvals: [],
|
|
293
|
+
decisions: [],
|
|
294
|
+
comments: [],
|
|
295
|
+
follow_ups: ["C-002"],
|
|
296
|
+
closed_at: "2026-09-24T11:00:00.000Z",
|
|
297
|
+
seal: "e".repeat(64),
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// ── 1. The json format ───────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
test("the output schema lists exactly the formats a kind file may name", () => {
|
|
303
|
+
expect(schema.$defs.result.properties.kind.properties.format.enum).toEqual([...RECORD_FORMATS]);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe("format json", () => {
|
|
307
|
+
test("chud's units, sessions and driver closures read through kind files alone", async () => {
|
|
308
|
+
write("design/units/U-0001.json", json(unit("U-0001", { outcome: "done", closed_at: "2026-09-24T11:00:00.000Z" })));
|
|
309
|
+
write("design/units/U-0002.json", json(unit("U-0002")));
|
|
310
|
+
write("design/sessions/S-0001.json", json(SESSION));
|
|
311
|
+
write("design/drivers/closures/D-001.json", json({ driver: "D-001", closed_at: "2026-09-24T11:00:00.000Z", by: "alex" }));
|
|
312
|
+
|
|
313
|
+
const units = await query("unit");
|
|
314
|
+
expect(units.kind).toMatchObject({ name: "unit", format: "json" });
|
|
315
|
+
expect(units.records.map((r) => [r.id, r.state, r.valid, r.supersededBy])).toEqual([
|
|
316
|
+
["U-0001", "done", true, null],
|
|
317
|
+
["U-0002", "open", true, null],
|
|
318
|
+
]);
|
|
319
|
+
expect(units.records[0].data).toMatchObject({ id: "U-0001", scope: ["app/src"] });
|
|
320
|
+
// An empty pins list cites nothing, as the design note says it should for an open unit.
|
|
321
|
+
expect(warned(units.records[1])).toEqual(["record-no-evidence"]);
|
|
322
|
+
|
|
323
|
+
const sessions = await query("session");
|
|
324
|
+
expect(sessions.records.map((r) => [r.id, r.state, r.valid])).toEqual([["S-0001", "closed", true]]);
|
|
325
|
+
|
|
326
|
+
const closures = await query("closure");
|
|
327
|
+
expect(closures.records.map((r) => [r.id, r.state, r.valid, r.supersededBy])).toEqual([["D-001", null, true, null]]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("a record that fails its schema is record-schema-invalid, with the shared defs applied", async () => {
|
|
331
|
+
write("design/units/U-0001.json", json(unit("U-0001", { base_commit: "not-a-commit" })));
|
|
332
|
+
const [r] = (await query("unit")).records;
|
|
333
|
+
expect(codes(r)).toEqual(["record-schema-invalid"]);
|
|
334
|
+
expect(r.reasons[0].message).toMatch(/\/base_commit must match pattern/);
|
|
335
|
+
expect(r.id).toBe("U-0001");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test.each([
|
|
339
|
+
["a file that is not JSON", '{"id": "U-0001",\n', /not valid JSON/],
|
|
340
|
+
["an array at the top level", json([unit("U-0001")]), /one object at the top level/],
|
|
341
|
+
["a string at the top level", '"U-0001"\n', /one object at the top level/],
|
|
342
|
+
["a repeated member name", '{\n "id": "U-0001",\n "outcome": "open",\n "outcome": "done"\n}\n', /"outcome" repeats/],
|
|
343
|
+
["a repeated name in a nested object, spelled with an escape", '{"id": "U-0001", "agent": {"name": "a", "\\u006eame": "b"}}\n', /\/agent: member name "name" repeats/],
|
|
344
|
+
["a number too large for a double", '{"id": "U-0001", "n": 1e400}\n', /not a JSON number/],
|
|
345
|
+
])("%s is record-unparseable", async (_label, text, why) => {
|
|
346
|
+
write("design/units/U-0001.json", text);
|
|
347
|
+
const [r] = (await query("unit")).records;
|
|
348
|
+
expect(codes(r)).toEqual(["record-unparseable"]);
|
|
349
|
+
expect(r.reasons[0].message).toMatch(why);
|
|
350
|
+
expect(r.data).toBeNull();
|
|
351
|
+
expect(r.id).toBeNull();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("the same member name in two objects is not a repeat", () => {
|
|
355
|
+
expect(parseJsonRecord('{"a": {"x": 1}, "b": {"x": 2}, "c": [{"x": 1}, {"x": 2}]}')).toMatchObject({ ok: true });
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("reads the same way under --at", async () => {
|
|
359
|
+
write("design/units/U-0001.json", json(unit("U-0001")));
|
|
360
|
+
write("design/units/U-0002.json", '[{"id": "U-0002"}]\n');
|
|
361
|
+
write("design/units/U-0003.json", '{"id": "U-0003", "id": "U-0004"}\n');
|
|
362
|
+
const head = commit();
|
|
363
|
+
write("design/units/U-0001.json", "broken now\n");
|
|
364
|
+
const doc = await query("unit", "main");
|
|
365
|
+
expect(doc.at).toBe(head);
|
|
366
|
+
expect(doc.records.map((r) => [r.id, codes(r)])).toEqual([
|
|
367
|
+
["U-0001", []],
|
|
368
|
+
[null, ["record-unparseable"]],
|
|
369
|
+
[null, ["record-unparseable"]],
|
|
370
|
+
]);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
describe("a JSON session kind (#2673)", () => {
|
|
375
|
+
test("its seal is the digest without the seal member, by the JSON rule", async () => {
|
|
376
|
+
write("design/session.kind.mjs", kindFile({ ...KINDS.session, session: { verdicts: "approvals", seal: "seal", subjects: { kind: "contract.kind.mjs" } } }));
|
|
377
|
+
write("design/contracts/C-001-first.md", '---\nid: "C-001"\nstatus: "approved"\n---\n');
|
|
378
|
+
const { seal: _drop, ...unsealed } = SESSION;
|
|
379
|
+
const seal = sha256(json(unsealed));
|
|
380
|
+
write("design/sessions/S-0001.json", json({ ...SESSION, approvals: [{ record: "C-001" }], seal }));
|
|
381
|
+
write("design/sessions/S-0002.json", json({ ...SESSION, id: "S-0002", seal }));
|
|
382
|
+
const doc = await query("session");
|
|
383
|
+
expect(doc.records.map((r) => [r.id, codes(r)])).toEqual([
|
|
384
|
+
["S-0001", ["session-seal-mismatch"]],
|
|
385
|
+
["S-0002", ["session-seal-mismatch"]],
|
|
386
|
+
]);
|
|
387
|
+
const good = { ...unsealed, id: "S-0003" };
|
|
388
|
+
write("design/sessions/S-0003.json", json({ ...good, seal: sha256(json(good)) }));
|
|
389
|
+
const [, , third] = (await query("session")).records;
|
|
390
|
+
expect([third.id, codes(third)]).toEqual(["S-0003", []]);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe("the write commands", () => {
|
|
395
|
+
test("refuse a JSON kind and a content-addressed one, which they cannot write", async () => {
|
|
396
|
+
for (const kind of ["unit", "evidence"]) {
|
|
397
|
+
const doc = await newRecord({ kind: `design/${kind}.kind.mjs`, fields: "{}", cwd: root, dryRun: true });
|
|
398
|
+
expect("error" in doc && doc.error.code).toBe("write-usage-invalid");
|
|
399
|
+
expect("error" in doc && doc.error.message).toMatch(/write only Markdown front matter records with an idField/);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
describe("the write commands through a declared kind (#2680)", () => {
|
|
405
|
+
afterEach(() => vi.restoreAllMocks());
|
|
406
|
+
|
|
407
|
+
test.each(["unit", "evidence"])("refuse the declared %s kind with write-usage-invalid", async (kind) => {
|
|
408
|
+
write(
|
|
409
|
+
"chant.workspace.json",
|
|
410
|
+
json({ name: "chud", schema: 1, members: [{ name: "design", dir: "design", kind: "other", because: "chud's records" }], records: [{ kind: `design/${kind}.kind.mjs` }] }),
|
|
411
|
+
);
|
|
412
|
+
write("design/units/U-0001.json", json(unit("U-0001")));
|
|
413
|
+
write("fields.json", "{}\n");
|
|
414
|
+
const out: string[] = [];
|
|
415
|
+
vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => void out.push(a.join(" ")));
|
|
416
|
+
vi.spyOn(process, "cwd").mockReturnValue(root);
|
|
417
|
+
const runs: string[][] = [
|
|
418
|
+
["new", "--from", join(root, "fields.json"), "--dry-run"],
|
|
419
|
+
["amend", "U-0001", "--set", join(root, "fields.json"), "--dry-run"],
|
|
420
|
+
["review", "U-0001", "--verdict", "agree", "--by", "bo", "--dry-run"],
|
|
421
|
+
];
|
|
422
|
+
for (const argv of runs) {
|
|
423
|
+
out.length = 0;
|
|
424
|
+
const status = await runRecordsWrite({ args: parseArgs(["workspace", "records", ...argv]), plugins: [] } as never);
|
|
425
|
+
const doc = JSON.parse(out.join("\n")) as { error?: { code: string; message: string } };
|
|
426
|
+
expect(status, argv[0]).toBe(1);
|
|
427
|
+
expect(doc.error?.code, argv[0]).toBe("write-usage-invalid");
|
|
428
|
+
expect(doc.error?.message, argv[0]).toMatch(/write only Markdown front matter records with an idField/);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
describe("the decision kind", () => {
|
|
434
|
+
test("still reads as markdown front matter, and says so", async () => {
|
|
435
|
+
const doc = await queryRecords({ kind: "docs/design/decisions/decision.kind.mjs", cwd: REPO });
|
|
436
|
+
expect(validateOutput(doc)).toBe(true);
|
|
437
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
438
|
+
expect(doc.kind.format).toBe("markdown-front-matter");
|
|
439
|
+
expect(doc.records.find((r) => r.id === "ws-053")).toMatchObject({ state: "decided", valid: true, supersededBy: null });
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// ── The digest of a JSON record ──────────────────────────────────────────────
|
|
444
|
+
|
|
445
|
+
describe("the digest of a JSON record", () => {
|
|
446
|
+
const withReviews = (reviews: unknown[], at: "middle" | "last" | "first") => {
|
|
447
|
+
const body: Record<string, unknown> = { id: "U-0001", outcome: "open" };
|
|
448
|
+
const entries = Object.entries(body);
|
|
449
|
+
const member: [string, unknown] = ["reviews", reviews];
|
|
450
|
+
const ordered = at === "first" ? [member, ...entries] : at === "middle" ? [entries[0], member, entries[1]] : [...entries, member];
|
|
451
|
+
return json(Object.fromEntries(ordered));
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
test.each(["first", "middle", "last"] as const)("with the reviews member %s, is the hash of the text with that member and one comma deleted", (at) => {
|
|
455
|
+
const without = json({ id: "U-0001", outcome: "open" });
|
|
456
|
+
const one = withReviews([{ reviewer: "bo", verdict: "agree" }], at);
|
|
457
|
+
const two = withReviews([{ reviewer: "bo", verdict: "agree" }, { reviewer: "cy", verdict: "dissent", note: "no" }], at);
|
|
458
|
+
// What a hand-editor gets by deleting the member's lines and the comma, then running sha256sum.
|
|
459
|
+
expect(recordTextDigest(one, "reviews", "json")).toBe(sha256(without));
|
|
460
|
+
expect(recordTextDigest(two, "reviews", "json")).toBe(sha256(without));
|
|
461
|
+
expect(recordTextDigest(withReviews([], at), "reviews", "json")).toBe(sha256(without));
|
|
462
|
+
expect(recordTextDigest(without, "reviews", "json")).toBe(sha256(without));
|
|
463
|
+
// Any other edit moves it.
|
|
464
|
+
expect(recordTextDigest(one.replace('"open"', '"done"'), "reviews", "json")).not.toBe(sha256(without));
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("CRLF reads as LF, a lone member leaves the braces, and a nested reviews member stays", () => {
|
|
468
|
+
expect(recordTextDigest('{\r\n "id": "U-0001",\r\n "reviews": []\r\n}\r\n', "reviews", "json")).toBe(sha256('{\n "id": "U-0001"\n}\n'));
|
|
469
|
+
expect(recordTextDigest('{\n "reviews": []\n}\n', "reviews", "json")).toBe(sha256("{\n}\n"));
|
|
470
|
+
const nested = '{"id": "U-0001", "x": {"reviews": []}}';
|
|
471
|
+
expect(recordTextDigest(nested, "reviews", "json")).toBe(sha256(nested));
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("text that is not a JSON record is hashed whole, and markdown keeps #2672's rule", () => {
|
|
475
|
+
expect(recordTextDigest('[{"reviews": []}]', "reviews", "json")).toBe(sha256('[{"reviews": []}]'));
|
|
476
|
+
const md = '---\nid: "a-001"\nreviews:\n - reviewer: "bo"\n---\n# A\n';
|
|
477
|
+
expect(recordTextDigest(md, "reviews")).toBe(sha256('---\nid: "a-001"\n---\n# A\n'));
|
|
478
|
+
expect(recordTextDigest(md, "reviews", "json")).toBe(sha256(md));
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("records reports it, and a verdict on it counts toward the quorum", async () => {
|
|
482
|
+
write(
|
|
483
|
+
"design/review.kind.mjs",
|
|
484
|
+
kindFile({ ...KINDS.unit, name: "reviewed-unit", schema: { id: "urn:test:open", path: "schemas/open.schema.json" }, pins: undefined, reviews: { field: "reviews", decider: "by" } }),
|
|
485
|
+
);
|
|
486
|
+
write("design/schemas/open.schema.json", json({ $id: "urn:test:open", type: "object" }));
|
|
487
|
+
const bare = json({ id: "U-0001", outcome: "done", by: "alex" });
|
|
488
|
+
const digest = sha256(bare);
|
|
489
|
+
write("design/units/U-0001.json", json({ id: "U-0001", outcome: "done", by: "alex", reviews: [{ reviewer: "bo", verdict: "agree", digest }, { reviewer: "cy", verdict: "agree", digest }] }));
|
|
490
|
+
const [r] = (await query("review")).records;
|
|
491
|
+
expect(r.digest).toBe(digest);
|
|
492
|
+
expect(r.quorum).toMatchObject({ agreed: 2, met: true });
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// ── 2. Optional lifecycle, and supersedes of bare ids ────────────────────────
|
|
497
|
+
|
|
498
|
+
describe("kinds without a lifecycle", () => {
|
|
499
|
+
test("a stateless kind's records have state null and are valid", async () => {
|
|
500
|
+
write("design/drivers/closures/D-001.json", json({ driver: "D-001", closed_at: "2026-09-24T11:00:00.000Z", by: "alex" }));
|
|
501
|
+
write("design/drivers/closures/D-002.json", json({ driver: "D-002", closed_at: "2026-09-24T12:00:00.000Z", by: "bo", note: "done" }));
|
|
502
|
+
const doc = await query("closure");
|
|
503
|
+
expect(doc.records.map((r) => [r.id, r.state, r.valid, r.supersededBy])).toEqual([
|
|
504
|
+
["D-001", null, true, null],
|
|
505
|
+
["D-002", null, true, null],
|
|
506
|
+
]);
|
|
507
|
+
expect(doc.summary).toEqual({ total: 2, valid: 2, invalid: 0, superseded: 0 });
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const contract = (id: string, status: string, supersedes?: string | string[]) =>
|
|
511
|
+
`---\nid: "${id}"\nstatus: "${status}"\n${supersedes === undefined ? "" : `supersedes: ${JSON.stringify(supersedes)}\n`}---\n\n# ${id}\n`;
|
|
512
|
+
|
|
513
|
+
test("supersedes as one id, as chud's contracts write it, derives supersededBy under approval ranks", async () => {
|
|
514
|
+
write("design/contracts/C-001-first.md", contract("C-001", "approved"));
|
|
515
|
+
write("design/contracts/C-002-second.md", contract("C-002", "approved", "C-001"));
|
|
516
|
+
write("design/contracts/C-003-draft.md", contract("C-003", "draft", "C-002"));
|
|
517
|
+
write("design/contracts/C-004-typo.md", contract("C-004", "approved", "C-999"));
|
|
518
|
+
const doc = await query("contract");
|
|
519
|
+
const by = Object.fromEntries(doc.records.map((r) => [r.id, r]));
|
|
520
|
+
expect(by["C-001"].supersededBy).toBe("C-002");
|
|
521
|
+
expect(by["C-002"].supersededBy).toBeNull();
|
|
522
|
+
expect(warned(by["C-003"])).toEqual(["record-supersedes-pending"]);
|
|
523
|
+
expect(codes(by["C-004"])).toEqual(["record-supersedes-unknown"]);
|
|
524
|
+
expect(doc.summary.superseded).toBe(1);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("supersedes as a list of ids works the same way", async () => {
|
|
528
|
+
write("design/contracts/C-001-a.md", contract("C-001", "approved"));
|
|
529
|
+
write("design/contracts/C-002-b.md", contract("C-002", "approved"));
|
|
530
|
+
write("design/contracts/C-003-c.md", contract("C-003", "retired", ["C-001", "C-002"]));
|
|
531
|
+
write("design/contracts/C-004-d.md", contract("C-004", "draft", ["C-003"]));
|
|
532
|
+
const doc = await query("contract");
|
|
533
|
+
expect(doc.records.map((r) => [r.id, r.supersededBy])).toEqual([
|
|
534
|
+
["C-001", "C-003"],
|
|
535
|
+
["C-002", "C-003"],
|
|
536
|
+
["C-003", null],
|
|
537
|
+
["C-004", null],
|
|
538
|
+
]);
|
|
539
|
+
expect(warned(doc.records[3])).toEqual(["record-supersedes-pending"]);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test.each([
|
|
543
|
+
["supersedes without states", { ...KINDS.closure, supersedes: { field: "supersedes" } }, /supersedes: a kind without states cannot have supersedes/],
|
|
544
|
+
["approval without states", { ...KINDS.closure, approval: { a: 1 } }, /approval: a kind without states cannot have approval ranks/],
|
|
545
|
+
["states without a stateField", { ...KINDS.closure, states: ["open"], closedStates: [] }, /states: stateField, states and closedStates are given together/],
|
|
546
|
+
["both idField and idFrom", { ...KINDS.closure, idFrom: "sha256" }, /idField: a kind names its id with exactly one of idField and idFrom/],
|
|
547
|
+
["neither idField nor idFrom", { ...KINDS.closure, idField: undefined }, /idField: a kind names its id with exactly one of idField and idFrom/],
|
|
548
|
+
["an unknown format", { ...KINDS.closure, format: "yaml" }, /format/],
|
|
549
|
+
["a session block without states", { ...KINDS.closure, session: { verdicts: "approvals", seal: "seal", subjects: { kind: "contract.kind.mjs" } } }, /session: a session kind must have states/],
|
|
550
|
+
])("a kind with %s is kind-invalid, naming the field", async (_label, kind, why) => {
|
|
551
|
+
write("design/closure.kind.mjs", kindFile(kind));
|
|
552
|
+
const error = await failure("closure");
|
|
553
|
+
expect(error.code).toBe("kind-invalid");
|
|
554
|
+
expect(error.message).toMatch(why);
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// ── 3. Schema files a schema references ──────────────────────────────────────
|
|
559
|
+
|
|
560
|
+
describe("schema.refs", () => {
|
|
561
|
+
test("each named file is loaded, by its $id", async () => {
|
|
562
|
+
const loaded = await loadRecordKind(join(root, "design", "unit.kind.mjs"));
|
|
563
|
+
expect(loaded.refs.map((s) => s.$id)).toEqual([`${BASE}/defs.schema.json`]);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("a missing file is schema-unreadable, naming it", async () => {
|
|
567
|
+
rmSync(join(root, "design", "schemas", "defs.schema.json"));
|
|
568
|
+
const error = await failure("unit");
|
|
569
|
+
expect(error.code).toBe("schema-unreadable");
|
|
570
|
+
expect(error.message).toMatch(/schemas\/defs\.schema\.json/);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("a file whose $id differs is schema-id-mismatch, naming it", async () => {
|
|
574
|
+
write("design/schemas/defs.schema.json", json({ ...DEFS, $id: `${BASE}/other.schema.json` }));
|
|
575
|
+
const error = await failure("unit");
|
|
576
|
+
expect(error.code).toBe("schema-id-mismatch");
|
|
577
|
+
expect(error.message).toMatch(/schemas\/defs\.schema\.json has \$id/);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("without refs, a schema whose $ref resolves nowhere is still schema-invalid", async () => {
|
|
581
|
+
write("design/unit.kind.mjs", kindFile({ ...KINDS.unit, schema: { id: UNIT_SCHEMA.$id, path: "schemas/unit.schema.json" } }));
|
|
582
|
+
write("design/units/U-0001.json", json(unit("U-0001")));
|
|
583
|
+
const error = await failure("unit");
|
|
584
|
+
expect(error.code).toBe("schema-invalid");
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// ── 4. Content-addressed ids ─────────────────────────────────────────────────
|
|
589
|
+
|
|
590
|
+
describe("idFrom sha256", () => {
|
|
591
|
+
test("chud's evidence reads with the hash of its bytes as its id, in the tree and under --at", async () => {
|
|
592
|
+
const h = writeEvidence();
|
|
593
|
+
const bytes = readFileSync(join(root, "design", "evidence", `${h}.json`));
|
|
594
|
+
expect(createHash("sha256").update(bytes).digest("hex")).toBe(h);
|
|
595
|
+
const tree = await query("evidence");
|
|
596
|
+
expect(tree.records.map((r) => [r.id, r.state, r.valid, r.assets, r.warnings])).toEqual([[h, null, true, [], []]]);
|
|
597
|
+
commit();
|
|
598
|
+
const at = await query("evidence", "HEAD");
|
|
599
|
+
expect(at.records.map((r) => [r.id, r.valid, r.assets])).toEqual([[h, true, []]]);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test("a file that changes keeps its name: valid, a new id, and itself pinned drifted; a unit pinning it drifts too", async () => {
|
|
603
|
+
const h = writeEvidence();
|
|
604
|
+
const path = `design/evidence/${h}.json`;
|
|
605
|
+
write("design/units/U-0001.json", json(unit("U-0001", { outcome: "done", evidence: [h, { path, sha256: h }] })));
|
|
606
|
+
const before = await query("unit");
|
|
607
|
+
expect(before.records[0].assets).toEqual([{ path, sha256: h, actual: h, state: "pinned" }]);
|
|
608
|
+
|
|
609
|
+
const edited = json({ ...EVIDENCE, ok: false });
|
|
610
|
+
write(path, edited);
|
|
611
|
+
const actual = sha256(edited);
|
|
612
|
+
const [r] = (await query("evidence")).records;
|
|
613
|
+
expect(r.valid).toBe(true);
|
|
614
|
+
expect(r.id).toBe(actual);
|
|
615
|
+
expect(r.assets).toEqual([{ path, sha256: h, actual, state: "drifted" }]);
|
|
616
|
+
expect(warned(r)).toEqual(["asset-drift"]);
|
|
617
|
+
|
|
618
|
+
const [u] = (await query("unit")).records;
|
|
619
|
+
expect(u.assets).toEqual([{ path, sha256: h, actual, state: "drifted" }]);
|
|
620
|
+
expect(warned(u)).toEqual(["asset-drift"]);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test("the drift shows under --at as well", async () => {
|
|
624
|
+
const h = writeEvidence();
|
|
625
|
+
write(`design/evidence/${h}.json`, json({ ...EVIDENCE, output: "0 passed" }));
|
|
626
|
+
commit();
|
|
627
|
+
const [r] = (await query("evidence", "HEAD")).records;
|
|
628
|
+
expect(r.assets[0]).toMatchObject({ sha256: h, state: "drifted" });
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test("a name that claims no hash warns asset-drift and lists nothing it cannot pin", async () => {
|
|
632
|
+
write("design/evidence.kind.mjs", kindFile({ ...KINDS.evidence, location: { dir: "evidence", match: "\\.json$" } }));
|
|
633
|
+
write("design/evidence/latest.json", json(EVIDENCE));
|
|
634
|
+
const [r] = (await query("evidence")).records;
|
|
635
|
+
expect(r.valid).toBe(true);
|
|
636
|
+
expect(r.assets).toEqual([]);
|
|
637
|
+
expect(warned(r)).toEqual(["asset-drift"]);
|
|
638
|
+
expect(r.warnings[0].message).toMatch(/claims no sha256/);
|
|
639
|
+
});
|
|
640
|
+
});
|