@intentius/chant 0.87.0 → 0.88.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/handlers/misc.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +4 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/version.d.ts +8 -0
- package/dist/cli/version.d.ts.map +1 -0
- package/dist/workspace/__fixtures__/sessions.d.ts +9 -0
- package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +13 -0
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/environments.d.ts +80 -0
- package/dist/workspace/environments.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +14 -5
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +6 -2
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-close.d.ts +41 -0
- package/dist/workspace/records-close.d.ts.map +1 -0
- package/dist/workspace/records-since.d.ts +40 -1
- package/dist/workspace/records-since.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +109 -11
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +40 -12
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/runtimes.d.ts +6 -0
- package/dist/workspace/runtimes.d.ts.map +1 -1
- package/dist/workspace/session-kinds.d.ts +28 -0
- package/dist/workspace/session-kinds.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +2 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +42 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/misc.ts +1 -9
- package/src/cli/main.ts +20 -8
- package/src/cli/mcp/server.test.ts +14 -1
- package/src/cli/mcp/server.ts +3 -1
- package/src/cli/registry.ts +4 -1
- package/src/cli/version.ts +15 -0
- package/src/workspace/__fixtures__/sessions.ts +41 -0
- package/src/workspace/composites.schema.json +68 -3
- package/src/workspace/composites.test.ts +119 -5
- package/src/workspace/composites.ts +26 -7
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +11 -0
- package/src/workspace/environments.ts +165 -0
- package/src/workspace/read-contract.test.ts +3 -0
- package/src/workspace/reason-codes.test.ts +8 -3
- package/src/workspace/reason-codes.ts +18 -6
- package/src/workspace/record-assets.test.ts +4 -3
- package/src/workspace/records-amend.schema.json +30 -1
- package/src/workspace/records-cli.ts +44 -7
- package/src/workspace/records-close.schema.json +192 -0
- package/src/workspace/records-close.ts +129 -0
- package/src/workspace/records-contract.test.ts +4 -3
- package/src/workspace/records-new.schema.json +25 -0
- package/src/workspace/records-review.schema.json +55 -2
- package/src/workspace/records-sessions-write.test.ts +274 -0
- package/src/workspace/records-since.schema.json +27 -2
- package/src/workspace/records-since.ts +120 -6
- package/src/workspace/records-write-contract.test.ts +5 -1
- package/src/workspace/records-write.test.ts +4 -2
- package/src/workspace/records-write.ts +307 -43
- package/src/workspace/records.schema.json +22 -3
- package/src/workspace/records.ts +73 -22
- package/src/workspace/runtimes.ts +12 -3
- package/src/workspace/session-kinds.ts +79 -0
- package/src/workspace/status.ts +1 -1
- package/src/workspace/trust/record-seal.test.ts +315 -0
- package/src/workspace/trust/seal.test.ts +4 -14
- package/src/workspace/trust/seal.ts +119 -25
|
@@ -9,6 +9,18 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Records are matched by id. A record whose id could not be read is left
|
|
11
11
|
* out of the comparison, since nothing says which record it was before.
|
|
12
|
+
*
|
|
13
|
+
* `--since <session id>` (#2693) compares the commit a review session
|
|
14
|
+
* opened at with the commit that carries its close. The first is the
|
|
15
|
+
* record's opening revision (the kind's `session.openedRev` field). The
|
|
16
|
+
* record's closing revision (`session.closedRev`) is HEAD when `records
|
|
17
|
+
* close` wrote the close, before the caller committed it, so the close and
|
|
18
|
+
* the verdicts committed with it are in the first commit after it that
|
|
19
|
+
* changed the session file. A session written before chant wrote these
|
|
20
|
+
* fields falls back to its file's history: the commit that added it and the
|
|
21
|
+
* last commit that changed it. An open session, or one whose close is not
|
|
22
|
+
* committed yet, is compared with the working tree, and an open one says so
|
|
23
|
+
* with `since-session-open`.
|
|
12
24
|
*/
|
|
13
25
|
|
|
14
26
|
import { realpathSync } from "node:fs";
|
|
@@ -16,8 +28,9 @@ import { relative } from "node:path";
|
|
|
16
28
|
import { pinEntries } from "./record-assets";
|
|
17
29
|
import { gitRoot, resolveRevision } from "./record-source";
|
|
18
30
|
import { READ_ERROR_CODES, RecordReadError, type LoadedRecordKind, type ReadErrorCode, type RecordEntry, type RecordKind } from "./records";
|
|
19
|
-
import { readRecordsFor, RECORDS_CONTRACT_VERSION } from "./records-cli";
|
|
31
|
+
import { readRecordsFor, realpathOr, RECORDS_CONTRACT_VERSION } from "./records-cli";
|
|
20
32
|
import type { ReasonCode } from "./reason-codes";
|
|
33
|
+
import { commitsTouching, findSessionKinds, SESSION_ID } from "./session-kinds";
|
|
21
34
|
|
|
22
35
|
/** `$id` of the JSON Schema for `records --since --json`, shipped beside this file. */
|
|
23
36
|
export const RECORDS_SINCE_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records-since/v1/records-since.schema.json";
|
|
@@ -27,9 +40,38 @@ export const RECORDS_SINCE_ERROR_CODES = [
|
|
|
27
40
|
...READ_ERROR_CODES,
|
|
28
41
|
/** `--since` names no commit. */
|
|
29
42
|
"since-rev-unknown",
|
|
43
|
+
/** `--since` has a session id's shape, names no commit, and no session has the id (#2693). */
|
|
44
|
+
"since-session-unknown",
|
|
30
45
|
] as const satisfies readonly ReasonCode[];
|
|
31
46
|
export type RecordsSinceErrorCode = (typeof RECORDS_SINCE_ERROR_CODES)[number];
|
|
32
47
|
|
|
48
|
+
/** Why a session's comparison is not the one between its open and close commits (#2693). Closed. */
|
|
49
|
+
export const RECORDS_SINCE_REASON_CODES = [
|
|
50
|
+
/** The session is open, so the comparison runs to the working tree. */
|
|
51
|
+
"since-session-open",
|
|
52
|
+
] as const satisfies readonly ReasonCode[];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The session `--since` named (#2693), and where the two revisions came
|
|
56
|
+
* from: the record's own fields, or git's history of its file for a session
|
|
57
|
+
* written before chant wrote them.
|
|
58
|
+
*/
|
|
59
|
+
export interface SinceSession {
|
|
60
|
+
id: string;
|
|
61
|
+
/** The session file, from the repository root, with / separators. */
|
|
62
|
+
path: string;
|
|
63
|
+
state: string | null;
|
|
64
|
+
/** Where `since` came from: the session's opening revision field, or the last commit that added its file. */
|
|
65
|
+
sinceFrom: "opened-rev" | "history";
|
|
66
|
+
/**
|
|
67
|
+
* Where `at` came from: the first commit after the closing revision that
|
|
68
|
+
* changed the session file (the commit carrying the close), the last
|
|
69
|
+
* commit that changed the file, --at, or the working tree.
|
|
70
|
+
*/
|
|
71
|
+
atFrom: "close-commit" | "history" | "at" | "working-tree";
|
|
72
|
+
reasons: { code: (typeof RECORDS_SINCE_REASON_CODES)[number]; message: string }[];
|
|
73
|
+
}
|
|
74
|
+
|
|
33
75
|
/** The kinds of change, in the order the output lists them. */
|
|
34
76
|
export const SINCE_CHANGE_KINDS = ["new", "removed", "state", "verdict", "supersession", "pin"] as const;
|
|
35
77
|
export type SinceChangeKind = (typeof SINCE_CHANGE_KINDS)[number];
|
|
@@ -52,6 +94,8 @@ export type RecordsSinceDocument =
|
|
|
52
94
|
kind: { name: string; schema: string; file: string };
|
|
53
95
|
since: string;
|
|
54
96
|
at: string | null;
|
|
97
|
+
/** With `--since <session id>` only. */
|
|
98
|
+
session?: SinceSession;
|
|
55
99
|
changes: SinceChange[];
|
|
56
100
|
summary: Record<SinceChangeKind, number>;
|
|
57
101
|
}
|
|
@@ -81,8 +125,10 @@ export async function queryRecordsSince(query: RecordsSinceQuery): Promise<Recor
|
|
|
81
125
|
error: { code, message },
|
|
82
126
|
});
|
|
83
127
|
try {
|
|
84
|
-
const
|
|
85
|
-
const
|
|
128
|
+
const found = await findSinceSession(query);
|
|
129
|
+
const since = resolveSince(query.cwd, found ? found.since : query.since, found?.session.id);
|
|
130
|
+
const at = found ? (found.at ?? undefined) : query.at;
|
|
131
|
+
const after = await readRecordsFor({ kind: query.kind, at, cwd: query.cwd });
|
|
86
132
|
let before: RecordEntry[];
|
|
87
133
|
try {
|
|
88
134
|
before = (await readRecordsFor({ kind: query.kind, at: since, cwd: query.cwd })).result.records;
|
|
@@ -99,6 +145,7 @@ export async function queryRecordsSince(query: RecordsSinceQuery): Promise<Recor
|
|
|
99
145
|
kind: kindView(after.loaded, after.root),
|
|
100
146
|
since,
|
|
101
147
|
at: after.at,
|
|
148
|
+
...(found ? { session: found.session } : {}),
|
|
102
149
|
changes,
|
|
103
150
|
summary,
|
|
104
151
|
};
|
|
@@ -113,8 +160,71 @@ function kindView(loaded: LoadedRecordKind, root: string): { name: string; schem
|
|
|
113
160
|
return { name: loaded.kind.name, schema: loaded.kind.schema.id, file: relative(root, loaded.file).split("\\").join("/") };
|
|
114
161
|
}
|
|
115
162
|
|
|
116
|
-
/**
|
|
117
|
-
|
|
163
|
+
/**
|
|
164
|
+
* The session `query.since` names, with the revisions to compare, or null
|
|
165
|
+
* when it names none and is to be read as a revision. It is looked up only
|
|
166
|
+
* in a git repository, when it has a session id's shape, in the kind read
|
|
167
|
+
* when that is a session kind and in the session kinds the declaration
|
|
168
|
+
* names. An id-shaped value that is neither a session nor a commit is
|
|
169
|
+
* `since-session-unknown`.
|
|
170
|
+
*/
|
|
171
|
+
async function findSinceSession(query: RecordsSinceQuery): Promise<{ since: string; at: string | null; session: SinceSession } | null> {
|
|
172
|
+
const top = gitRoot(realpathOr(query.cwd));
|
|
173
|
+
if (!top || !SESSION_ID.test(query.since)) return null;
|
|
174
|
+
const kinds = await findSessionKinds(query.cwd, [query.kind]);
|
|
175
|
+
for (const k of kinds) {
|
|
176
|
+
let records: RecordEntry[];
|
|
177
|
+
try {
|
|
178
|
+
records = (await readRecordsFor({ kind: k.file, cwd: query.cwd })).result.records;
|
|
179
|
+
} catch (err) {
|
|
180
|
+
if (err instanceof RecordReadError) continue;
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
const s = records.find((r) => r.id === query.since);
|
|
184
|
+
if (!s) continue;
|
|
185
|
+
const decl = k.kind.session!;
|
|
186
|
+
const field = (f: string | undefined): string | null => (f !== undefined && typeof s.data?.[f] === "string" ? (s.data[f] as string) : null);
|
|
187
|
+
const closed = s.state !== null && (k.kind.closedStates ?? []).includes(s.state);
|
|
188
|
+
const session: SinceSession = { id: s.id!, path: s.path, state: s.state, sinceFrom: "opened-rev", atFrom: "working-tree", reasons: [] };
|
|
189
|
+
let since = field(decl.openedRev);
|
|
190
|
+
if (since === null) {
|
|
191
|
+
since = commitsTouching(top, s.path, ["--diff-filter=A"])[0] ?? null;
|
|
192
|
+
session.sinceFrom = "history";
|
|
193
|
+
if (since === null) {
|
|
194
|
+
throw new SinceError("since-rev-unknown", `session ${s.id} (${s.path}) names no commit it opened at${decl.openedRev ? ` in ${decl.openedRev}` : ""}, and no commit added its file`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
let at: string | null = null;
|
|
198
|
+
if (query.at !== undefined) {
|
|
199
|
+
at = query.at;
|
|
200
|
+
session.atFrom = "at";
|
|
201
|
+
} else if (closed) {
|
|
202
|
+
const closedRev = field(decl.closedRev);
|
|
203
|
+
if (closedRev !== null && /^[0-9a-f]{40,64}$/.test(closedRev)) {
|
|
204
|
+
// The oldest of these is the commit that carried the close. None yet means the close is only in the working tree.
|
|
205
|
+
at = commitsTouching(top, s.path, [`${closedRev}..HEAD`]).slice(-1)[0] ?? null;
|
|
206
|
+
session.atFrom = at === null ? "working-tree" : "close-commit";
|
|
207
|
+
} else {
|
|
208
|
+
at = commitsTouching(top, s.path, ["-1"])[0] ?? null;
|
|
209
|
+
session.atFrom = at === null ? "working-tree" : "history";
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
session.reasons.push({ code: "since-session-open", message: `session ${s.id} is ${s.state ?? "not closed"}, so it has no closing revision, and the comparison runs to the working tree` });
|
|
213
|
+
}
|
|
214
|
+
return { since, at, session };
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
resolveRevision(top, query.since);
|
|
218
|
+
return null;
|
|
219
|
+
} catch (err) {
|
|
220
|
+
if (!(err instanceof RecordReadError)) throw err;
|
|
221
|
+
}
|
|
222
|
+
if (kinds.length === 0) return null;
|
|
223
|
+
throw new SinceError("since-session-unknown", `--since ${query.since} names no commit, and no ${[...new Set(kinds.map((k) => k.kind.name))].join(" or ")} record has that id`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The full commit id `rev` names, from the repository holding `cwd`; `session` names the session it came from. */
|
|
227
|
+
function resolveSince(cwd: string, rev: string, session?: string): string {
|
|
118
228
|
let dir = cwd;
|
|
119
229
|
try {
|
|
120
230
|
dir = realpathSync(cwd);
|
|
@@ -127,7 +237,7 @@ function resolveSince(cwd: string, rev: string): string {
|
|
|
127
237
|
return resolveRevision(top, rev);
|
|
128
238
|
} catch (err) {
|
|
129
239
|
if (err instanceof RecordReadError && err.code === "revision-unknown") {
|
|
130
|
-
throw new SinceError("since-rev-unknown", `--since ${rev} names no commit in this repository`);
|
|
240
|
+
throw new SinceError("since-rev-unknown", session ? `session ${session} names ${rev}, which is no commit in this repository` : `--since ${rev} names no commit in this repository`);
|
|
131
241
|
}
|
|
132
242
|
throw err;
|
|
133
243
|
}
|
|
@@ -252,6 +362,10 @@ export function formatSince(doc: Extract<RecordsSinceDocument, { changes: SinceC
|
|
|
252
362
|
}
|
|
253
363
|
});
|
|
254
364
|
const s = doc.summary;
|
|
365
|
+
if (doc.session) {
|
|
366
|
+
const x = doc.session;
|
|
367
|
+
lines.unshift(`session ${x.id} (${x.state ?? "no state"}): opened at ${doc.since.slice(0, 8)} (${x.sinceFrom}), compared to ${doc.at ? `${doc.at.slice(0, 8)} (${x.atFrom})` : "the working tree"}`);
|
|
368
|
+
}
|
|
255
369
|
lines.push(
|
|
256
370
|
`${doc.changes.length} changes since ${doc.since.slice(0, 8)}${doc.at ? ` to ${doc.at.slice(0, 8)}` : " to the working tree"}: ${s.new} new, ${s.removed} removed, ${s.state} state, ${s.verdict} verdicts, ${s.supersession} supersessions, ${s.pin} pins`,
|
|
257
371
|
);
|
|
@@ -13,6 +13,7 @@ import { afterAll, describe, expect, test } from "vitest";
|
|
|
13
13
|
import { RECORD_WARNING_CODES } from "./records";
|
|
14
14
|
import amendSchema from "./records-amend.schema.json";
|
|
15
15
|
import newSchema from "./records-new.schema.json";
|
|
16
|
+
import closeSchema from "./records-close.schema.json";
|
|
16
17
|
import reviewSchema from "./records-review.schema.json";
|
|
17
18
|
import {
|
|
18
19
|
AMEND_ERROR_CODES,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
REVIEW_ERROR_CODES,
|
|
27
28
|
reviewRecord,
|
|
28
29
|
} from "./records-write";
|
|
30
|
+
import { CLOSE_ERROR_CODES, RECORDS_CLOSE_SCHEMA_ID } from "./records-close";
|
|
29
31
|
|
|
30
32
|
const REPO = join(import.meta.dirname, "..", "..", "..", "..");
|
|
31
33
|
const KIND = "docs/design/decisions/decision.kind.mjs";
|
|
@@ -50,6 +52,7 @@ const SCHEMAS = [
|
|
|
50
52
|
{ name: "records-new", schema: newSchema, id: RECORDS_NEW_SCHEMA_ID, codes: NEW_ERROR_CODES },
|
|
51
53
|
{ name: "records-amend", schema: amendSchema, id: RECORDS_AMEND_SCHEMA_ID, codes: AMEND_ERROR_CODES },
|
|
52
54
|
{ name: "records-review", schema: reviewSchema, id: RECORDS_REVIEW_SCHEMA_ID, codes: REVIEW_ERROR_CODES },
|
|
55
|
+
{ name: "records-close", schema: closeSchema, id: RECORDS_CLOSE_SCHEMA_ID, codes: CLOSE_ERROR_CODES },
|
|
53
56
|
] as const;
|
|
54
57
|
|
|
55
58
|
function expectValid(schema: object, doc: unknown): void {
|
|
@@ -118,8 +121,9 @@ describe("real output validates", () => {
|
|
|
118
121
|
expectValid(amendSchema, await amendRecord({ kind: KIND, id: "ws-003", fields: JSON.stringify({ title: "x" }), cwd: root }));
|
|
119
122
|
|
|
120
123
|
for (const dryRun of [true, false]) {
|
|
121
|
-
expectValid(reviewSchema, await reviewRecord({ kind: KIND, id: made.id, verdict: "dissent", by: "alice", note: "Not yet.",
|
|
124
|
+
expectValid(reviewSchema, await reviewRecord({ kind: KIND, id: made.id, verdict: "dissent", by: "alice", note: "Not yet.", dryRun, cwd: root }));
|
|
122
125
|
}
|
|
123
126
|
expectValid(reviewSchema, await reviewRecord({ kind: KIND, id: made.id, verdict: "dissent", by: "alice", cwd: root }));
|
|
127
|
+
expectValid(reviewSchema, await reviewRecord({ kind: KIND, id: made.id, verdict: "agree", by: "alice", session: "S-0001", cwd: root }));
|
|
124
128
|
});
|
|
125
129
|
});
|
|
@@ -311,8 +311,10 @@ describe("records review", () => {
|
|
|
311
311
|
expect(code(await reviewRecord({ kind: KIND, id: "ws-001", verdict: "dissent", by: "bob", cwd: dir }))).toBe("review-note-required");
|
|
312
312
|
expect(code(await reviewRecord({ kind: KIND, id: "ws-001", verdict: "dissent", by: "bob", note: " ", cwd: dir }))).toBe("review-note-required");
|
|
313
313
|
expect(touched(before, snapshot())).toEqual([]);
|
|
314
|
-
const doc = await reviewRecord({ kind: KIND, id: "ws-001", verdict: "dissent", by: "bob", note: "It leaves out X.",
|
|
315
|
-
expect(doc).toMatchObject({ review: { reviewer: "bob", verdict: "dissent", note: "It leaves out X."
|
|
314
|
+
const doc = await reviewRecord({ kind: KIND, id: "ws-001", verdict: "dissent", by: "bob", note: "It leaves out X.", cwd: dir });
|
|
315
|
+
expect(doc).toMatchObject({ review: { reviewer: "bob", verdict: "dissent", note: "It leaves out X." } });
|
|
316
|
+
// No session kind names these decisions as its subjects, so no session exists to give a verdict in (#2693).
|
|
317
|
+
expect(code(await reviewRecord({ kind: KIND, id: "ws-001", verdict: "agree", by: "carol", session: "S-0001", cwd: dir }))).toBe("session-unknown");
|
|
316
318
|
});
|
|
317
319
|
|
|
318
320
|
test("reviews accumulate on a proposal too", async () => {
|