@intentius/chant 0.85.0 → 0.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +13 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +13 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
- package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
- package/dist/workspace/checks/records.d.ts +1 -0
- package/dist/workspace/checks/records.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +4 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +14 -1
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/conformance/index.d.ts +211 -0
- package/dist/workspace/conformance/index.d.ts.map +1 -0
- package/dist/workspace/conformance/vitest.d.ts +11 -0
- package/dist/workspace/conformance/vitest.d.ts.map +1 -0
- package/dist/workspace/declaration.d.ts +28 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +40 -0
- package/dist/workspace/declared-kinds.d.ts +43 -0
- package/dist/workspace/declared-kinds.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +11 -0
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/intent-cli.d.ts +2 -1
- package/dist/workspace/intent-cli.d.ts.map +1 -1
- package/dist/workspace/intent-joins.d.ts +45 -8
- package/dist/workspace/intent-joins.d.ts.map +1 -1
- package/dist/workspace/intent.d.ts +71 -7
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/ls.d.ts +31 -1
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +47 -4
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-sessions.d.ts +51 -0
- package/dist/workspace/record-sessions.d.ts.map +1 -0
- package/dist/workspace/record-source.d.ts +2 -0
- package/dist/workspace/record-source.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +71 -4
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-since.d.ts +90 -0
- package/dist/workspace/records-since.d.ts.map +1 -0
- package/dist/workspace/records-write.d.ts +171 -0
- package/dist/workspace/records-write.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +244 -15
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/runtimes.d.ts +60 -0
- package/dist/workspace/runtimes.d.ts.map +1 -0
- package/dist/workspace/status-gates.d.ts +90 -0
- package/dist/workspace/status-gates.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +17 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +85 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +7 -0
- package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
- package/dist/workspace/work.d.ts +56 -0
- package/dist/workspace/work.d.ts.map +1 -0
- package/package.json +19 -1
- package/src/cli/main.ts +55 -3
- package/src/cli/registry.ts +13 -1
- package/src/lifecycle/gate-ledger.ts +14 -0
- package/src/workspace/__fixtures__/sessions.ts +66 -0
- package/src/workspace/checks/records.ts +19 -0
- package/src/workspace/checks.test.ts +2 -0
- package/src/workspace/checks.ts +7 -1
- package/src/workspace/composites.schema.json +65 -3
- package/src/workspace/composites.test.ts +95 -5
- package/src/workspace/composites.ts +28 -7
- package/src/workspace/conformance/__fixture__/app/package.json +7 -0
- package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +376 -0
- package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
- package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
- package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
- package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
- package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
- package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
- package/src/workspace/conformance/conformance.test.ts +149 -0
- package/src/workspace/conformance/index.mjs +31 -0
- package/src/workspace/conformance/index.ts +453 -0
- package/src/workspace/conformance/vitest.ts +62 -0
- package/src/workspace/declaration.schema.json +40 -0
- package/src/workspace/declaration.ts +62 -0
- package/src/workspace/declared-kinds.test.ts +321 -0
- package/src/workspace/declared-kinds.ts +76 -0
- package/src/workspace/graph-cli.ts +8 -0
- package/src/workspace/intent-cli.ts +29 -6
- package/src/workspace/intent-gaps.test.ts +217 -0
- package/src/workspace/intent-joins.test.ts +60 -0
- package/src/workspace/intent-joins.ts +71 -19
- package/src/workspace/intent.schema.json +304 -7
- package/src/workspace/intent.test.ts +99 -0
- package/src/workspace/intent.ts +365 -46
- package/src/workspace/ls.schema.json +34 -0
- package/src/workspace/ls.ts +69 -4
- package/src/workspace/read-contract.test.ts +30 -9
- package/src/workspace/reason-codes.test.ts +16 -4
- package/src/workspace/reason-codes.ts +55 -4
- package/src/workspace/record-assets.test.ts +3 -1
- package/src/workspace/record-sessions.ts +105 -0
- package/src/workspace/record-source.ts +14 -5
- package/src/workspace/records-amend.schema.json +167 -0
- package/src/workspace/records-cli.ts +308 -19
- package/src/workspace/records-contract.test.ts +57 -2
- package/src/workspace/records-formats.test.ts +640 -0
- package/src/workspace/records-new.schema.json +158 -0
- package/src/workspace/records-quorum.test.ts +196 -0
- package/src/workspace/records-review.schema.json +227 -0
- package/src/workspace/records-sessions.test.ts +108 -0
- package/src/workspace/records-since.schema.json +193 -0
- package/src/workspace/records-since.test.ts +174 -0
- package/src/workspace/records-since.ts +259 -0
- package/src/workspace/records-write-contract.test.ts +125 -0
- package/src/workspace/records-write.test.ts +373 -0
- package/src/workspace/records-write.ts +765 -0
- package/src/workspace/records.schema.json +202 -9
- package/src/workspace/records.ts +700 -41
- package/src/workspace/runtimes.ts +107 -0
- package/src/workspace/status-contract.test.ts +163 -0
- package/src/workspace/status-gates.ts +215 -0
- package/src/workspace/status.schema.json +69 -3
- package/src/workspace/status.ts +35 -2
- package/src/workspace/trust/seal.test.ts +232 -0
- package/src/workspace/trust/seal.ts +195 -0
- package/src/workspace/trust/ssh-commit.ts +2 -2
- package/src/workspace/work.test.ts +390 -0
- package/src/workspace/work.ts +163 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two gaps a work item could not name before #2686, a stale pin and an
|
|
3
|
+
* open dissent, and `work-done-gap-open` on a records read, on a workspace
|
|
4
|
+
* built in a throwaway git repository:
|
|
5
|
+
*
|
|
6
|
+
* - c1 adds the app, the screen spec `design/screens/home.json`, and dec-001,
|
|
7
|
+
* which constrains the spec's path and pins it by hash.
|
|
8
|
+
* - c2 adds dec-002, which supersedes dec-001, constrains the same path and
|
|
9
|
+
* pins the spec at the same hash, so its pin is stale; dec-003, which
|
|
10
|
+
* constrains `app/server.mjs` and has one open dissent (bob's), one
|
|
11
|
+
* withdrawn and one addressed; and the work items:
|
|
12
|
+
* W-001 (open, from intent-pin-stale on the spec), W-002 (open, from
|
|
13
|
+
* intent-decision-contested on the server), W-003 (done, from
|
|
14
|
+
* intent-decision-contested on the server, which still fires), W-004
|
|
15
|
+
* (done, from intent-pin-drifted on the spec, which does not fire) and
|
|
16
|
+
* W-005 (open, implements dec-002 and dec-003, no gap source).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
22
|
+
import { sha256Hex } from "../content-digest";
|
|
23
|
+
import { cleanScratch, contract, git, REPO, repo, writeFiles } from "./__fixtures__/contract-repo";
|
|
24
|
+
import { intentGraph, type FindingNode, type IntentDocument, type WorkNode } from "./intent";
|
|
25
|
+
import intentSchema from "./intent.schema.json";
|
|
26
|
+
import { parseFrontMatter } from "./records";
|
|
27
|
+
import { queryRecords } from "./records-cli";
|
|
28
|
+
import recordsSchema from "./records.schema.json";
|
|
29
|
+
|
|
30
|
+
const REF = join(REPO, "reference-workspace");
|
|
31
|
+
const BASE = (() => {
|
|
32
|
+
const fm = parseFrontMatter(readFileSync(join(REF, "decisions", "ref-001-how-the-app-is-deployed.md"), "utf-8"));
|
|
33
|
+
if (!fm.ok) throw new Error(fm.message);
|
|
34
|
+
return fm.value;
|
|
35
|
+
})();
|
|
36
|
+
|
|
37
|
+
const HOME = `${JSON.stringify({ route: "/", regions: ["header"] }, null, 2)}\n`;
|
|
38
|
+
const pin = { title: "The home screen spec", path: "design/screens/home.json", sha256: sha256Hex(Buffer.from(HOME)), as_of: "2026-09-24T12:00:00Z" };
|
|
39
|
+
|
|
40
|
+
function decision(id: string, fields: Record<string, unknown>): string {
|
|
41
|
+
const data = { ...BASE, id, title: `Decision ${id}`, state: "decided", supersedes: [], evidence: [], reviews: [], ...fields };
|
|
42
|
+
return `---\n${JSON.stringify(data, null, 2)}\n---\n\n# ${id}\n`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function work(id: string, fields: Record<string, unknown>): string {
|
|
46
|
+
const data = { schema: 1, id, title: `Work ${id}`, state: "open", implements: [], needs: [], constrains: ["path:app/server.mjs"], evidence: [], opened_on: "2026-09-24", supersedes: [], ...fields };
|
|
47
|
+
return `---\n${JSON.stringify(data, null, 2)}\n---\n\n# ${id}\n\nWhat the work is.\n`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const done = { state: "done", closed_on: "2026-09-25", evidence: [{ title: "The review", url: "https://example.com/review" }] };
|
|
51
|
+
const KIND_FILES = {
|
|
52
|
+
"decisions/decision.kind.mjs": readFileSync(join(REF, "decisions", "decision.kind.mjs"), "utf-8"),
|
|
53
|
+
"decisions/decision.schema.json": readFileSync(join(REF, "decisions", "decision.schema.json"), "utf-8"),
|
|
54
|
+
"work/work.kind.mjs": readFileSync(join(REF, "work", "work.kind.mjs"), "utf-8"),
|
|
55
|
+
"work/work.schema.json": readFileSync(join(REF, "work", "work.schema.json"), "utf-8"),
|
|
56
|
+
};
|
|
57
|
+
const DECLARATION = JSON.stringify({
|
|
58
|
+
name: "studio",
|
|
59
|
+
schema: 1,
|
|
60
|
+
members: [
|
|
61
|
+
{ name: "app", dir: "app", kind: "other", because: "a plain Node server" },
|
|
62
|
+
{ name: "design", dir: "design", kind: "other", because: "the screen specs" },
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const DEC_003 = decision("dec-003", {
|
|
67
|
+
constrains: ["path:app/server.mjs"],
|
|
68
|
+
reviews: [
|
|
69
|
+
{ reviewer: "Bob", verdict: "dissent", note: "The port is wrong.", on: "2026-09-24" },
|
|
70
|
+
{ reviewer: "carol", verdict: "dissent", note: "Too early.", on: "2026-09-24", withdrawn_on: "2026-09-25" },
|
|
71
|
+
{ reviewer: "dave", verdict: "dissent", note: "Needs a test.", on: "2026-09-24", addressed_by: "acme/studio#7" },
|
|
72
|
+
{ reviewer: "erin", verdict: "agree", on: "2026-09-24" },
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
let root: string;
|
|
77
|
+
|
|
78
|
+
function commit(message: string): void {
|
|
79
|
+
git(root, "add", "-A");
|
|
80
|
+
git(root, "commit", "-q", "-m", message);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
beforeAll(() => {
|
|
84
|
+
root = repo({
|
|
85
|
+
"chant.workspace.json": DECLARATION,
|
|
86
|
+
"app/server.mjs": "export const port = 8080;\n",
|
|
87
|
+
"design/screens/home.json": HOME,
|
|
88
|
+
...KIND_FILES,
|
|
89
|
+
"decisions/dec-001-spec.md": decision("dec-001", { constrains: ["path:design/screens/home.json"], evidence: [pin] }),
|
|
90
|
+
});
|
|
91
|
+
commit("the app, the spec and dec-001");
|
|
92
|
+
writeFiles(root, {
|
|
93
|
+
"decisions/dec-002-spec-again.md": decision("dec-002", { constrains: ["path:design/screens/home.json"], evidence: [pin], supersedes: [{ decision: "dec-001" }] }),
|
|
94
|
+
"decisions/dec-003-server.md": DEC_003,
|
|
95
|
+
"work/W-001-stale.md": work("W-001", { constrains: ["path:design/screens/home.json"], source: { finding: "intent-pin-stale", region: "design/screens/home.json", decision: "dec-002" } }),
|
|
96
|
+
"work/W-002-contested.md": work("W-002", { source: { finding: "intent-decision-contested", region: "app/server.mjs", decision: "dec-003" } }),
|
|
97
|
+
"work/W-003-contested-done.md": work("W-003", { ...done, source: { finding: "intent-decision-contested", region: "app/server.mjs", decision: "dec-003" } }),
|
|
98
|
+
"work/W-004-drift-done.md": work("W-004", { ...done, constrains: ["path:design/screens/home.json"], source: { finding: "intent-pin-drifted", region: "design/screens/home.json" } }),
|
|
99
|
+
"work/W-005-carry-out.md": work("W-005", { implements: ["dec-002", "dec-003"] }),
|
|
100
|
+
});
|
|
101
|
+
commit("dec-002 supersedes dec-001, dec-003 is contested, and the work");
|
|
102
|
+
});
|
|
103
|
+
afterAll(cleanScratch);
|
|
104
|
+
|
|
105
|
+
const intent = contract(intentSchema);
|
|
106
|
+
const records = contract(recordsSchema);
|
|
107
|
+
type Result = Exclude<IntentDocument, { error: unknown }>;
|
|
108
|
+
|
|
109
|
+
async function walk(region: string): Promise<Result> {
|
|
110
|
+
const { doc } = await intentGraph({ cwd: root, region, kinds: ["decisions/decision.kind.mjs", "work/work.kind.mjs"].map((k) => join(root, k)) });
|
|
111
|
+
intent.expectValid(doc);
|
|
112
|
+
if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
|
|
113
|
+
return doc;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const findingsOf = (doc: Result, code: string) => doc.nodes.filter((n): n is FindingNode => n.kind === "finding" && n.code === code);
|
|
117
|
+
|
|
118
|
+
describe("intent-pin-stale (#2686)", () => {
|
|
119
|
+
test("a current decision pinning the artifact at the hash the record it supersedes pinned is stale, beside drifted and missing", async () => {
|
|
120
|
+
const doc = await walk("design/screens/home.json");
|
|
121
|
+
expect(doc.nodes.find((n) => n.id === "artifact:design/screens/home.json")).toMatchObject({ pinState: "stale" });
|
|
122
|
+
const stale = findingsOf(doc, "intent-pin-stale");
|
|
123
|
+
expect(stale).toHaveLength(1);
|
|
124
|
+
expect(stale[0].concerns).toEqual(["record:decision/dec-002", "artifact:design/screens/home.json", "record:decision/dec-001"]);
|
|
125
|
+
expect(stale[0].message).toContain("dec-001");
|
|
126
|
+
expect(findingsOf(doc, "intent-pin-drifted")).toEqual([]);
|
|
127
|
+
expect(findingsOf(doc, "intent-pin-missing")).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("it is addressed by the item that came from it and by the item implementing the decision", async () => {
|
|
131
|
+
const doc = await walk("design/screens/home.json");
|
|
132
|
+
const [stale] = findingsOf(doc, "intent-pin-stale");
|
|
133
|
+
expect(stale).toMatchObject({ addressed: true, addressedBy: [{ id: "W-001", state: "open" }, { id: "W-005", state: "open" }] });
|
|
134
|
+
expect(doc.edges).toContainEqual({ kind: "addressed-by", from: stale.id, to: "record:work/W-001" });
|
|
135
|
+
expect(doc.edges).toContainEqual({ kind: "addressed-by", from: stale.id, to: "record:work/W-005" });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("a stale pin on a decision in the graph only through supersession is not raised", async () => {
|
|
139
|
+
// app/server.mjs brings in no decision pinning the spec.
|
|
140
|
+
const doc = await walk("app/server.mjs");
|
|
141
|
+
expect(findingsOf(doc, "intent-pin-stale")).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("intent-decision-contested (#2686)", () => {
|
|
146
|
+
test("a current decision constraining the region with a dissent neither addressed nor withdrawn is contested, with the count and principals", async () => {
|
|
147
|
+
const doc = await walk("app/server.mjs");
|
|
148
|
+
const contested = findingsOf(doc, "intent-decision-contested");
|
|
149
|
+
expect(contested).toHaveLength(1);
|
|
150
|
+
expect(contested[0]).toMatchObject({ concerns: ["record:decision/dec-003", "region:app/server.mjs"], openConcerns: { count: 1, principals: ["bob"] } });
|
|
151
|
+
expect(contested[0].message).toContain("1 open concern, from Bob");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("it is addressed by the items that came from it, and not through implements", async () => {
|
|
155
|
+
const doc = await walk("app/server.mjs");
|
|
156
|
+
const [contested] = findingsOf(doc, "intent-decision-contested");
|
|
157
|
+
expect(contested.addressedBy).toEqual([
|
|
158
|
+
{ id: "W-002", state: "open" },
|
|
159
|
+
{ id: "W-003", state: "done" },
|
|
160
|
+
]);
|
|
161
|
+
expect(doc.edges).toContainEqual({ kind: "addressed-by", from: contested.id, to: "record:work/W-002" });
|
|
162
|
+
const w003 = doc.nodes.find((n): n is WorkNode => n.kind === "work" && n.record === "W-003");
|
|
163
|
+
expect(w003!.warnings.map((w) => w.code)).toEqual(["work-done-gap-open"]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("a decision whose dissents are all addressed or withdrawn is not contested", async () => {
|
|
167
|
+
writeFiles(root, { "decisions/dec-003-server.md": DEC_003.replace(`"note": "The port is wrong.",`, `"note": "The port is wrong.", "addressed_by": "acme/studio#8",`) });
|
|
168
|
+
try {
|
|
169
|
+
const doc = await walk("app/server.mjs");
|
|
170
|
+
expect(findingsOf(doc, "intent-decision-contested")).toEqual([]);
|
|
171
|
+
} finally {
|
|
172
|
+
writeFiles(root, { "decisions/dec-003-server.md": DEC_003 });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("work-done-gap-open in records (#2686)", () => {
|
|
178
|
+
test("records --json warns on a done item whose gap still fires, and not on one whose gap closed", async () => {
|
|
179
|
+
const doc = await queryRecords({ kind: "work/work.kind.mjs", cwd: root });
|
|
180
|
+
records.expectValid(doc);
|
|
181
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
182
|
+
const warned = Object.fromEntries(doc.records.map((r) => [r.id, r.warnings.map((w) => w.code)]));
|
|
183
|
+
expect(warned).toEqual({ "W-001": [], "W-002": [], "W-003": ["work-done-gap-open"], "W-004": [], "W-005": [] });
|
|
184
|
+
expect(doc.records.find((r) => r.id === "W-003")!.warnings[0].message).toContain("intent-decision-contested");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("the warning follows the revision read: under --at c1 there is no work, and under --at HEAD W-003 is warned", async () => {
|
|
188
|
+
const c1 = git(root, "rev-parse", "HEAD~1");
|
|
189
|
+
const doc = await queryRecords({ kind: "work/work.kind.mjs", cwd: root, at: c1 });
|
|
190
|
+
records.expectValid(doc);
|
|
191
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
192
|
+
expect(doc.records).toEqual([]);
|
|
193
|
+
const head = await queryRecords({ kind: "work/work.kind.mjs", cwd: root, at: "HEAD" });
|
|
194
|
+
if ("error" in head) throw new Error(head.error.message);
|
|
195
|
+
expect(head.records.find((r) => r.id === "W-003")!.warnings.map((w) => w.code)).toEqual(["work-done-gap-open"]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("without a workspace declaration the walk can't be made, so the record is left as it was", async () => {
|
|
199
|
+
const bare = repo({
|
|
200
|
+
"app/server.mjs": "export const port = 8080;\n",
|
|
201
|
+
...KIND_FILES,
|
|
202
|
+
"decisions/dec-003-server.md": DEC_003,
|
|
203
|
+
"work/W-003-contested-done.md": work("W-003", { ...done, source: { finding: "intent-decision-contested", region: "app/server.mjs" } }),
|
|
204
|
+
});
|
|
205
|
+
git(bare, "add", "-A");
|
|
206
|
+
git(bare, "commit", "-q", "-m", "one");
|
|
207
|
+
const doc = await queryRecords({ kind: "work/work.kind.mjs", cwd: bare });
|
|
208
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
209
|
+
expect(doc.records[0].warnings).toEqual([]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("the intent graph reads its kinds without asking records for the warning, so it is raised once", async () => {
|
|
213
|
+
const doc = await walk("app/server.mjs");
|
|
214
|
+
const w003 = doc.nodes.find((n): n is WorkNode => n.kind === "work" && n.record === "W-003");
|
|
215
|
+
expect(w003!.warnings.filter((w) => w.code === "work-done-gap-open")).toHaveLength(1);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The commit-join hook's data form and its export checks, with no git
|
|
3
|
+
* (#2651, #2663).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "vitest";
|
|
7
|
+
import { joinByData, readCommitJoins, trailerValue, trailerValues, type CommitJoinContext, type IntentCommit } from "./intent-joins";
|
|
8
|
+
|
|
9
|
+
const commit = (trailers: Record<string, string[]>): IntentCommit => ({ sha: "a".repeat(40), subject: "s", body: "", author: { name: "t", email: "t@example.com" }, date: "2026-09-24T00:00:00Z", trailers });
|
|
10
|
+
|
|
11
|
+
const files: Record<string, string> = {
|
|
12
|
+
"evidence/E-1.json": JSON.stringify({ id: "ignored", kind: "test-run" }),
|
|
13
|
+
"evidence/E-3.json": JSON.stringify({ kind: "review" }),
|
|
14
|
+
"units/U-1.json": JSON.stringify({ role: "implement" }),
|
|
15
|
+
};
|
|
16
|
+
const context: CommitJoinContext = { read: (p) => files[p], list: () => undefined, at: null };
|
|
17
|
+
|
|
18
|
+
describe("the data form reads every trailer value (#2663)", () => {
|
|
19
|
+
test("each value of the evidence trailer, under any case of its key, is one piece of evidence", () => {
|
|
20
|
+
const data = { trailers: { unit: "Unit", evidence: "Evidence" }, records: { unit: "units/{id}.json", evidence: "evidence/{id}.json" } };
|
|
21
|
+
const out = joinByData(data, commit({ Unit: ["U-1"], Evidence: ["E-1", " E-2 ", "E-1"], evidence: ["E-3", ""] }), context);
|
|
22
|
+
expect(out).toEqual({
|
|
23
|
+
unit: { id: "U-1", role: "implement" },
|
|
24
|
+
evidence: [
|
|
25
|
+
{ id: "E-1", kind: "test-run" },
|
|
26
|
+
{ id: "E-2" },
|
|
27
|
+
{ id: "E-3", kind: "review" },
|
|
28
|
+
],
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a unit or contract trailer gives its first value, since a commit has one of each", () => {
|
|
33
|
+
const out = joinByData({ trailers: { unit: "Unit", contract: "Contract" } }, commit({ Unit: ["U-1", "U-2"], contract: ["C-1"], Contract: ["C-2"] }), context);
|
|
34
|
+
expect(out.unit).toEqual({ id: "U-1" });
|
|
35
|
+
expect(out.contract?.id).toBe(trailerValue({ contract: ["C-1"], Contract: ["C-2"] }, "Contract"));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("trailerValues keeps git's order, trims, and drops empty values and repeats", () => {
|
|
39
|
+
expect(trailerValues({ "Chud-Evidence": ["h1", "h2"], "chud-evidence": ["h2", " h3", " "] }, "CHUD-EVIDENCE")).toEqual(["h1", "h2", "h3"]);
|
|
40
|
+
expect(trailerValues({}, "X")).toEqual([]);
|
|
41
|
+
expect(trailerValue({ X: [" "] }, "x")).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("commitJoinsName (#2663)", () => {
|
|
46
|
+
test("names the findings of either form, and leaves the data form's own keys alone", () => {
|
|
47
|
+
const join = () => undefined;
|
|
48
|
+
expect(readCommitJoins({ commitJoins: join, commitJoinsName: "chud" })).toEqual({ form: "function", join, name: "chud" });
|
|
49
|
+
expect(readCommitJoins({ commitJoins: { trailers: { unit: "Unit" } }, commitJoinsName: "chud" })).toEqual({ form: "data", data: { trailers: { unit: "Unit" } }, name: "chud" });
|
|
50
|
+
expect(readCommitJoins({ commitJoins: join })).toEqual({ form: "function", join });
|
|
51
|
+
// A name inside the data form is still a key the data form does not have.
|
|
52
|
+
expect(readCommitJoins({ commitJoins: { name: "chud", trailers: {} } })).toMatch(/Unrecognized key/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a name that can't be a plugin:<name>: segment, or one with no joins, is refused", () => {
|
|
56
|
+
for (const bad of ["a:b", "a b", "", 7]) expect(readCommitJoins({ commitJoins: () => undefined, commitJoinsName: bad }), String(bad)).toMatch(/^commitJoinsName:/);
|
|
57
|
+
expect(readCommitJoins({ commitJoinsName: "chud" })).toMatch(/no commitJoins/);
|
|
58
|
+
expect(readCommitJoins({})).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -9,11 +9,16 @@
|
|
|
9
9
|
*
|
|
10
10
|
* - A function `commitJoins(commit, context)` that returns the unit, contract
|
|
11
11
|
* and evidence for one commit, or nothing. It is given the commit's sha,
|
|
12
|
-
* subject, body, author, date and trailers, and a `read`
|
|
13
|
-
*
|
|
12
|
+
* subject, body, author, date and trailers, and a context whose `read(path)`
|
|
13
|
+
* returns a file and whose `list(dir)` lists a directory, both in the tree
|
|
14
|
+
* read (#2663). With `list` a plugin can find the record that names a
|
|
15
|
+
* commit, such as a unit record whose `result.commit` is the commit's sha,
|
|
16
|
+
* so the commit needs no trailer at all.
|
|
14
17
|
* - Data, which core interprets with no plugin code: the trailer keys that
|
|
15
18
|
* name a unit, a contract or evidence, the record paths to read for each
|
|
16
|
-
* (`units/{id}.json`), and the trailer keys that claim authorship.
|
|
19
|
+
* (`units/{id}.json`), and the trailer keys that claim authorship. Every
|
|
20
|
+
* value of the evidence trailer is read; a unit or contract trailer gives
|
|
21
|
+
* its first value, since a commit has one unit and one contract.
|
|
17
22
|
*
|
|
18
23
|
* Either way core never parses a plugin's own trailer or record format: it
|
|
19
24
|
* reads the trailers git reports and hands them over, and a key means
|
|
@@ -27,6 +32,14 @@
|
|
|
27
32
|
* about, which the graph carries as finding nodes. That is how a plugin says
|
|
28
33
|
* what it knows and core does not, such as a contract whose criteria changed
|
|
29
34
|
* in a commit that names no decision.
|
|
35
|
+
*
|
|
36
|
+
* `<name>` is the kind's name by default: its record kind's name, or the
|
|
37
|
+
* file's name without `.kind.mjs`. A kind file may name its findings itself
|
|
38
|
+
* with a sibling export, `commitJoinsName` (#2663), so joins that live beside
|
|
39
|
+
* a record kind called `decision` can still report `plugin:chud:<code>`. It is
|
|
40
|
+
* a sibling export rather than a property because the data form is a closed
|
|
41
|
+
* object whose keys are all joins, and a function's `name` property is
|
|
42
|
+
* already its own name.
|
|
30
43
|
*/
|
|
31
44
|
|
|
32
45
|
import { z } from "zod";
|
|
@@ -47,6 +60,18 @@ export interface IntentCommit {
|
|
|
47
60
|
export interface CommitJoinContext {
|
|
48
61
|
/** The text of a file from the workspace root, in the tree read; undefined when it is not a file there. */
|
|
49
62
|
read(path: string): string | undefined;
|
|
63
|
+
/**
|
|
64
|
+
* The entries directly inside directory `dir`, in the same tree as `read`
|
|
65
|
+
* (#2663): paths from the workspace root, sorted, a directory's with a
|
|
66
|
+
* trailing `/`. `""` or `"."` is the workspace root. Undefined when `dir` is
|
|
67
|
+
* not a directory there.
|
|
68
|
+
*
|
|
69
|
+
* The tree is the one the graph reads, the commit `--at` names or the
|
|
70
|
+
* working tree, and not each joined commit's own tree: a record that names
|
|
71
|
+
* a commit's sha was written after that commit, so it is only in a later
|
|
72
|
+
* tree.
|
|
73
|
+
*/
|
|
74
|
+
list(dir: string): string[] | undefined;
|
|
50
75
|
/** The full commit id read with `--at`, or null for the working tree. */
|
|
51
76
|
at: string | null;
|
|
52
77
|
}
|
|
@@ -63,7 +88,7 @@ export interface JoinedEntity {
|
|
|
63
88
|
|
|
64
89
|
/** A finding a plugin contributes for one commit (#2656). */
|
|
65
90
|
export interface PluginFinding {
|
|
66
|
-
/** `plugin:<name>:<code>`, where `<name>` is the kind's name. */
|
|
91
|
+
/** `plugin:<name>:<code>`, where `<name>` is the kind file's `commitJoinsName`, or else the kind's name. */
|
|
67
92
|
code: string;
|
|
68
93
|
message: string;
|
|
69
94
|
/** What the finding is about: node ids, commit shas, record ids, unit, contract or evidence ids, or paths. */
|
|
@@ -103,24 +128,49 @@ export const commitJoinsDataSchema = z
|
|
|
103
128
|
|
|
104
129
|
export type CommitJoinsData = z.infer<typeof commitJoinsDataSchema>;
|
|
105
130
|
|
|
106
|
-
/**
|
|
107
|
-
|
|
131
|
+
/**
|
|
132
|
+
* A kind file's `commitJoins` export, checked: a function, or data. `name` is
|
|
133
|
+
* its `commitJoinsName` export, when it has one: the namespace of its findings.
|
|
134
|
+
*/
|
|
135
|
+
export type CommitJoins = ({ form: "function"; join: CommitJoinsFunction } | { form: "data"; data: CommitJoinsData }) & { name?: string };
|
|
136
|
+
|
|
137
|
+
/** What a `commitJoinsName` may be: the `<name>` of `plugin:<name>:<code>`. */
|
|
138
|
+
const JOINS_NAME = /^[^:\s]+$/;
|
|
108
139
|
|
|
109
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* Read a kind module's `commitJoins` export and its `commitJoinsName`.
|
|
142
|
+
* Undefined when it has no `commitJoins`; a string when either is malformed,
|
|
143
|
+
* or when it names findings it has no joins for.
|
|
144
|
+
*/
|
|
110
145
|
export function readCommitJoins(mod: Record<string, unknown>): CommitJoins | string | undefined {
|
|
111
146
|
const value = mod.commitJoins;
|
|
112
|
-
|
|
113
|
-
if (
|
|
147
|
+
const name = mod.commitJoinsName;
|
|
148
|
+
if (value === undefined) return name === undefined ? undefined : "commitJoinsName: a kind file with no commitJoins has no findings to name";
|
|
149
|
+
if (name !== undefined && (typeof name !== "string" || !JOINS_NAME.test(name))) return "commitJoinsName: a string with no colon or space, the <name> of plugin:<name>:<code>";
|
|
150
|
+
const named = name === undefined ? {} : { name: name as string };
|
|
151
|
+
if (typeof value === "function") return { form: "function", join: value as CommitJoinsFunction, ...named };
|
|
114
152
|
const parsed = commitJoinsDataSchema.safeParse(value);
|
|
115
153
|
if (!parsed.success) return parsed.error.issues.map((i) => `commitJoins${i.path.length ? `.${i.path.join(".")}` : ""}: ${i.message}`).join("; ");
|
|
116
|
-
return { form: "data", data: parsed.data };
|
|
154
|
+
return { form: "data", data: parsed.data, ...named };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Every value of trailer `key`, trimmed, without empty ones or repeats, compared without case as git does. */
|
|
158
|
+
export function trailerValues(trailers: Record<string, string[]>, key: string): string[] {
|
|
159
|
+
const want = key.toLowerCase();
|
|
160
|
+
const out: string[] = [];
|
|
161
|
+
for (const [k, values] of Object.entries(trailers)) {
|
|
162
|
+
if (k.toLowerCase() !== want) continue;
|
|
163
|
+
for (const v of values) {
|
|
164
|
+
const t = v.trim();
|
|
165
|
+
if (t && !out.includes(t)) out.push(t);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
117
169
|
}
|
|
118
170
|
|
|
119
171
|
/** The first value of trailer `key`, compared without case as git does. */
|
|
120
172
|
export function trailerValue(trailers: Record<string, string[]>, key: string): string | undefined {
|
|
121
|
-
|
|
122
|
-
for (const [k, values] of Object.entries(trailers)) if (k.toLowerCase() === want && values.length > 0) return values[0].trim() || undefined;
|
|
123
|
-
return undefined;
|
|
173
|
+
return trailerValues(trailers, key)[0];
|
|
124
174
|
}
|
|
125
175
|
|
|
126
176
|
/** Whether the commit carries trailer `key`. */
|
|
@@ -147,14 +197,15 @@ function recordFields(template: string | undefined, id: string, context: CommitJ
|
|
|
147
197
|
/** Interpret the data form for one commit. Throws when a named record can't be read as JSON. */
|
|
148
198
|
export function joinByData(data: CommitJoinsData, commit: IntentCommit, context: CommitJoinContext): CommitJoin {
|
|
149
199
|
const out: CommitJoin = {};
|
|
150
|
-
|
|
200
|
+
const entity = (part: "unit" | "contract" | "evidence", id: string): JoinedEntity => ({ ...recordFields(data.records?.[part], id, context), id });
|
|
201
|
+
for (const part of ["unit", "contract"] as const) {
|
|
151
202
|
const key = data.trailers[part];
|
|
152
203
|
const id = key ? trailerValue(commit.trailers, key) : undefined;
|
|
153
|
-
if (
|
|
154
|
-
const entity: JoinedEntity = { ...recordFields(data.records?.[part], id, context), id };
|
|
155
|
-
if (part === "evidence") out.evidence = entity;
|
|
156
|
-
else out[part] = entity;
|
|
204
|
+
if (id) out[part] = entity(part, id);
|
|
157
205
|
}
|
|
206
|
+
// Each value of the evidence trailer is one piece of evidence (#2663).
|
|
207
|
+
const evidence = data.trailers.evidence ? trailerValues(commit.trailers, data.trailers.evidence) : [];
|
|
208
|
+
if (evidence.length > 0) out.evidence = evidence.map((id) => entity("evidence", id));
|
|
158
209
|
const claimed = (data.authorship ?? []).filter((k) => hasTrailer(commit.trailers, k));
|
|
159
210
|
if (claimed.length > 0) out.authorship = claimed;
|
|
160
211
|
return out;
|
|
@@ -168,7 +219,8 @@ export function entityDecisions(entity: JoinedEntity | undefined): string[] {
|
|
|
168
219
|
|
|
169
220
|
/**
|
|
170
221
|
* Run a kind's joins for one commit, checking what a function returns.
|
|
171
|
-
* `name` is the
|
|
222
|
+
* `name` is the namespace its findings' codes must use: the kind file's
|
|
223
|
+
* `commitJoinsName`, or else the kind's name.
|
|
172
224
|
*/
|
|
173
225
|
export async function runCommitJoins(joins: CommitJoins, commit: IntentCommit, context: CommitJoinContext, name: string): Promise<CommitJoin> {
|
|
174
226
|
if (joins.form === "data") return joinByData(joins.data, commit, context);
|