@intentius/chant 0.84.0 → 0.86.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/mcp/resource-handlers.d.ts +2 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +1 -0
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/tools/composites.d.ts +44 -0
- package/dist/cli/mcp/tools/composites.d.ts.map +1 -0
- package/dist/cli/mcp/tools/search.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +13 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +4 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/composite.d.ts +6 -0
- package/dist/composite.d.ts.map +1 -1
- package/dist/lexicon.d.ts +44 -0
- package/dist/lexicon.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__/contract-repo.d.ts +7 -0
- package/dist/workspace/__fixtures__/contract-repo.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 +152 -0
- package/dist/workspace/composites.d.ts.map +1 -0
- 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 +24 -2
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/intent-cli.d.ts +6 -1
- package/dist/workspace/intent-cli.d.ts.map +1 -1
- package/dist/workspace/intent-joins.d.ts +74 -9
- package/dist/workspace/intent-joins.d.ts.map +1 -1
- package/dist/workspace/intent.d.ts +90 -6
- 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/member-commands.d.ts +7 -2
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +52 -2
- 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 +65 -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 +164 -0
- package/dist/workspace/records-write.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +202 -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/work.d.ts +56 -0
- package/dist/workspace/work.d.ts.map +1 -0
- package/package.json +19 -1
- package/src/cli/handlers/graph.ts +4 -0
- package/src/cli/main.test.ts +9 -0
- package/src/cli/main.ts +56 -3
- package/src/cli/mcp/resource-handlers.ts +17 -0
- package/src/cli/mcp/server.test.ts +140 -4
- package/src/cli/mcp/server.ts +5 -1
- package/src/cli/mcp/tools/composites.ts +98 -0
- package/src/cli/mcp/tools/search.ts +47 -5
- package/src/cli/registry.ts +13 -1
- package/src/components/cli-support.test.ts +16 -0
- package/src/components/cli-support.ts +8 -2
- package/src/composite.ts +9 -0
- package/src/lexicon.ts +47 -0
- package/src/lifecycle/gate-ledger.ts +14 -0
- package/src/workspace/__fixtures__/contract-repo.ts +17 -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 +533 -0
- package/src/workspace/composites.test.ts +334 -0
- package/src/workspace/composites.ts +316 -0
- 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 +364 -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 +40 -4
- package/src/workspace/intent-cli.ts +54 -7
- package/src/workspace/intent-joins.test.ts +60 -0
- package/src/workspace/intent-joins.ts +117 -20
- package/src/workspace/intent.schema.json +357 -19
- package/src/workspace/intent.test.ts +235 -20
- package/src/workspace/intent.ts +396 -51
- package/src/workspace/ls.schema.json +34 -0
- package/src/workspace/ls.ts +69 -4
- package/src/workspace/member-commands.ts +11 -5
- package/src/workspace/read-contract.test.ts +52 -3
- package/src/workspace/reason-codes.test.ts +48 -4
- package/src/workspace/reason-codes.ts +67 -2
- 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 +246 -19
- package/src/workspace/records-contract.test.ts +77 -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 +202 -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 +736 -0
- package/src/workspace/records.schema.json +187 -9
- package/src/workspace/records.test.ts +93 -0
- package/src/workspace/records.ts +683 -49
- 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/work.test.ts +388 -0
- package/src/workspace/work.ts +163 -0
|
@@ -8,8 +8,13 @@
|
|
|
8
8
|
* the exit code is 0 whenever the read itself worked. Only a kind, schema or
|
|
9
9
|
* revision that cannot be read exits 1.
|
|
10
10
|
*
|
|
11
|
-
* It needs no `chant.workspace.json`. The kind is passed explicitly,
|
|
12
|
-
*
|
|
11
|
+
* It needs no `chant.workspace.json`. The kind is passed explicitly, or,
|
|
12
|
+
* without `--kind`, it is every record kind the declaration names (#2680), so
|
|
13
|
+
* nothing is inferred (#2525 rule 1). Several kinds print one document per
|
|
14
|
+
* kind, in the declaration's order, inside one set.
|
|
15
|
+
*
|
|
16
|
+
* With `--since <rev>` it prints what changed between two revisions instead,
|
|
17
|
+
* through `records-since.ts` (#2673).
|
|
13
18
|
*/
|
|
14
19
|
|
|
15
20
|
import { execFileSync } from "node:child_process";
|
|
@@ -20,8 +25,26 @@ import type { CommandContext } from "../cli/registry";
|
|
|
20
25
|
import { findWorkspaceRoot } from "../project-root";
|
|
21
26
|
import { fileDigest, isWorkspacePath } from "./record-assets";
|
|
22
27
|
import { gitRevisionSource, gitRoot, resolveRevision, workingTreeSource } from "./record-source";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
28
|
+
import { declaredRecordKinds, readDeclaration, WorkspaceReadError, type RecordKindDeclaration } from "./declaration";
|
|
29
|
+
import { declaredKindFile } from "./declared-kinds";
|
|
30
|
+
import { locateWorkspace } from "./which-chant";
|
|
31
|
+
import {
|
|
32
|
+
computeQuorum,
|
|
33
|
+
DEFAULT_QUORUM,
|
|
34
|
+
loadRecordKind,
|
|
35
|
+
normalisePrincipal,
|
|
36
|
+
readRecords,
|
|
37
|
+
RecordReadError,
|
|
38
|
+
type LoadedRecordKind,
|
|
39
|
+
type Quorum,
|
|
40
|
+
type ReadErrorCode,
|
|
41
|
+
type ReadRecordsResult,
|
|
42
|
+
type RecordEntry,
|
|
43
|
+
type RecordFormat,
|
|
44
|
+
type RecordHistory,
|
|
45
|
+
} from "./records";
|
|
46
|
+
import { gitTree, workingTree, type WorkspaceTree } from "./tree";
|
|
47
|
+
import type { DecisionWork } from "./work";
|
|
25
48
|
import { activeAttestors, type ProvenanceLevel } from "./trust/attestor";
|
|
26
49
|
import { policyAtBase, recordProvenance, resolveBase, type BaseSource, type RecordProvenance } from "./trust/provenance";
|
|
27
50
|
|
|
@@ -31,7 +54,8 @@ export const RECORDS_CONTRACT_VERSION = 1;
|
|
|
31
54
|
/** `$id` of the JSON Schema for the `--json` output, shipped beside this file. */
|
|
32
55
|
export const RECORDS_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records/v1/records.schema.json";
|
|
33
56
|
|
|
34
|
-
const USAGE =
|
|
57
|
+
const USAGE =
|
|
58
|
+
"chant workspace records [--kind <kind file>] [--current] [--at <rev>] [--base <rev>] [--require attested] [--json] | chant workspace records [--kind <kind file>] --since <rev> [--at <rev>] [--json] | chant workspace records pin <path> | chant workspace records new|amend|review (#2670)";
|
|
35
59
|
|
|
36
60
|
/** Exit code when the read worked and a record falls below `--require`. */
|
|
37
61
|
export const EXIT_BELOW_REQUIRED = 2;
|
|
@@ -46,8 +70,14 @@ export interface RecordsQuery {
|
|
|
46
70
|
cwd: string;
|
|
47
71
|
}
|
|
48
72
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
73
|
+
/**
|
|
74
|
+
* A record as the output carries it: the entry plus its provenance (#2547),
|
|
75
|
+
* and its quorum when the kind has a reviews list (#2671).
|
|
76
|
+
*/
|
|
77
|
+
export type RecordView = RecordEntry & { provenance: RecordProvenance; quorum?: Quorum | null };
|
|
78
|
+
|
|
79
|
+
/** The role in the trust policy whose holders' verdicts the quorum does not count (#2671). */
|
|
80
|
+
export const AGENT_ROLE = "agent";
|
|
51
81
|
|
|
52
82
|
/** Where provenance was judged from (#2547). */
|
|
53
83
|
export interface TrustView {
|
|
@@ -65,7 +95,7 @@ export type RecordsDocument =
|
|
|
65
95
|
| {
|
|
66
96
|
$schema: string;
|
|
67
97
|
contract: number;
|
|
68
|
-
kind: { name: string; schema: string; file: string };
|
|
98
|
+
kind: { name: string; schema: string; file: string; format: RecordFormat };
|
|
69
99
|
at: string | null;
|
|
70
100
|
/** The directory pinned paths resolve in, from the repository root: the workspace holding the kind file, or the repository root (#2549). */
|
|
71
101
|
workspaceRoot: string;
|
|
@@ -73,9 +103,58 @@ export type RecordsDocument =
|
|
|
73
103
|
trust: TrustView;
|
|
74
104
|
records: RecordView[];
|
|
75
105
|
summary: { total: number; valid: number; invalid: number; superseded: number };
|
|
106
|
+
/** For a work kind (#2683): each decision its decision kind reads, with the work records implementing it. */
|
|
107
|
+
decisions?: DecisionWork[];
|
|
76
108
|
}
|
|
77
109
|
| { $schema: string; contract: number; error: { code: ReadErrorCode; message: string } };
|
|
78
110
|
|
|
111
|
+
/** Where a document in a {@link RecordsSetDocument} comes from: the declaration's entry for its kind (#2680). */
|
|
112
|
+
export interface DeclaredKindView {
|
|
113
|
+
/** The member that declares the kind, or null for the workspace's own. */
|
|
114
|
+
member: string | null;
|
|
115
|
+
/** The kind file from the workspace root. */
|
|
116
|
+
path: string;
|
|
117
|
+
/** The name the declaration gives the kind, or null. */
|
|
118
|
+
name: string | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The `records` output without `--kind` when the declaration names record
|
|
123
|
+
* kinds (#2680): one {@link RecordsDocument} per kind, in the declaration's
|
|
124
|
+
* order, each with the entry that declares it.
|
|
125
|
+
*/
|
|
126
|
+
export interface RecordsSetDocument {
|
|
127
|
+
$schema: string;
|
|
128
|
+
contract: number;
|
|
129
|
+
kinds: (RecordsDocument & { declared: DeclaredKindView })[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The record kinds the declaration nearest above `cwd` names, in the tree
|
|
134
|
+
* `at` reads, with each kind file on disk. Empty when there is no declaration.
|
|
135
|
+
* Throws a {@link WorkspaceReadError} for one that can't be read.
|
|
136
|
+
*/
|
|
137
|
+
export function declaredKindFiles(cwd: string, at?: string): { declared: RecordKindDeclaration; file: string }[] {
|
|
138
|
+
let located;
|
|
139
|
+
try {
|
|
140
|
+
located = locateWorkspace(cwd, at);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (err instanceof WorkspaceReadError && err.code === "declaration-missing") return [];
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
return declaredRecordKinds(readDeclaration(located.tree)).map((declared) => ({ declared, file: declaredKindFile(declared, located.rootOnDisk) }));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Read every declared kind in `kinds`, as {@link queryRecords} reads one. */
|
|
149
|
+
export async function queryDeclaredRecords(kinds: { declared: RecordKindDeclaration; file: string }[], query: Omit<RecordsQuery, "kind">): Promise<RecordsSetDocument> {
|
|
150
|
+
const out: RecordsSetDocument["kinds"] = [];
|
|
151
|
+
for (const k of kinds) {
|
|
152
|
+
const doc = await queryRecords({ ...query, kind: k.file });
|
|
153
|
+
out.push({ ...doc, declared: { member: k.declared.member, path: k.declared.path, name: k.declared.name } });
|
|
154
|
+
}
|
|
155
|
+
return { $schema: RECORDS_OUTPUT_SCHEMA_ID, contract: RECORDS_CONTRACT_VERSION, kinds: out };
|
|
156
|
+
}
|
|
157
|
+
|
|
79
158
|
/** A records read, before provenance. */
|
|
80
159
|
export interface RecordsRead {
|
|
81
160
|
loaded: LoadedRecordKind;
|
|
@@ -86,15 +165,32 @@ export interface RecordsRead {
|
|
|
86
165
|
at: string | null;
|
|
87
166
|
/** Where pinned paths resolve, relative to `root` ("." for the root itself). */
|
|
88
167
|
workspaceRoot: string;
|
|
168
|
+
/** The workspace root's tree, as read: the working tree, or the revision under `--at`. */
|
|
169
|
+
tree: WorkspaceTree;
|
|
89
170
|
result: ReadRecordsResult;
|
|
90
171
|
}
|
|
91
172
|
|
|
173
|
+
/**
|
|
174
|
+
* The quorum the workspace declares: the declaration at the workspace root
|
|
175
|
+
* of the tree read, or the default when there is none, or when it can't be
|
|
176
|
+
* read (#2671). Records need no declaration, so neither does this.
|
|
177
|
+
*/
|
|
178
|
+
function declaredQuorum(tree: WorkspaceTree): { need: number; needFrom: "declaration" | "default" } {
|
|
179
|
+
try {
|
|
180
|
+
const q = readDeclaration(tree).quorum;
|
|
181
|
+
if (q !== null) return { need: q, needFrom: "declaration" };
|
|
182
|
+
} catch {
|
|
183
|
+
// No declaration, or one this read can't use: the default applies.
|
|
184
|
+
}
|
|
185
|
+
return { need: DEFAULT_QUORUM, needFrom: "default" };
|
|
186
|
+
}
|
|
187
|
+
|
|
92
188
|
/**
|
|
93
189
|
* Where a kind's pinned paths resolve: the workspace whose declaration sits
|
|
94
190
|
* nearest above the kind file, when it is inside the repository, or else the
|
|
95
191
|
* repository root. Relative to `root`, with / separators.
|
|
96
192
|
*/
|
|
97
|
-
function pinRoot(kindFile: string, root: string): string {
|
|
193
|
+
export function pinRoot(kindFile: string, root: string): string {
|
|
98
194
|
const found = findWorkspaceRoot(dirname(kindFile));
|
|
99
195
|
if (!found) return ".";
|
|
100
196
|
const rel = relative(root, realpathOr(found.dir)).split(sep).join("/");
|
|
@@ -146,14 +242,20 @@ export async function readRecordsFor(query: Omit<RecordsQuery, "base">): Promise
|
|
|
146
242
|
assets = gitTree(top, at, workspaceRoot === "." ? "" : workspaceRoot);
|
|
147
243
|
}
|
|
148
244
|
const history = top ? gitHistory(top, at ?? "HEAD", workspaceRoot) : undefined;
|
|
149
|
-
|
|
150
|
-
|
|
245
|
+
// A session kind's verdicts name records of another kind, read from the same tree (#2673).
|
|
246
|
+
let subjects: { records: RecordEntry[]; reviews: string } | undefined;
|
|
247
|
+
if (loaded.kind.session) {
|
|
248
|
+
const subjectKind = await loadRecordKind(resolve(dirname(loaded.file), loaded.kind.session.subjects.kind), cwd);
|
|
249
|
+
subjects = { records: (await readRecords(subjectKind, { root, source })).records, reviews: subjectKind.kind.reviews?.field ?? "reviews" };
|
|
250
|
+
}
|
|
251
|
+
const result = await readRecords(loaded, { root, source, current: !!query.current, assets, workspaceRoot, ...(history ? { history } : {}), ...(subjects ? { subjects } : {}) });
|
|
252
|
+
return { loaded, root, top, at, workspaceRoot, tree: assets, result };
|
|
151
253
|
}
|
|
152
254
|
|
|
153
255
|
/** Run the query and build the document `--json` prints. Never throws a {@link RecordReadError}. */
|
|
154
256
|
export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument> {
|
|
155
257
|
try {
|
|
156
|
-
const { loaded, root, top, at, workspaceRoot, result } = await readRecordsFor(query);
|
|
258
|
+
const { loaded, root, top, at, workspaceRoot, tree, result } = await readRecordsFor(query);
|
|
157
259
|
// Provenance, judged by the policy at base and never by the tree read (#2547).
|
|
158
260
|
const base = top ? resolveBase(top, query.base) : { commit: null, from: null };
|
|
159
261
|
const policy = top ? policyAtBase(top, base) : policyAtBase(root, base);
|
|
@@ -164,6 +266,15 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
|
|
|
164
266
|
paths: result.records.map((r) => r.path),
|
|
165
267
|
attestors: policy.active ? await activeAttestors() : [],
|
|
166
268
|
});
|
|
269
|
+
// The quorum: the need from the declaration in the tree read, agents and
|
|
270
|
+
// whether verdicts need a seal from the policy at base (#2671).
|
|
271
|
+
const quorumOptions = loaded.kind.reviews
|
|
272
|
+
? {
|
|
273
|
+
...declaredQuorum(tree),
|
|
274
|
+
agents: new Set((policy.roles[AGENT_ROLE] ?? []).map(normalisePrincipal)),
|
|
275
|
+
attestation: policy.active,
|
|
276
|
+
}
|
|
277
|
+
: undefined;
|
|
167
278
|
return {
|
|
168
279
|
$schema: RECORDS_OUTPUT_SCHEMA_ID,
|
|
169
280
|
contract: RECORDS_CONTRACT_VERSION,
|
|
@@ -171,13 +282,19 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
|
|
|
171
282
|
name: loaded.kind.name,
|
|
172
283
|
schema: loaded.kind.schema.id,
|
|
173
284
|
file: relative(root, loaded.file).split("\\").join("/"),
|
|
285
|
+
format: loaded.kind.format,
|
|
174
286
|
},
|
|
175
287
|
at,
|
|
176
288
|
workspaceRoot,
|
|
177
289
|
current: !!query.current,
|
|
178
290
|
trust: { base: base.commit, baseFrom: base.from, active: policy.active, signersPath: policy.signersPath, problems: policy.problems },
|
|
179
|
-
records: result.records.map((r) => ({
|
|
291
|
+
records: result.records.map((r) => ({
|
|
292
|
+
...r,
|
|
293
|
+
provenance: provenance.get(r.path)!,
|
|
294
|
+
...(quorumOptions ? { quorum: computeQuorum(loaded.kind, r, quorumOptions) } : {}),
|
|
295
|
+
})),
|
|
180
296
|
summary: result.summary,
|
|
297
|
+
...(result.decisions ? { decisions: result.decisions } : {}),
|
|
181
298
|
};
|
|
182
299
|
} catch (err) {
|
|
183
300
|
if (!(err instanceof RecordReadError)) throw err;
|
|
@@ -204,6 +321,9 @@ export function pinFile(file: string, cwd: string): { path: string; sha256: stri
|
|
|
204
321
|
|
|
205
322
|
export async function runWorkspaceRecords(ctx: CommandContext): Promise<number> {
|
|
206
323
|
const { args } = ctx;
|
|
324
|
+
if (args.extraPositional === "new" || args.extraPositional === "amend" || args.extraPositional === "review") {
|
|
325
|
+
return (await import("./records-write")).runRecordsWrite(ctx);
|
|
326
|
+
}
|
|
207
327
|
if (args.extraPositional === "pin") {
|
|
208
328
|
if (!args.extraPositional2) {
|
|
209
329
|
console.error(formatError({ message: "pin needs the path of a file", hint: USAGE }));
|
|
@@ -218,17 +338,27 @@ export async function runWorkspaceRecords(ctx: CommandContext): Promise<number>
|
|
|
218
338
|
return 0;
|
|
219
339
|
}
|
|
220
340
|
if (args.extraPositional) {
|
|
221
|
-
console.error(formatError({ message: `chant workspace records takes no argument but pin (got ${args.extraPositional})`, hint: USAGE }));
|
|
222
|
-
return 1;
|
|
223
|
-
}
|
|
224
|
-
if (!args.kind) {
|
|
225
|
-
console.error(formatError({ message: "--kind <kind file> is required", hint: USAGE }));
|
|
341
|
+
console.error(formatError({ message: `chant workspace records takes no argument but pin, new, amend or review (got ${args.extraPositional})`, hint: USAGE }));
|
|
226
342
|
return 1;
|
|
227
343
|
}
|
|
344
|
+
if (!args.kind) return runDeclaredRecords(args);
|
|
228
345
|
if (args.require !== undefined && args.require !== "attested") {
|
|
229
346
|
console.error(formatError({ message: `--require takes one level, attested, not ${JSON.stringify(args.require)}`, hint: USAGE }));
|
|
230
347
|
return 1;
|
|
231
348
|
}
|
|
349
|
+
if (args.since !== undefined) {
|
|
350
|
+
if (args.current || args.require !== undefined || args.base !== undefined) {
|
|
351
|
+
console.error(formatError({ message: "--since compares two revisions and takes no --current, --require or --base", hint: USAGE }));
|
|
352
|
+
return 1;
|
|
353
|
+
}
|
|
354
|
+
// Loaded here, so a plain records read never loads it (#2673).
|
|
355
|
+
const { formatSince, queryRecordsSince } = await import("./records-since");
|
|
356
|
+
const since = await queryRecordsSince({ kind: args.kind, since: args.since, at: args.at, cwd: process.cwd() });
|
|
357
|
+
if (args.json) console.log(JSON.stringify(since, null, 2));
|
|
358
|
+
else if ("error" in since) console.error(formatError({ message: `${since.error.code}: ${since.error.message}`, hint: USAGE }));
|
|
359
|
+
else console.log(formatSince(since));
|
|
360
|
+
return "error" in since ? 1 : 0;
|
|
361
|
+
}
|
|
232
362
|
const doc = await queryRecords({ kind: args.kind, current: args.current, at: args.at, base: args.base, cwd: process.cwd() });
|
|
233
363
|
if (args.json) {
|
|
234
364
|
console.log(JSON.stringify(doc, null, 2));
|
|
@@ -256,7 +386,92 @@ export async function runWorkspaceRecords(ctx: CommandContext): Promise<number>
|
|
|
256
386
|
return 0;
|
|
257
387
|
}
|
|
258
388
|
|
|
259
|
-
|
|
389
|
+
/**
|
|
390
|
+
* `records` without `--kind` (#2680): every record kind the declaration
|
|
391
|
+
* names, or, when it names none or there is no declaration, the error it has
|
|
392
|
+
* always been. A kind whose read fails is listed with its error, the others
|
|
393
|
+
* are still read, and the exit code is 1.
|
|
394
|
+
*/
|
|
395
|
+
async function runDeclaredRecords(args: CommandContext["args"]): Promise<number> {
|
|
396
|
+
if (args.require !== undefined && args.require !== "attested") {
|
|
397
|
+
console.error(formatError({ message: `--require takes one level, attested, not ${JSON.stringify(args.require)}`, hint: USAGE }));
|
|
398
|
+
return 1;
|
|
399
|
+
}
|
|
400
|
+
let declared: { declared: RecordKindDeclaration; file: string }[];
|
|
401
|
+
try {
|
|
402
|
+
declared = declaredKindFiles(process.cwd(), args.at);
|
|
403
|
+
} catch (err) {
|
|
404
|
+
if (!(err instanceof WorkspaceReadError)) throw err;
|
|
405
|
+
console.error(formatError({ message: `${err.code}: ${err.describe()}; without --kind, the declaration names the record kinds`, hint: USAGE }));
|
|
406
|
+
return 1;
|
|
407
|
+
}
|
|
408
|
+
if (declared.length === 0) {
|
|
409
|
+
console.error(formatError({ message: "--kind <kind file> is required", hint: USAGE }));
|
|
410
|
+
return 1;
|
|
411
|
+
}
|
|
412
|
+
if (args.since !== undefined) return runDeclaredSince(declared, args);
|
|
413
|
+
const set = await queryDeclaredRecords(declared, { current: args.current, at: args.at, base: args.base, cwd: process.cwd() });
|
|
414
|
+
if (args.json) console.log(JSON.stringify(set, null, 2));
|
|
415
|
+
for (const doc of args.json ? [] : set.kinds) {
|
|
416
|
+
if ("error" in doc) {
|
|
417
|
+
console.error(formatError({ message: `${doc.declared.path}: ${doc.error.code}: ${doc.error.message}`, hint: USAGE }));
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
console.log(`${doc.declared.name ?? doc.kind.name} (${doc.declared.path})`);
|
|
421
|
+
console.log(formatRecords(doc.records, doc.summary, doc.at));
|
|
422
|
+
}
|
|
423
|
+
if (set.kinds.some((d) => "error" in d)) return 1;
|
|
424
|
+
if (args.require) {
|
|
425
|
+
const results = set.kinds.filter((d): d is Extract<typeof d, { records: unknown }> => !("error" in d));
|
|
426
|
+
const all = results.flatMap((d) => d.records);
|
|
427
|
+
const below = belowRequired(all, args.require);
|
|
428
|
+
if (below.length > 0) {
|
|
429
|
+
const inactive = results.find((d) => !d.trust.active);
|
|
430
|
+
console.error(
|
|
431
|
+
formatError({
|
|
432
|
+
message: `${below.length} of ${all.length} records are not ${args.require}: ${below
|
|
433
|
+
.slice(0, 5)
|
|
434
|
+
.map((r) => `${r.path} (${r.provenance.level})`)
|
|
435
|
+
.join(", ")}${below.length > 5 ? ", ..." : ""}`,
|
|
436
|
+
hint: inactive ? `there is no signers file (${inactive.trust.signersPath}) at base` : "run with --json to see each record's reason",
|
|
437
|
+
}),
|
|
438
|
+
);
|
|
439
|
+
return EXIT_BELOW_REQUIRED;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return 0;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* `records --since` without `--kind` (#2680): what changed in every declared
|
|
447
|
+
* kind, one `records-since` document per kind inside one set, in the
|
|
448
|
+
* declaration's order. A kind whose read fails is listed with its error, and
|
|
449
|
+
* the exit code is 1.
|
|
450
|
+
*/
|
|
451
|
+
async function runDeclaredSince(declared: { declared: RecordKindDeclaration; file: string }[], args: CommandContext["args"]): Promise<number> {
|
|
452
|
+
if (args.current || args.require !== undefined || args.base !== undefined) {
|
|
453
|
+
console.error(formatError({ message: "--since compares two revisions and takes no --current, --require or --base", hint: USAGE }));
|
|
454
|
+
return 1;
|
|
455
|
+
}
|
|
456
|
+
const { formatSince, queryRecordsSince, RECORDS_SINCE_OUTPUT_SCHEMA_ID } = await import("./records-since");
|
|
457
|
+
const kinds = [];
|
|
458
|
+
for (const k of declared) {
|
|
459
|
+
const doc = await queryRecordsSince({ kind: k.file, since: args.since!, at: args.at, cwd: process.cwd() });
|
|
460
|
+
kinds.push({ ...doc, declared: { member: k.declared.member, path: k.declared.path, name: k.declared.name } });
|
|
461
|
+
}
|
|
462
|
+
if (args.json) console.log(JSON.stringify({ $schema: RECORDS_SINCE_OUTPUT_SCHEMA_ID, contract: RECORDS_CONTRACT_VERSION, kinds }, null, 2));
|
|
463
|
+
for (const doc of args.json ? [] : kinds) {
|
|
464
|
+
if ("error" in doc) {
|
|
465
|
+
console.error(formatError({ message: `${doc.declared.path}: ${doc.error.code}: ${doc.error.message}`, hint: USAGE }));
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
console.log(`${doc.declared.name ?? doc.kind.name} (${doc.declared.path})`);
|
|
469
|
+
console.log(formatSince(doc));
|
|
470
|
+
}
|
|
471
|
+
return kinds.some((d) => "error" in d) ? 1 : 0;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function realpathOr(dir: string): string {
|
|
260
475
|
try {
|
|
261
476
|
return realpathSync(dir);
|
|
262
477
|
} catch {
|
|
@@ -279,8 +494,20 @@ function formatRecords(records: RecordView[], summary: { total: number; valid: n
|
|
|
279
494
|
const superseded = r.supersededBy ? ` superseded by ${r.supersededBy}` : "";
|
|
280
495
|
const attested = r.provenance.level === "attested" ? ` attested by ${r.provenance.principal}` : "";
|
|
281
496
|
lines.push(`${(r.id ?? "-").padEnd(idWidth)} ${(r.state ?? "-").padEnd(stateWidth)} ${title}${superseded}${attested}${flag}`);
|
|
497
|
+
if (r.ready !== undefined) {
|
|
498
|
+
const blocked = (r.blockedBy ?? []).map((b) => `${b.id} (${b.state ?? "unknown"})`).join(", ");
|
|
499
|
+
const implemented = (r.implements ?? []).map((d) => `${d.id} (${d.state ?? "unknown"})`).join(", ");
|
|
500
|
+
const status = [r.ready ? "ready" : blocked ? `blocked by ${blocked}` : "", implemented ? `implements ${implemented}` : ""].filter(Boolean).join("; ");
|
|
501
|
+
if (status) lines.push(`${" ".repeat(idWidth + 2)}${status}`);
|
|
502
|
+
}
|
|
282
503
|
for (const reason of r.reasons) lines.push(`${" ".repeat(idWidth + 2)}${reason.code}: ${reason.message} (${r.path})`);
|
|
283
504
|
for (const warning of r.warnings) lines.push(`${" ".repeat(idWidth + 2)}warning ${warning.code}: ${warning.message} (${r.path})`);
|
|
505
|
+
const q = r.quorum;
|
|
506
|
+
if (q && q.counted.length + q.notCounted.length > 0) {
|
|
507
|
+
const verdict = q.metWithObjections ? "met with objections" : q.met ? "met" : "not met";
|
|
508
|
+
const concerns = q.openConcerns.length > 0 ? `, ${q.openConcerns.length} open ${q.openConcerns.length === 1 ? "concern" : "concerns"}` : "";
|
|
509
|
+
lines.push(`${" ".repeat(idWidth + 2)}quorum ${q.agreed} of ${q.need} agreed, ${verdict}; ${q.notCounted.length} not counted${concerns}`);
|
|
510
|
+
}
|
|
284
511
|
}
|
|
285
512
|
lines.push(
|
|
286
513
|
`${summary.total} records${at ? ` at ${at.slice(0, 8)}` : ""}: ${summary.valid} valid, ${summary.invalid} invalid, ${summary.superseded} superseded`,
|
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
* doesn't exist yet, so the chant repo's own decision files stand in for it.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { cpSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import Ajv2020 from "ajv/dist/2020";
|
|
12
12
|
import { afterAll, describe, expect, test } from "vitest";
|
|
13
13
|
import { queryRecords, RECORDS_CONTRACT_VERSION, RECORDS_OUTPUT_SCHEMA_ID, type RecordsDocument } from "./records-cli";
|
|
14
|
-
import { READ_ERROR_CODES, RECORD_REASON_CODES } from "./records";
|
|
14
|
+
import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES, recordTextDigest, REVIEW_REASON_CODES } from "./records";
|
|
15
|
+
import { WORK_WARNING_CODES } from "./work";
|
|
15
16
|
import schema from "./records.schema.json";
|
|
16
17
|
import { PROVENANCE_LEVELS } from "./trust/attestor";
|
|
17
18
|
|
|
@@ -77,6 +78,80 @@ describe("records output schema", () => {
|
|
|
77
78
|
expect(doc.summary.invalid).toBe(2);
|
|
78
79
|
});
|
|
79
80
|
|
|
81
|
+
test("a workspace-sourced decision with no evidence validates, with its warning (#2654)", async () => {
|
|
82
|
+
const root = copyDecisions();
|
|
83
|
+
const dir = join(root, "docs", "design", "decisions");
|
|
84
|
+
const text = readFileSync(join(dir, "ws-003-seal-scope.md"), "utf-8")
|
|
85
|
+
.replace(/^id: .*$/m, 'id: "ws-900"')
|
|
86
|
+
.replace(/^source:\n(?: .*\n)*/m, 'source:\n kind: "workspace"\n member: "app"\n')
|
|
87
|
+
.replace(/^evidence:\n(?: .*\n)*/m, "evidence: []\n");
|
|
88
|
+
writeFileSync(join(dir, "ws-900-extra.md"), text);
|
|
89
|
+
const doc = await queryRecords({ kind: KIND, current: true, cwd: root });
|
|
90
|
+
expectValid(doc);
|
|
91
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
92
|
+
const r = doc.records.find((x) => x.id === "ws-900");
|
|
93
|
+
expect(r?.valid).toBe(true);
|
|
94
|
+
expect(r?.warnings?.map((w) => w.code)).toEqual(["record-no-evidence"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("lists exactly the codes a verdict is not counted for (#2671)", () => {
|
|
98
|
+
expect(schema.$defs.verdict.properties.reason.properties.code.enum).toEqual([...REVIEW_REASON_CODES]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("every record carries its digest and quorum, and the chant repo's decisions need the default two (#2671, #2672)", async () => {
|
|
102
|
+
const doc = await queryRecords({ kind: KIND, current: true, cwd: REPO });
|
|
103
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
104
|
+
for (const r of doc.records) {
|
|
105
|
+
expect(r.digest).toMatch(/^[0-9a-f]{64}$/);
|
|
106
|
+
expect(r.quorum).toMatchObject({ need: 2, needFrom: "default", agreed: 0, met: false, metWithObjections: false });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a decision with verdicts validates, with each verdict counted or not and why (#2671, #2672)", async () => {
|
|
111
|
+
const root = copyDecisions();
|
|
112
|
+
const dir = join(root, "docs", "design", "decisions");
|
|
113
|
+
writeFileSync(
|
|
114
|
+
join(root, "chant.workspace.json"),
|
|
115
|
+
JSON.stringify({ name: "w", schema: 1, quorum: 1, members: [{ name: "docs", dir: "docs", kind: "other", because: "decisions only" }] }),
|
|
116
|
+
);
|
|
117
|
+
const base = readFileSync(join(dir, "ws-003-seal-scope.md"), "utf-8").replace(/^id: .*$/m, 'id: "ws-900"');
|
|
118
|
+
const digest = recordTextDigest(base);
|
|
119
|
+
const entry = (reviewer: string, verdict: string, extra = "") => ` - reviewer: "${reviewer}"\n verdict: "${verdict}"\n on: "2026-09-24"${extra}`;
|
|
120
|
+
const reviews = [
|
|
121
|
+
entry("alice", "agree", `\n digest: "${digest}"`),
|
|
122
|
+
entry("Alice ", "agree", `\n digest: "${digest}"`),
|
|
123
|
+
entry("lex00", "agree"),
|
|
124
|
+
entry("bob", "dissent", `\n note: "a case is missing"\n digest: "${"0".repeat(64)}"`),
|
|
125
|
+
];
|
|
126
|
+
writeFileSync(join(dir, "ws-900-extra.md"), base.replace(/^reviews: \[\]$/m, `reviews:\n${reviews.join("\n")}`));
|
|
127
|
+
const doc = await queryRecords({ kind: KIND, current: true, cwd: root });
|
|
128
|
+
expectValid(doc);
|
|
129
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
130
|
+
const r = doc.records.find((x) => x.id === "ws-900")!;
|
|
131
|
+
expect(r.valid).toBe(true);
|
|
132
|
+
expect(r.digest).toBe(digest);
|
|
133
|
+
expect(r.warnings.map((w) => w.code)).toEqual(["review-undigested"]);
|
|
134
|
+
const q = r.quorum!;
|
|
135
|
+
expect(q).toMatchObject({ need: 1, needFrom: "declaration", agreed: 1, met: true, metWithObjections: true });
|
|
136
|
+
expect(q.counted.map((v) => v.reviewer)).toEqual(["Alice "]);
|
|
137
|
+
expect(q.notCounted.map((v) => [v.reviewer, v.reason?.code])).toEqual([
|
|
138
|
+
["alice", "review-duplicate"],
|
|
139
|
+
["lex00", "review-decider"],
|
|
140
|
+
["bob", "review-older-digest"],
|
|
141
|
+
]);
|
|
142
|
+
expect(q.openConcerns.map((c) => c.reviewer)).toEqual(["bob"]);
|
|
143
|
+
// A counted verdict with a reason, or a not-counted one without, is refused.
|
|
144
|
+
const bad = structuredClone(doc);
|
|
145
|
+
const record = bad.records.find((x) => x.id === "ws-900")!;
|
|
146
|
+
record.quorum!.counted[0].reason = { code: "review-duplicate", message: "x" };
|
|
147
|
+
expect(validate(bad)).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("lists exactly the warning codes the code can return", () => {
|
|
151
|
+
// A work kind's records carry the work warnings too, except work-done-gap-open, which only graph --intent raises (#2683).
|
|
152
|
+
expect(schema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES, ...WORK_WARNING_CODES.filter((c) => c !== "work-done-gap-open")]);
|
|
153
|
+
});
|
|
154
|
+
|
|
80
155
|
test("every failure validates with its code", async () => {
|
|
81
156
|
const root = copyDecisions();
|
|
82
157
|
const docs = [
|