@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
package/src/workspace/records.ts
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A record kind is data. It says where its records live, which JSON Schema
|
|
5
5
|
* they follow and which of their states are closed. This module reads every
|
|
6
|
-
* record a kind locates, parses its front matter as the
|
|
6
|
+
* record a kind locates, parses its structured core (the front matter, as the
|
|
7
|
+
* JSON subset of YAML, or the whole file as one I-JSON object, ws-053),
|
|
7
8
|
* validates it against the kind's schema and derives supersession from the
|
|
8
|
-
* records' own `supersedes` links. It never writes a record.
|
|
9
|
+
* records' own `supersedes` links. It never writes a record; `records-write.ts`
|
|
10
|
+
* does, through the rules here (#2670).
|
|
9
11
|
*
|
|
10
12
|
* A record that fails any of that is still returned, with reason codes, and the
|
|
11
13
|
* read succeeds. Only a failure to read the kind, its schema or the revision is
|
|
@@ -16,6 +18,8 @@
|
|
|
16
18
|
* runs. The level-0 goldens (#2526) fail if a level-0 command loads it.
|
|
17
19
|
*/
|
|
18
20
|
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { sha256Hex } from "../content-digest";
|
|
19
23
|
import { readFileSync, statSync } from "node:fs";
|
|
20
24
|
import { dirname, posix, relative, resolve, sep } from "node:path";
|
|
21
25
|
import yaml from "js-yaml";
|
|
@@ -23,8 +27,10 @@ import { z } from "zod";
|
|
|
23
27
|
import { importLexiconModule, registerLexiconDeclarations } from "../lexicon-module";
|
|
24
28
|
import type { ReasonCode } from "./reason-codes";
|
|
25
29
|
import { checkPins, pinEntries, type AssetPin } from "./record-assets";
|
|
30
|
+
import { joinSessions, type SessionCitation } from "./record-sessions";
|
|
26
31
|
import type { RecordSource } from "./record-source";
|
|
27
32
|
import type { WorkspaceTree } from "./tree";
|
|
33
|
+
import type { DecisionWork, WorkLink, WorkWarningCode } from "./work";
|
|
28
34
|
|
|
29
35
|
// ── Reason codes ─────────────────────────────────────────────────────────────
|
|
30
36
|
|
|
@@ -33,9 +39,9 @@ import type { WorkspaceTree } from "./tree";
|
|
|
33
39
|
* and a new code is a contract change (#2536).
|
|
34
40
|
*/
|
|
35
41
|
export const RECORD_REASON_CODES = [
|
|
36
|
-
/** No front matter, a YAML error
|
|
42
|
+
/** No front matter, a YAML error or a value outside the JSON subset of YAML, or for a JSON kind a file that is not one object or repeats a member name. */
|
|
37
43
|
"record-unparseable",
|
|
38
|
-
/** The front matter does not match the kind's schema. */
|
|
44
|
+
/** The record's front matter, or its JSON object, does not match the kind's schema. */
|
|
39
45
|
"record-schema-invalid",
|
|
40
46
|
/** Another record earlier in path order has the same id. */
|
|
41
47
|
"record-id-duplicate",
|
|
@@ -43,6 +49,10 @@ export const RECORD_REASON_CODES = [
|
|
|
43
49
|
"record-supersedes-unknown",
|
|
44
50
|
/** A second closed record supersedes a record another one already superseded. */
|
|
45
51
|
"record-supersedes-conflict",
|
|
52
|
+
/** A closed session's seal is not the digest of its text: it changed after it closed (#2673). */
|
|
53
|
+
"session-seal-mismatch",
|
|
54
|
+
/** A session's verdict names a record the kind's subject records do not have (#2673). */
|
|
55
|
+
"session-verdict-unknown-record",
|
|
46
56
|
] as const satisfies readonly ReasonCode[];
|
|
47
57
|
export type RecordReasonCode = (typeof RECORD_REASON_CODES)[number];
|
|
48
58
|
|
|
@@ -68,11 +78,66 @@ export const RECORD_WARNING_CODES = [
|
|
|
68
78
|
* product's own design flow with nothing to cite (#2654).
|
|
69
79
|
*/
|
|
70
80
|
"record-no-evidence",
|
|
81
|
+
/**
|
|
82
|
+
* A verdict in the kind's reviews list names no `digest`, so it is not bound
|
|
83
|
+
* to the text it judged. It still counts toward the quorum, and an
|
|
84
|
+
* amendment does not stop it counting (#2672).
|
|
85
|
+
*/
|
|
86
|
+
"review-undigested",
|
|
71
87
|
] as const satisfies readonly ReasonCode[];
|
|
72
88
|
export type RecordWarningCode = (typeof RECORD_WARNING_CODES)[number];
|
|
73
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Why a verdict does not count toward a record's quorum (#2671). Closed, like
|
|
92
|
+
* the reason codes. A verdict with none of these counts.
|
|
93
|
+
*/
|
|
94
|
+
export const REVIEW_REASON_CODES = [
|
|
95
|
+
/** The reviewer is the record's decider. */
|
|
96
|
+
"review-decider",
|
|
97
|
+
/** The reviewer holds the agent role in the trust policy at base. */
|
|
98
|
+
"review-agent",
|
|
99
|
+
/** A later verdict by the same principal, after normalising, replaces this one. */
|
|
100
|
+
"review-duplicate",
|
|
101
|
+
/** The verdict's digest is not the digest of the record's text now: the record changed after the verdict (#2672). */
|
|
102
|
+
"review-older-digest",
|
|
103
|
+
/**
|
|
104
|
+
* An attestation policy is active at base, and the verdict's seal does not
|
|
105
|
+
* verify for its reviewer: it has none, the reviewer has no key in the
|
|
106
|
+
* signers file, or the signature fails (#2687).
|
|
107
|
+
*/
|
|
108
|
+
"review-unattested",
|
|
109
|
+
] as const satisfies readonly ReasonCode[];
|
|
110
|
+
export type ReviewReasonCode = (typeof REVIEW_REASON_CODES)[number];
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Why a verdict's seal is not attested (#2687). Closed, like the reason
|
|
114
|
+
* codes. Every verdict in the quorum carries one of these in its
|
|
115
|
+
* `attestation`, unless its seal verified.
|
|
116
|
+
*/
|
|
117
|
+
export const SEAL_REASON_CODES = [
|
|
118
|
+
/** The verdict carries no seal. */
|
|
119
|
+
"seal-missing",
|
|
120
|
+
/** The reviewer has no key in the signers file at base. */
|
|
121
|
+
"seal-signer-unlisted",
|
|
122
|
+
/** The seal is malformed, names another signer, or its signature does not verify over the verdict. */
|
|
123
|
+
"seal-signature-invalid",
|
|
124
|
+
/** Nothing here can say whose seal it is: there is no signers file at base, or ssh-keygen is not installed. */
|
|
125
|
+
"seal-unverifiable",
|
|
126
|
+
] as const satisfies readonly ReasonCode[];
|
|
127
|
+
export type SealCode = (typeof SEAL_REASON_CODES)[number];
|
|
128
|
+
|
|
129
|
+
/** What a verdict's seal establishes (#2687). See `trust/seal.ts`. */
|
|
130
|
+
export interface VerdictAttestation {
|
|
131
|
+
/** Why the verdict is not attested. Absent when its seal verified. */
|
|
132
|
+
code?: SealCode;
|
|
133
|
+
message: string;
|
|
134
|
+
/** The fingerprint of the key that made the signature, when it was checked. */
|
|
135
|
+
key?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
74
138
|
export interface RecordWarning {
|
|
75
|
-
|
|
139
|
+
/** A work kind's records also carry the codes of `WORK_WARNING_CODES` (#2683). */
|
|
140
|
+
code: RecordWarningCode | WorkWarningCode;
|
|
76
141
|
message: string;
|
|
77
142
|
}
|
|
78
143
|
|
|
@@ -114,6 +179,10 @@ export class RecordReadError extends Error {
|
|
|
114
179
|
|
|
115
180
|
const idPattern = /^[a-z][a-z0-9-]*$/;
|
|
116
181
|
|
|
182
|
+
/** The formats a kind file may name. Closed. */
|
|
183
|
+
export const RECORD_FORMATS = ["markdown-front-matter", "json"] as const;
|
|
184
|
+
export type RecordFormat = (typeof RECORD_FORMATS)[number];
|
|
185
|
+
|
|
117
186
|
/** The data a kind file exports as `recordKind`. */
|
|
118
187
|
export const recordKindSchema = z
|
|
119
188
|
.object({
|
|
@@ -127,25 +196,49 @@ export const recordKindSchema = z
|
|
|
127
196
|
match: z.string().min(1),
|
|
128
197
|
})
|
|
129
198
|
.strict(),
|
|
130
|
-
/**
|
|
131
|
-
|
|
199
|
+
/**
|
|
200
|
+
* How a file holds its record's structured core: `markdown-front-matter`,
|
|
201
|
+
* the YAML front matter of a Markdown file, or `json`, the whole file as
|
|
202
|
+
* one JSON object (ws-053).
|
|
203
|
+
*/
|
|
204
|
+
format: z.enum(RECORD_FORMATS),
|
|
132
205
|
schema: z
|
|
133
206
|
.object({
|
|
134
207
|
/** The schema's `$id`. A schema file with a different `$id` is refused. */
|
|
135
208
|
id: z.string().min(1),
|
|
136
209
|
/** The schema file, relative to the kind file's directory. */
|
|
137
210
|
path: z.string().min(1),
|
|
211
|
+
/**
|
|
212
|
+
* Schema files the schema `$ref`s, each by its `$id` and path relative
|
|
213
|
+
* to the kind file's directory (ws-053). Each is checked for its `$id`
|
|
214
|
+
* and added to the validator before the schema compiles. Optional.
|
|
215
|
+
*/
|
|
216
|
+
refs: z.array(z.object({ id: z.string().min(1), path: z.string().min(1) }).strict()).optional(),
|
|
138
217
|
})
|
|
139
218
|
.strict(),
|
|
140
|
-
/** The
|
|
141
|
-
idField: z.string().min(1),
|
|
142
|
-
/**
|
|
143
|
-
|
|
144
|
-
|
|
219
|
+
/** The field holding the record's id. A kind has this or `idFrom`, never both. */
|
|
220
|
+
idField: z.string().min(1).optional(),
|
|
221
|
+
/**
|
|
222
|
+
* `sha256`: the record's id is the lowercase hex SHA-256 of the file's
|
|
223
|
+
* bytes, and the file name's stem (up to its first `.`) is the hash the
|
|
224
|
+
* name claims (ws-053). In place of `idField`.
|
|
225
|
+
*/
|
|
226
|
+
idFrom: z.literal("sha256").optional(),
|
|
227
|
+
/**
|
|
228
|
+
* The field holding the record's state. `stateField`, `states` and
|
|
229
|
+
* `closedStates` are given together or not at all; a kind without them
|
|
230
|
+
* has no lifecycle, and its records have state null (ws-053).
|
|
231
|
+
*/
|
|
232
|
+
stateField: z.string().min(1).optional(),
|
|
233
|
+
states: z.array(z.string().min(1)).min(1).optional(),
|
|
145
234
|
/** States whose records are final. A `supersedes` link takes effect only from a record in one of them. */
|
|
146
|
-
closedStates: z.array(z.string().min(1)),
|
|
147
|
-
/**
|
|
148
|
-
|
|
235
|
+
closedStates: z.array(z.string().min(1)).optional(),
|
|
236
|
+
/**
|
|
237
|
+
* The field of links to superseded records. With `key`, a list of objects
|
|
238
|
+
* whose `key` holds the target id; without it, one id or a list of ids
|
|
239
|
+
* (ws-053). Optional, and only on a kind with states.
|
|
240
|
+
*/
|
|
241
|
+
supersedes: z.object({ field: z.string().min(1), key: z.string().min(1).optional() }).strict().optional(),
|
|
149
242
|
/**
|
|
150
243
|
* How strongly each state is approved (#2524 D4). With it, a supersedes
|
|
151
244
|
* link takes effect when the new record's rank is above 0 and at least the
|
|
@@ -165,15 +258,84 @@ export const recordKindSchema = z
|
|
|
165
258
|
* (#2549). Optional.
|
|
166
259
|
*/
|
|
167
260
|
constrains: z.object({ field: z.string().min(1) }).strict().optional(),
|
|
261
|
+
/**
|
|
262
|
+
* The front-matter list of review verdicts and the field naming the
|
|
263
|
+
* decider (#2671, #2672). With it, each record gets a digest of its text
|
|
264
|
+
* without that list, and `records --json` computes its quorum. Each
|
|
265
|
+
* entry holds `reviewer`, `verdict` (agree, dissent or abstain) and
|
|
266
|
+
* optionally `digest`, `note`, `proposes`, `addressed_by` and
|
|
267
|
+
* `withdrawn_on`. Optional.
|
|
268
|
+
*/
|
|
269
|
+
reviews: z.object({ field: z.string().min(1), decider: z.string().min(1) }).strict().optional(),
|
|
270
|
+
/**
|
|
271
|
+
* A review-session kind (#2673, #2650 C10): the front-matter list of the
|
|
272
|
+
* verdicts a session produced, the field that seals a closed session, and
|
|
273
|
+
* the records its verdicts name, as the kind file that locates them
|
|
274
|
+
* (relative to this kind file's directory). The entries of that kind's
|
|
275
|
+
* reviews list (its `reviews.field`, or `reviews`) name a session in
|
|
276
|
+
* `session`. Optional.
|
|
277
|
+
*/
|
|
278
|
+
session: z
|
|
279
|
+
.object({
|
|
280
|
+
verdicts: z.string().min(1),
|
|
281
|
+
seal: z.string().min(1),
|
|
282
|
+
subjects: z.object({ kind: z.string().min(1) }).strict(),
|
|
283
|
+
})
|
|
284
|
+
.strict()
|
|
285
|
+
.optional(),
|
|
286
|
+
/**
|
|
287
|
+
* A work kind (#2683): the front-matter lists of the work ids a record
|
|
288
|
+
* needs and the decision ids it implements, the decision kind file those
|
|
289
|
+
* ids name (relative to this kind file), the state a ready record is in,
|
|
290
|
+
* the state that satisfies a need, and the field holding the closing
|
|
291
|
+
* date. With it, `work.ts` gives each record `ready`, `blockedBy` and
|
|
292
|
+
* `implements`, and the read lists each decision with `implementedBy`.
|
|
293
|
+
* Optional.
|
|
294
|
+
*/
|
|
295
|
+
work: z
|
|
296
|
+
.object({
|
|
297
|
+
needs: z.string().min(1),
|
|
298
|
+
implements: z.string().min(1),
|
|
299
|
+
decisions: z.string().min(1),
|
|
300
|
+
open: z.string().min(1),
|
|
301
|
+
done: z.string().min(1),
|
|
302
|
+
closedOn: z.string().min(1),
|
|
303
|
+
})
|
|
304
|
+
.strict()
|
|
305
|
+
.optional(),
|
|
168
306
|
})
|
|
169
307
|
.strict()
|
|
170
|
-
.refine((k) => k.
|
|
308
|
+
.refine((k) => (k.idField === undefined) !== (k.idFrom === undefined), {
|
|
309
|
+
message: "a kind names its id with exactly one of idField and idFrom",
|
|
310
|
+
path: ["idField"],
|
|
311
|
+
})
|
|
312
|
+
.refine((k) => new Set([k.stateField === undefined, k.states === undefined, k.closedStates === undefined]).size === 1, {
|
|
313
|
+
message: "stateField, states and closedStates are given together or not at all",
|
|
314
|
+
path: ["states"],
|
|
315
|
+
})
|
|
316
|
+
.refine((k) => k.states !== undefined || k.supersedes === undefined, {
|
|
317
|
+
message: "a kind without states cannot have supersedes: a link takes effect only from a closed or ranked state",
|
|
318
|
+
path: ["supersedes"],
|
|
319
|
+
})
|
|
320
|
+
.refine((k) => k.states !== undefined || k.session === undefined, {
|
|
321
|
+
message: "a session kind must have states: a session is sealed when it reaches a closed state",
|
|
322
|
+
path: ["session"],
|
|
323
|
+
})
|
|
324
|
+
.refine((k) => k.states !== undefined || k.approval === undefined, {
|
|
325
|
+
message: "a kind without states cannot have approval ranks",
|
|
326
|
+
path: ["approval"],
|
|
327
|
+
})
|
|
328
|
+
.refine((k) => (k.closedStates ?? []).every((s) => (k.states ?? []).includes(s)), {
|
|
171
329
|
message: "every closed state must be listed in states",
|
|
172
330
|
path: ["closedStates"],
|
|
173
331
|
})
|
|
174
|
-
.refine((k) => Object.keys(k.approval ?? {}).every((s) => k.states.includes(s)), {
|
|
332
|
+
.refine((k) => Object.keys(k.approval ?? {}).every((s) => (k.states ?? []).includes(s)), {
|
|
175
333
|
message: "every state approval ranks must be listed in states",
|
|
176
334
|
path: ["approval"],
|
|
335
|
+
})
|
|
336
|
+
.refine((k) => !k.work || (k.states !== undefined && k.states.includes(k.work.open) && k.states.includes(k.work.done)), {
|
|
337
|
+
message: "a work kind must have states, and its work open and done states must be listed in them",
|
|
338
|
+
path: ["work"],
|
|
177
339
|
});
|
|
178
340
|
|
|
179
341
|
export type RecordKind = z.infer<typeof recordKindSchema>;
|
|
@@ -186,6 +348,8 @@ export interface LoadedRecordKind {
|
|
|
186
348
|
/** Absolute path of the records directory in the working tree. */
|
|
187
349
|
dir: string;
|
|
188
350
|
schema: Record<string, unknown>;
|
|
351
|
+
/** The schema files `schema.refs` names, in order (ws-053). Empty without it. */
|
|
352
|
+
refs: Record<string, unknown>[];
|
|
189
353
|
}
|
|
190
354
|
|
|
191
355
|
/**
|
|
@@ -225,20 +389,26 @@ export async function loadRecordKind(path: string, cwd: string = process.cwd()):
|
|
|
225
389
|
}
|
|
226
390
|
const kind = parsed.data;
|
|
227
391
|
const base = dirname(file);
|
|
228
|
-
const
|
|
392
|
+
const schema = readSchemaFile(kind, base, path, kind.schema);
|
|
393
|
+
const refs = (kind.schema.refs ?? []).map((ref) => readSchemaFile(kind, base, path, ref));
|
|
394
|
+
return { kind, file, dir: resolve(base, kind.location.dir), schema, refs };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** A schema file a kind names, refused when it can't be read or its `$id` is not the one the kind names. */
|
|
398
|
+
function readSchemaFile(kind: RecordKind, base: string, kindPath: string, named: { id: string; path: string }): Record<string, unknown> {
|
|
229
399
|
let schema: Record<string, unknown>;
|
|
230
400
|
try {
|
|
231
|
-
schema = JSON.parse(readFileSync(
|
|
401
|
+
schema = JSON.parse(readFileSync(resolve(base, named.path), "utf-8")) as Record<string, unknown>;
|
|
232
402
|
} catch (err) {
|
|
233
|
-
throw new RecordReadError("schema-unreadable", `schema ${
|
|
403
|
+
throw new RecordReadError("schema-unreadable", `schema ${named.path} named by ${kindPath} could not be read: ${message(err)}`);
|
|
234
404
|
}
|
|
235
|
-
if (schema.$id !==
|
|
405
|
+
if (schema === null || typeof schema !== "object" || schema.$id !== named.id) {
|
|
236
406
|
throw new RecordReadError(
|
|
237
407
|
"schema-id-mismatch",
|
|
238
|
-
`kind ${kind.name} names schema ${
|
|
408
|
+
`kind ${kind.name} names schema ${named.id}, but ${named.path} has $id ${JSON.stringify(schema?.$id)}`,
|
|
239
409
|
);
|
|
240
410
|
}
|
|
241
|
-
return
|
|
411
|
+
return schema;
|
|
242
412
|
}
|
|
243
413
|
|
|
244
414
|
// ── Front matter ─────────────────────────────────────────────────────────────
|
|
@@ -268,6 +438,120 @@ export function parseFrontMatter(text: string): FrontMatter {
|
|
|
268
438
|
return { ok: true, value: value as Record<string, unknown> };
|
|
269
439
|
}
|
|
270
440
|
|
|
441
|
+
// ── JSON records ─────────────────────────────────────────────────────────────
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* A JSON record: the whole file is one object, read as I-JSON (RFC 7493)
|
|
445
|
+
* requires on the two points JSON.parse lets through (ws-053). A top-level
|
|
446
|
+
* value other than an object is refused, and so is a member name that repeats
|
|
447
|
+
* within one object, at any depth, which JSON.parse accepts by keeping the
|
|
448
|
+
* last value. Names compare after their escapes are decoded, so `"a"` and
|
|
449
|
+
* `"\u0061"` are the same name. A number too large for a double is refused as
|
|
450
|
+
* front matter's is.
|
|
451
|
+
*/
|
|
452
|
+
export function parseJsonRecord(text: string): FrontMatter {
|
|
453
|
+
let value: unknown;
|
|
454
|
+
try {
|
|
455
|
+
value = JSON.parse(text);
|
|
456
|
+
} catch (err) {
|
|
457
|
+
return { ok: false, message: `not valid JSON: ${message(err).split("\n")[0]}` };
|
|
458
|
+
}
|
|
459
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
460
|
+
return { ok: false, message: "a JSON record must be one object at the top level" };
|
|
461
|
+
}
|
|
462
|
+
const scan = scanJson(text);
|
|
463
|
+
if (scan.duplicate) return { ok: false, message: `${scan.duplicate.at || "/"}: member name ${JSON.stringify(scan.duplicate.name)} repeats, which I-JSON refuses` };
|
|
464
|
+
const problem = nonJson(value, "", new Set());
|
|
465
|
+
if (problem) return { ok: false, message: problem };
|
|
466
|
+
return { ok: true, value: value as Record<string, unknown> };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** A record's structured core, parsed as the kind's format says. */
|
|
470
|
+
export function parseRecord(format: RecordFormat, text: string): FrontMatter {
|
|
471
|
+
return format === "json" ? parseJsonRecord(text) : parseFrontMatter(text);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Where one top-level member of a JSON object sits in its text. */
|
|
475
|
+
interface JsonMember {
|
|
476
|
+
name: string;
|
|
477
|
+
/** Index of the opening quote of the member's name. */
|
|
478
|
+
start: number;
|
|
479
|
+
/** Index just past the last character of the member's value. */
|
|
480
|
+
end: number;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* One pass over text JSON.parse accepted: the top-level object's members, with
|
|
485
|
+
* where each starts and ends, and the first member name that repeats within
|
|
486
|
+
* one object at any depth.
|
|
487
|
+
*/
|
|
488
|
+
function scanJson(text: string): { members: JsonMember[]; duplicate?: { name: string; at: string } } {
|
|
489
|
+
const members: JsonMember[] = [];
|
|
490
|
+
// One frame per open object or array: an object's names so far, or null for an array.
|
|
491
|
+
const stack: { names: Set<string> | null; path: string; key?: string; index: number }[] = [];
|
|
492
|
+
let pendingTop: { name: string; start: number } | undefined;
|
|
493
|
+
let valueStart = -1;
|
|
494
|
+
const closeValue = (end: number): void => {
|
|
495
|
+
// A value just ended at `end`; if it is a top-level member's value, record the member.
|
|
496
|
+
if (stack.length === 1 && pendingTop) {
|
|
497
|
+
members.push({ ...pendingTop, end });
|
|
498
|
+
pendingTop = undefined;
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
let i = 0;
|
|
502
|
+
while (i < text.length) {
|
|
503
|
+
const c = text[i];
|
|
504
|
+
if (c === '"') {
|
|
505
|
+
let j = i + 1;
|
|
506
|
+
while (text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
|
|
507
|
+
const raw = text.slice(i, j + 1);
|
|
508
|
+
let k = j + 1;
|
|
509
|
+
while (k < text.length && /[ \t\n\r]/.test(text[k])) k++;
|
|
510
|
+
const top = stack[stack.length - 1];
|
|
511
|
+
if (text[k] === ":" && top?.names) {
|
|
512
|
+
const name = JSON.parse(raw) as string;
|
|
513
|
+
if (top.names.has(name)) return { members, duplicate: { name, at: top.path } };
|
|
514
|
+
top.names.add(name);
|
|
515
|
+
top.key = name;
|
|
516
|
+
if (stack.length === 1) pendingTop = { name, start: i };
|
|
517
|
+
i = k + 1;
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
closeValue(j + 1);
|
|
521
|
+
i = j + 1;
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (c === "{" || c === "[") {
|
|
525
|
+
const parent = stack[stack.length - 1];
|
|
526
|
+
const at = parent ? `${parent.path}/${parent.names ? parent.key : parent.index}` : "";
|
|
527
|
+
stack.push({ names: c === "{" ? new Set() : null, path: at, index: 0 });
|
|
528
|
+
i++;
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if (c === "}" || c === "]") {
|
|
532
|
+
stack.pop();
|
|
533
|
+
closeValue(i + 1);
|
|
534
|
+
i++;
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (c === ",") {
|
|
538
|
+
const top = stack[stack.length - 1];
|
|
539
|
+
if (top && !top.names) top.index++;
|
|
540
|
+
i++;
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (/[ \t\n\r:]/.test(c)) {
|
|
544
|
+
i++;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
// A number, true, false or null.
|
|
548
|
+
valueStart = i;
|
|
549
|
+
while (i < text.length && !/[ \t\n\r,\]}]/.test(text[i])) i++;
|
|
550
|
+
if (valueStart < i) closeValue(i);
|
|
551
|
+
}
|
|
552
|
+
return { members };
|
|
553
|
+
}
|
|
554
|
+
|
|
271
555
|
/** The first thing in `v` that JSON cannot say, or that only an alias can produce. */
|
|
272
556
|
function nonJson(v: unknown, at: string, seen: Set<object>): string | undefined {
|
|
273
557
|
if (v === null || typeof v === "string" || typeof v === "boolean") return undefined;
|
|
@@ -290,6 +574,280 @@ function nonJson(v: unknown, at: string, seen: Set<object>): string | undefined
|
|
|
290
574
|
return undefined;
|
|
291
575
|
}
|
|
292
576
|
|
|
577
|
+
// ── Record digest ────────────────────────────────────────────────────────────
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* The digest a review verdict names: the lowercase hex SHA-256 of the record
|
|
581
|
+
* file's text with its reviews block taken out of the front matter (#2672), or
|
|
582
|
+
* for a JSON record its reviews member taken out of the object (ws-053).
|
|
583
|
+
* Adding, changing or removing a verdict leaves it as it was; any other edit
|
|
584
|
+
* to the file changes it, so a verdict given before an amendment stops
|
|
585
|
+
* counting.
|
|
586
|
+
*
|
|
587
|
+
* The rule, which a hand-editor can follow with a text editor and
|
|
588
|
+
* `sha256sum`:
|
|
589
|
+
*
|
|
590
|
+
* 1. Line endings become LF (CRLF and a lone CR each become one LF).
|
|
591
|
+
* 2. When the text starts with a `---` line and a later line is exactly
|
|
592
|
+
* `---`, the lines between them are the front matter. In it, the line
|
|
593
|
+
* that starts, at column 0, with the key `field` (bare, or in single or
|
|
594
|
+
* double quotes), optional spaces or tabs and a `:`, is removed, and so
|
|
595
|
+
* is every line after it, up to the closing `---`, that is empty or
|
|
596
|
+
* starts with a space, a tab, `#` or `-`. Removal stops at the first
|
|
597
|
+
* other line. Everything else, the `---` lines and the body included, is
|
|
598
|
+
* kept byte for byte.
|
|
599
|
+
* 3. The digest is the SHA-256 of the result's UTF-8 bytes.
|
|
600
|
+
*
|
|
601
|
+
* Text with no front matter, or no such key, is hashed after step 1 alone.
|
|
602
|
+
* A writer that adds a verdict must change only the reviews block: a
|
|
603
|
+
* reformatted front matter is a new digest, and every earlier verdict stops
|
|
604
|
+
* counting.
|
|
605
|
+
*/
|
|
606
|
+
export function recordTextDigest(text: string, field: string | null = "reviews", format: RecordFormat = "markdown-front-matter"): string {
|
|
607
|
+
const lf = text.replace(/\r\n?/g, "\n");
|
|
608
|
+
const kept = field === null ? lf : format === "json" ? withoutMember(lf, field) : withoutBlock(lf, field);
|
|
609
|
+
return createHash("sha256").update(kept, "utf8").digest("hex");
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* `text` with the top-level member `field` removed from a JSON record, by the
|
|
614
|
+
* rule a hand-editor follows for a JSON record (ws-053):
|
|
615
|
+
*
|
|
616
|
+
* 1. Line endings become LF, as for Markdown.
|
|
617
|
+
* 2. When the text is a JSON record (one object, no repeated member name) and
|
|
618
|
+
* its top-level object has a member named `field`, that member is deleted:
|
|
619
|
+
* the whitespace right before its name, the name, the colon, the value,
|
|
620
|
+
* and one comma with the whitespace right before that comma. The comma is
|
|
621
|
+
* the one after the value when another member follows, or else the one
|
|
622
|
+
* before the member, when a member precedes it. Nothing else changes: other
|
|
623
|
+
* members, their order, the indentation and the final newline stay byte
|
|
624
|
+
* for byte.
|
|
625
|
+
* 3. The digest is the SHA-256 of the result's UTF-8 bytes.
|
|
626
|
+
*
|
|
627
|
+
* So in a pretty-printed file, deleting the `"reviews": [...]` lines and the
|
|
628
|
+
* comma that separated the member from its neighbour gives the text to hash.
|
|
629
|
+
* Text that is not a JSON record, or has no such member, is hashed after step 1
|
|
630
|
+
* alone. A writer that adds a verdict changes only that member's value.
|
|
631
|
+
*/
|
|
632
|
+
function withoutMember(text: string, field: string): string {
|
|
633
|
+
const parsed = parseJsonRecord(text);
|
|
634
|
+
if (!parsed.ok) return text;
|
|
635
|
+
const { members } = scanJson(text);
|
|
636
|
+
const at = members.findIndex((m) => m.name === field);
|
|
637
|
+
if (at < 0) return text;
|
|
638
|
+
const m = members[at];
|
|
639
|
+
let start = m.start;
|
|
640
|
+
while (start > 0 && /[ \t\n]/.test(text[start - 1])) start--;
|
|
641
|
+
let end = m.end;
|
|
642
|
+
if (at + 1 < members.length) {
|
|
643
|
+
// The comma after the value, and the whitespace before it.
|
|
644
|
+
let k = end;
|
|
645
|
+
while (/[ \t\n]/.test(text[k])) k++;
|
|
646
|
+
end = k + 1;
|
|
647
|
+
} else if (at > 0) {
|
|
648
|
+
// The comma before the member, and the whitespace between it and the previous value.
|
|
649
|
+
let k = start - 1;
|
|
650
|
+
while (k > 0 && text[k] !== ",") k--;
|
|
651
|
+
while (k > 0 && /[ \t\n]/.test(text[k - 1])) k--;
|
|
652
|
+
start = k;
|
|
653
|
+
}
|
|
654
|
+
return text.slice(0, start) + text.slice(end);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** `text` with the top-level `field` block removed from its front matter, by the rule {@link recordTextDigest} states. */
|
|
658
|
+
function withoutBlock(text: string, field: string): string {
|
|
659
|
+
const lines = text.split("\n");
|
|
660
|
+
if (lines[0] !== "---") return text;
|
|
661
|
+
const close = lines.indexOf("---", 1);
|
|
662
|
+
if (close < 0) return text;
|
|
663
|
+
const key = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
664
|
+
const starts = new RegExp(`^(?:${key}|"${key}"|'${key}')[ \\t]*:(?:[ \\t]|$)`);
|
|
665
|
+
const out: string[] = [];
|
|
666
|
+
for (let i = 0; i < lines.length; i++) {
|
|
667
|
+
if (i > 0 && i < close && starts.test(lines[i])) {
|
|
668
|
+
while (i + 1 < close && /^(?:$|[ \t#-])/.test(lines[i + 1])) i++;
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
out.push(lines[i]);
|
|
672
|
+
}
|
|
673
|
+
return out.join("\n");
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ── Quorum ───────────────────────────────────────────────────────────────────
|
|
677
|
+
|
|
678
|
+
/** The quorum a workspace sets when its declaration names none: two verdicts besides the decider's (#2555). */
|
|
679
|
+
export const DEFAULT_QUORUM = 2;
|
|
680
|
+
|
|
681
|
+
/** A principal as the quorum compares it: NFKC, trimmed and lower-cased, so `alice` and `Alice ` are one reviewer (#2671). */
|
|
682
|
+
export function normalisePrincipal(name: string): string {
|
|
683
|
+
return name.normalize("NFKC").trim().toLowerCase();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** One verdict as the quorum reads it. */
|
|
687
|
+
export interface QuorumVerdict {
|
|
688
|
+
/** The entry's position in the record's reviews list, from 0. */
|
|
689
|
+
index: number;
|
|
690
|
+
/** The reviewer, normalised. */
|
|
691
|
+
principal: string;
|
|
692
|
+
/** The reviewer as written. */
|
|
693
|
+
reviewer: string;
|
|
694
|
+
verdict: "agree" | "dissent" | "abstain";
|
|
695
|
+
/** The digest the verdict names, or null when it names none. */
|
|
696
|
+
digest: string | null;
|
|
697
|
+
/**
|
|
698
|
+
* true when its seal verifies for the reviewer against the signers at base;
|
|
699
|
+
* false when a seal is missing under an active policy, or fails; null when
|
|
700
|
+
* nothing here can say (#2687).
|
|
701
|
+
*/
|
|
702
|
+
attested: boolean | null;
|
|
703
|
+
attestation: VerdictAttestation;
|
|
704
|
+
/** Why it does not count. Absent on a counted verdict. */
|
|
705
|
+
reason?: { code: ReviewReasonCode; message: string };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** A dissent that is neither addressed nor withdrawn. */
|
|
709
|
+
export interface OpenConcern {
|
|
710
|
+
index: number;
|
|
711
|
+
principal: string;
|
|
712
|
+
reviewer: string;
|
|
713
|
+
note: string | null;
|
|
714
|
+
/** The proposed decision the dissent opened, when it names one. */
|
|
715
|
+
proposes?: string;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export interface Quorum {
|
|
719
|
+
/** How many counted agree verdicts, besides the decider's, the record needs. */
|
|
720
|
+
need: number;
|
|
721
|
+
/** Whether `need` comes from the workspace declaration or is the default. */
|
|
722
|
+
needFrom: "declaration" | "default";
|
|
723
|
+
/** Counted verdicts that agree. */
|
|
724
|
+
agreed: number;
|
|
725
|
+
/** Verdicts that count: one per principal, the latest, after the exclusions. */
|
|
726
|
+
counted: QuorumVerdict[];
|
|
727
|
+
/** Verdicts that don't, each with its reason. */
|
|
728
|
+
notCounted: QuorumVerdict[];
|
|
729
|
+
openConcerns: OpenConcern[];
|
|
730
|
+
/** `agreed` reaches `need`. */
|
|
731
|
+
met: boolean;
|
|
732
|
+
/** `met`, with at least one open concern. A met quorum with an open concern is never consensus (RFC 7282). */
|
|
733
|
+
metWithObjections: boolean;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export interface QuorumOptions {
|
|
737
|
+
need: number;
|
|
738
|
+
needFrom: "declaration" | "default";
|
|
739
|
+
/** Normalised principals that hold the agent role. */
|
|
740
|
+
agents: ReadonlySet<string>;
|
|
741
|
+
/** Whether an attestation policy is active, so a verdict needs a seal to count. */
|
|
742
|
+
attestation: boolean;
|
|
743
|
+
/**
|
|
744
|
+
* Checks one verdict's seal (#2687), as `trust/seal.ts` does against the
|
|
745
|
+
* policy at base. Without it no seal is checked: a sealed verdict is
|
|
746
|
+
* `seal-unverifiable`, so under an active policy none counts.
|
|
747
|
+
*/
|
|
748
|
+
verifySeal?: (v: SealInput) => { attested: boolean | null } & VerdictAttestation;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** What a seal check reads of one verdict. */
|
|
752
|
+
export interface SealInput {
|
|
753
|
+
record: string | null;
|
|
754
|
+
reviewer: string;
|
|
755
|
+
verdict: string;
|
|
756
|
+
on: unknown;
|
|
757
|
+
digest: string | null;
|
|
758
|
+
seal: unknown;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** The check used when {@link QuorumOptions.verifySeal} is not given. */
|
|
762
|
+
function uncheckedSeal(attestation: boolean, v: SealInput): { attested: boolean | null } & VerdictAttestation {
|
|
763
|
+
if (v.seal === undefined || v.seal === null) {
|
|
764
|
+
return { attested: attestation ? false : null, code: "seal-missing", message: `the verdict by ${v.reviewer} carries no seal` };
|
|
765
|
+
}
|
|
766
|
+
return { attested: null, code: "seal-unverifiable", message: `the seal by ${v.reviewer} was not checked` };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const VERDICTS = new Set(["agree", "dissent", "abstain"]);
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* The quorum of one record, read from its reviews list, or null when the
|
|
773
|
+
* kind has no reviews list or the front matter could not be read (#2671).
|
|
774
|
+
* Malformed entries are skipped; the schema reports them.
|
|
775
|
+
*
|
|
776
|
+
* A verdict is not counted when its reviewer is the decider, holds the agent
|
|
777
|
+
* role, names a digest other than the record's own now, or, under an active
|
|
778
|
+
* attestation policy, carries no seal that verifies for its reviewer (#2687),
|
|
779
|
+
* in that order. Of the rest, the latest verdict per principal counts and
|
|
780
|
+
* each earlier one is a duplicate. Every verdict reports its seal's check in
|
|
781
|
+
* `attested` and `attestation`, whether it counts or not.
|
|
782
|
+
*/
|
|
783
|
+
export function computeQuorum(kind: RecordKind, entry: Pick<RecordEntry, "data" | "digest"> & { id?: string | null }, options: QuorumOptions): Quorum | null {
|
|
784
|
+
if (!kind.reviews || entry.data === null) return null;
|
|
785
|
+
const list = entry.data[kind.reviews.field];
|
|
786
|
+
const decidedBy = entry.data[kind.reviews.decider];
|
|
787
|
+
const decider = typeof decidedBy === "string" ? normalisePrincipal(decidedBy) : null;
|
|
788
|
+
const idValue = entry.id !== undefined ? entry.id : kind.idField !== undefined ? entry.data[kind.idField] : null;
|
|
789
|
+
const record = typeof idValue === "string" ? idValue : null;
|
|
790
|
+
const verdicts: QuorumVerdict[] = [];
|
|
791
|
+
const openConcerns: OpenConcern[] = [];
|
|
792
|
+
(Array.isArray(list) ? list : []).forEach((raw, index) => {
|
|
793
|
+
if (raw === null || typeof raw !== "object") return;
|
|
794
|
+
const r = raw as Record<string, unknown>;
|
|
795
|
+
if (typeof r.reviewer !== "string" || typeof r.verdict !== "string" || !VERDICTS.has(r.verdict)) return;
|
|
796
|
+
const digest = typeof r.digest === "string" ? r.digest : null;
|
|
797
|
+
const input: SealInput = { record, reviewer: r.reviewer, verdict: r.verdict, on: r.on, digest, seal: r.seal };
|
|
798
|
+
const { attested, ...attestation } = options.verifySeal ? options.verifySeal(input) : uncheckedSeal(options.attestation, input);
|
|
799
|
+
const v: QuorumVerdict = {
|
|
800
|
+
index,
|
|
801
|
+
principal: normalisePrincipal(r.reviewer),
|
|
802
|
+
reviewer: r.reviewer,
|
|
803
|
+
verdict: r.verdict as QuorumVerdict["verdict"],
|
|
804
|
+
digest,
|
|
805
|
+
attested,
|
|
806
|
+
attestation,
|
|
807
|
+
};
|
|
808
|
+
if (v.principal === decider) {
|
|
809
|
+
v.reason = { code: "review-decider", message: `${v.reviewer} decided this record, and the quorum counts verdicts besides the decider's` };
|
|
810
|
+
} else if (options.agents.has(v.principal)) {
|
|
811
|
+
v.reason = { code: "review-agent", message: `${v.reviewer} holds the agent role in the trust policy at base, and an agent's verdict does not count` };
|
|
812
|
+
} else if (v.digest !== null && v.digest !== entry.digest) {
|
|
813
|
+
v.reason = { code: "review-older-digest", message: `${v.reviewer} judged the text at digest ${v.digest.slice(0, 12)}, and the record's text is now at ${entry.digest.slice(0, 12)}` };
|
|
814
|
+
} else if (options.attestation && attested !== true) {
|
|
815
|
+
v.reason = { code: "review-unattested", message: `an attestation policy is active at base, and ${attestation.message}` };
|
|
816
|
+
}
|
|
817
|
+
verdicts.push(v);
|
|
818
|
+
if (v.verdict === "dissent" && r.addressed_by == null && r.withdrawn_on == null) {
|
|
819
|
+
openConcerns.push({
|
|
820
|
+
index,
|
|
821
|
+
principal: v.principal,
|
|
822
|
+
reviewer: v.reviewer,
|
|
823
|
+
note: typeof r.note === "string" ? r.note : null,
|
|
824
|
+
...(typeof r.proposes === "string" ? { proposes: r.proposes } : {}),
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
// The latest verdict per principal stands; earlier ones are duplicates.
|
|
829
|
+
const latest = new Map<string, QuorumVerdict>();
|
|
830
|
+
for (const v of verdicts) if (!v.reason) latest.set(v.principal, v);
|
|
831
|
+
for (const v of verdicts) {
|
|
832
|
+
if (v.reason || latest.get(v.principal) === v) continue;
|
|
833
|
+
const later = latest.get(v.principal)!;
|
|
834
|
+
v.reason = { code: "review-duplicate", message: `the later verdict by ${JSON.stringify(later.reviewer)} (entry ${later.index}) replaces this one, and a principal counts once` };
|
|
835
|
+
}
|
|
836
|
+
const counted = verdicts.filter((v) => !v.reason);
|
|
837
|
+
const agreed = counted.filter((v) => v.verdict === "agree").length;
|
|
838
|
+
const met = agreed >= options.need;
|
|
839
|
+
return {
|
|
840
|
+
need: options.need,
|
|
841
|
+
needFrom: options.needFrom,
|
|
842
|
+
agreed,
|
|
843
|
+
counted,
|
|
844
|
+
notCounted: verdicts.filter((v) => v.reason),
|
|
845
|
+
openConcerns,
|
|
846
|
+
met,
|
|
847
|
+
metWithObjections: met && openConcerns.length > 0,
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
293
851
|
// ── Reading ──────────────────────────────────────────────────────────────────
|
|
294
852
|
|
|
295
853
|
export interface RecordReason {
|
|
@@ -308,12 +866,26 @@ export interface RecordEntry {
|
|
|
308
866
|
reasons: RecordReason[];
|
|
309
867
|
/** The id of the closed record whose `supersedes` link replaces this one, or null. */
|
|
310
868
|
supersededBy: string | null;
|
|
311
|
-
/** The front matter
|
|
869
|
+
/** The record's structured core as JSON (the front matter, or the whole JSON file), or null when it could not be parsed. */
|
|
312
870
|
data: Record<string, unknown> | null;
|
|
313
|
-
/**
|
|
871
|
+
/**
|
|
872
|
+
* Each workspace file the record pins, checked against the tree read
|
|
873
|
+
* (#2549). Empty when nothing was checked. A content-addressed record whose
|
|
874
|
+
* name claims a hash other than its bytes' lists itself, drifted (ws-053).
|
|
875
|
+
*/
|
|
314
876
|
assets: AssetPin[];
|
|
315
877
|
/** Findings that leave the record valid, such as a pinned file that changed (#2549). */
|
|
316
878
|
warnings: RecordWarning[];
|
|
879
|
+
/** {@link recordTextDigest} of the file's text, without the kind's reviews list when it has one (#2672). */
|
|
880
|
+
digest: string;
|
|
881
|
+
/** For a session kind only: the subject records' review entries that name this session (#2673). */
|
|
882
|
+
citedBy?: SessionCitation[];
|
|
883
|
+
/** For a work kind (#2683): the record's state is its kind's open state and every need is done. */
|
|
884
|
+
ready?: boolean;
|
|
885
|
+
/** For a work kind: each need that is not done, with its state, or null when no record has the id. */
|
|
886
|
+
blockedBy?: WorkLink[];
|
|
887
|
+
/** For a work kind: each decision the record implements, with its state, or null when no decision has the id. */
|
|
888
|
+
implements?: WorkLink[];
|
|
317
889
|
}
|
|
318
890
|
|
|
319
891
|
export interface ReadRecordsOptions {
|
|
@@ -333,6 +905,18 @@ export interface ReadRecordsOptions {
|
|
|
333
905
|
* compared.
|
|
334
906
|
*/
|
|
335
907
|
history?: RecordHistory;
|
|
908
|
+
/**
|
|
909
|
+
* For a session kind: the records its `session.subjects.kind` locates, read
|
|
910
|
+
* from the same tree (#2673). Without them no verdict is checked and no
|
|
911
|
+
* session is cited.
|
|
912
|
+
*/
|
|
913
|
+
subjects?: { records: RecordEntry[]; reviews: string };
|
|
914
|
+
/**
|
|
915
|
+
* The workspace root, from `root` with / separators ("." for `root`
|
|
916
|
+
* itself): where a content-addressed record's own path is reported from
|
|
917
|
+
* when it lists itself in `assets` (ws-053). Defaults to ".".
|
|
918
|
+
*/
|
|
919
|
+
workspaceRoot?: string;
|
|
336
920
|
}
|
|
337
921
|
|
|
338
922
|
/** Commit times, in seconds since the epoch, read from git. */
|
|
@@ -346,6 +930,8 @@ export interface RecordHistory {
|
|
|
346
930
|
export interface ReadRecordsResult {
|
|
347
931
|
records: RecordEntry[];
|
|
348
932
|
summary: { total: number; valid: number; invalid: number; superseded: number };
|
|
933
|
+
/** For a work kind (#2683): every decision its decision kind reads, with the work records implementing it. */
|
|
934
|
+
decisions?: DecisionWork[];
|
|
349
935
|
}
|
|
350
936
|
|
|
351
937
|
type Validator = (data: unknown) => { ok: true } | { ok: false; errors: string[] };
|
|
@@ -385,21 +971,49 @@ function renderSchemaErrors(errors: readonly SchemaError[]): string[] {
|
|
|
385
971
|
.map((e) => `${e.instancePath || "/"} ${described.get(e) ?? e.message ?? "is invalid"}`);
|
|
386
972
|
}
|
|
387
973
|
|
|
388
|
-
async function compileSchema(schema: Record<string, unknown>): Promise<Validator> {
|
|
974
|
+
async function compileSchema(schema: Record<string, unknown>, refs: readonly Record<string, unknown>[] = []): Promise<Validator> {
|
|
389
975
|
const mod = (await import("ajv")) as unknown as { default: unknown };
|
|
390
976
|
// ajv is CommonJS; its class is the default export, or that export's own default.
|
|
391
977
|
const Ajv = ((mod.default as { default?: unknown }).default ?? mod.default) as new (opts: object) => {
|
|
978
|
+
addSchema(s: object): unknown;
|
|
392
979
|
compile(s: object): ((d: unknown) => boolean) & { errors?: SchemaError[] | null };
|
|
393
980
|
};
|
|
394
981
|
let validate: ReturnType<InstanceType<typeof Ajv>["compile"]>;
|
|
395
982
|
try {
|
|
396
|
-
|
|
983
|
+
const ajv = new Ajv({ allErrors: true, strict: false, verbose: true });
|
|
984
|
+
// The files the schema $refs, registered by their $id first (ws-053).
|
|
985
|
+
for (const ref of refs) ajv.addSchema(ref);
|
|
986
|
+
validate = ajv.compile(schema);
|
|
397
987
|
} catch (err) {
|
|
398
988
|
throw new RecordReadError("schema-invalid", `the kind's schema does not compile: ${message(err)}`);
|
|
399
989
|
}
|
|
400
990
|
return (data) => (validate(data) ? { ok: true } : { ok: false, errors: renderSchemaErrors(validate.errors ?? []) });
|
|
401
991
|
}
|
|
402
992
|
|
|
993
|
+
/**
|
|
994
|
+
* The ids a record's supersedes field names (ws-053). With the kind's `key`,
|
|
995
|
+
* the field is a list of objects and each one's `key` holds an id; without
|
|
996
|
+
* it, the field holds one id or a list of ids. Anything else names none.
|
|
997
|
+
*/
|
|
998
|
+
export function supersedesTargets(kind: Pick<RecordKind, "supersedes">, data: Record<string, unknown> | null): string[] {
|
|
999
|
+
if (!kind.supersedes || data === null) return [];
|
|
1000
|
+
const { field, key } = kind.supersedes;
|
|
1001
|
+
const value = data[field];
|
|
1002
|
+
if (key === undefined && typeof value === "string") return [value];
|
|
1003
|
+
if (!Array.isArray(value)) return [];
|
|
1004
|
+
const ids = key === undefined ? value : value.map((l) => (l !== null && typeof l === "object" ? (l as Record<string, unknown>)[key] : undefined));
|
|
1005
|
+
return ids.filter((x): x is string => typeof x === "string");
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** The stem of a file name: the name up to its first `.`, the hash a content-addressed record's name claims (ws-053). */
|
|
1009
|
+
function nameStem(path: string): string {
|
|
1010
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
1011
|
+
const dot = name.indexOf(".");
|
|
1012
|
+
return dot < 0 ? name : name.slice(0, dot);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
1016
|
+
|
|
403
1017
|
/** Read every record `loaded` locates, through `options.source`. */
|
|
404
1018
|
export async function readRecords(loaded: LoadedRecordKind, options: ReadRecordsOptions): Promise<ReadRecordsResult> {
|
|
405
1019
|
const { kind } = loaded;
|
|
@@ -409,22 +1023,54 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
409
1023
|
throw new RecordReadError("location-missing", `records directory ${dirRel} does not exist${options.source.label}`);
|
|
410
1024
|
}
|
|
411
1025
|
const match = new RegExp(kind.location.match);
|
|
412
|
-
const validate = await compileSchema(loaded.schema);
|
|
1026
|
+
const validate = await compileSchema(loaded.schema, loaded.refs);
|
|
1027
|
+
const workspaceRoot = options.workspaceRoot ?? ".";
|
|
413
1028
|
|
|
414
1029
|
const entries: RecordEntry[] = [];
|
|
1030
|
+
const texts = new Map<string, string>();
|
|
415
1031
|
for (const name of names.filter((n) => match.test(n)).sort()) {
|
|
416
1032
|
const path = dirRel === "." ? name : `${dirRel}/${name}`;
|
|
417
|
-
const
|
|
1033
|
+
const text = options.source.read(path);
|
|
1034
|
+
const entry: RecordEntry = {
|
|
1035
|
+
id: null,
|
|
1036
|
+
path,
|
|
1037
|
+
state: null,
|
|
1038
|
+
valid: true,
|
|
1039
|
+
reasons: [],
|
|
1040
|
+
supersededBy: null,
|
|
1041
|
+
data: null,
|
|
1042
|
+
assets: [],
|
|
1043
|
+
warnings: [],
|
|
1044
|
+
digest: recordTextDigest(text, kind.reviews?.field ?? null, kind.format),
|
|
1045
|
+
};
|
|
418
1046
|
entries.push(entry);
|
|
419
|
-
|
|
1047
|
+
if (kind.session) texts.set(path, text);
|
|
1048
|
+
const fm = parseRecord(kind.format, text);
|
|
420
1049
|
if (!fm.ok) {
|
|
421
1050
|
entry.reasons.push({ code: "record-unparseable", message: fm.message });
|
|
422
1051
|
continue;
|
|
423
1052
|
}
|
|
424
1053
|
entry.data = fm.value;
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
1054
|
+
if (kind.idFrom === "sha256") {
|
|
1055
|
+
// The id is the hash of the bytes; the name's stem is the hash it claims.
|
|
1056
|
+
// A name that claims another is the record pinning itself, drifted (ws-053).
|
|
1057
|
+
entry.id = sha256Hex(options.source.bytes(path));
|
|
1058
|
+
const stem = nameStem(path);
|
|
1059
|
+
if (stem !== entry.id) {
|
|
1060
|
+
const self = workspaceRoot === "." ? path : path.startsWith(`${workspaceRoot}/`) ? path.slice(workspaceRoot.length + 1) : path;
|
|
1061
|
+
if (SHA256_HEX.test(stem)) entry.assets.push({ path: self, sha256: stem, actual: entry.id, state: "drifted" });
|
|
1062
|
+
entry.warnings.push({
|
|
1063
|
+
code: "asset-drift",
|
|
1064
|
+
message: SHA256_HEX.test(stem)
|
|
1065
|
+
? `${self} is named for sha256 ${stem.slice(0, 12)}, and its bytes${options.source.label} hash to ${entry.id.slice(0, 12)}`
|
|
1066
|
+
: `${self} is content-addressed, and its name claims no sha256: its bytes${options.source.label} hash to ${entry.id.slice(0, 12)}`,
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
} else {
|
|
1070
|
+
const id = fm.value[kind.idField!];
|
|
1071
|
+
if (typeof id === "string") entry.id = id;
|
|
1072
|
+
}
|
|
1073
|
+
const state = kind.stateField === undefined ? undefined : fm.value[kind.stateField];
|
|
428
1074
|
if (typeof state === "string") entry.state = state;
|
|
429
1075
|
const result = validate(fm.value);
|
|
430
1076
|
if (!result.ok) {
|
|
@@ -437,10 +1083,22 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
437
1083
|
}
|
|
438
1084
|
if (options.assets) {
|
|
439
1085
|
const checked = checkPins(pinEntries(fm.value, kind.pins.field), options.assets);
|
|
440
|
-
entry.assets
|
|
1086
|
+
entry.assets.push(...checked.assets);
|
|
441
1087
|
entry.warnings.push(...checked.warnings);
|
|
442
1088
|
}
|
|
443
1089
|
}
|
|
1090
|
+
if (kind.reviews) {
|
|
1091
|
+
const list = fm.value[kind.reviews.field];
|
|
1092
|
+
const undigested = (Array.isArray(list) ? list : [])
|
|
1093
|
+
.filter((r): r is Record<string, unknown> => r !== null && typeof r === "object" && !Array.isArray(r) && (r as Record<string, unknown>).digest === undefined)
|
|
1094
|
+
.map((r) => (typeof r.reviewer === "string" ? r.reviewer : "an unnamed reviewer"));
|
|
1095
|
+
if (undigested.length > 0) {
|
|
1096
|
+
entry.warnings.push({
|
|
1097
|
+
code: "review-undigested",
|
|
1098
|
+
message: `the ${undigested.length === 1 ? "verdict" : "verdicts"} by ${undigested.join(", ")} name no digest: ${undigested.length === 1 ? "it counts" : "they count"}, and an amendment will not stop ${undigested.length === 1 ? "it" : "them"} counting`,
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
444
1102
|
}
|
|
445
1103
|
|
|
446
1104
|
// Ids: the first file in path order keeps an id; later ones are flagged.
|
|
@@ -457,17 +1115,12 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
457
1115
|
// approval rule: from a record ranked above 0 and at least as high as the
|
|
458
1116
|
// one it names (#2524 D4). Without them, only from a closed record (#2555).
|
|
459
1117
|
// A record is superseded at most once.
|
|
460
|
-
const closed = new Set(kind.closedStates);
|
|
1118
|
+
const closed = new Set(kind.closedStates ?? []);
|
|
461
1119
|
const rank = (state: string | null): number => (state === null ? 0 : (kind.approval?.[state] ?? 0));
|
|
462
1120
|
const takesEffect = (from: RecordEntry, to: RecordEntry): boolean =>
|
|
463
1121
|
kind.approval ? rank(from.state) > 0 && rank(from.state) >= rank(to.state) : from.state !== null && closed.has(from.state);
|
|
464
1122
|
for (const e of entries) {
|
|
465
|
-
const
|
|
466
|
-
if (!Array.isArray(links)) continue;
|
|
467
|
-
for (const link of links) {
|
|
468
|
-
if (link === null || typeof link !== "object") continue;
|
|
469
|
-
const target = (link as Record<string, unknown>)[kind.supersedes.key];
|
|
470
|
-
if (typeof target !== "string") continue;
|
|
1123
|
+
for (const target of supersedesTargets(kind, e.data)) {
|
|
471
1124
|
const old = byId.get(target);
|
|
472
1125
|
if (!old) {
|
|
473
1126
|
e.reasons.push({ code: "record-supersedes-unknown", message: `supersedes ${target}, which no record has` });
|
|
@@ -514,6 +1167,11 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
514
1167
|
}
|
|
515
1168
|
}
|
|
516
1169
|
|
|
1170
|
+
// A session's seal and its verdicts' records (#2673).
|
|
1171
|
+
joinSessions(kind, entries, texts, options.subjects ?? null);
|
|
1172
|
+
// A work kind's links, ready and blocked (#2683), from every record before --current.
|
|
1173
|
+
const work = kind.work ? await (await import("./work")).applyWork(loaded, entries, options) : undefined;
|
|
1174
|
+
|
|
517
1175
|
for (const e of entries) e.valid = e.reasons.length === 0;
|
|
518
1176
|
const records = options.current ? entries.filter((e) => e.supersededBy === null) : entries;
|
|
519
1177
|
return {
|
|
@@ -524,6 +1182,7 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
524
1182
|
invalid: records.filter((e) => !e.valid).length,
|
|
525
1183
|
superseded: entries.filter((e) => e.supersededBy !== null).length,
|
|
526
1184
|
},
|
|
1185
|
+
...(work ? { decisions: work.decisions } : {}),
|
|
527
1186
|
};
|
|
528
1187
|
}
|
|
529
1188
|
|