@intentius/chant 0.93.0 → 0.95.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/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-plugins.d.ts +6 -6
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/op/builders.d.ts +14 -3
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/index.d.ts +6 -3
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/operator.d.ts +90 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/steward-beside.d.ts +84 -0
- package/dist/op/steward-beside.d.ts.map +1 -0
- package/dist/op/steward.d.ts +87 -2
- package/dist/op/steward.d.ts.map +1 -1
- package/dist/workspace/box-intent.d.ts +85 -0
- package/dist/workspace/box-intent.d.ts.map +1 -0
- package/dist/workspace/box-services.d.ts +31 -0
- package/dist/workspace/box-services.d.ts.map +1 -0
- package/dist/workspace/chant-migrations.d.ts +5 -0
- package/dist/workspace/chant-migrations.d.ts.map +1 -1
- package/dist/workspace/checks/boxes.d.ts +14 -1
- package/dist/workspace/checks/boxes.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/compose-graph.d.ts +11 -0
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +5 -1
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/decision-points.schema.json +3 -3
- package/dist/workspace/declaration.d.ts +43 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +62 -1
- package/dist/workspace/graph-cache.d.ts +168 -0
- package/dist/workspace/graph-cache.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +11 -5
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/intent-joins.d.ts +5 -5
- package/dist/workspace/kind-readers.d.ts +39 -0
- package/dist/workspace/kind-readers.d.ts.map +1 -0
- package/dist/workspace/kinds.d.ts +29 -0
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/member-commands.d.ts +15 -1
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/member-run.d.ts +2 -0
- package/dist/workspace/member-run.d.ts.map +1 -1
- package/dist/workspace/points-cli.d.ts +2 -0
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/points.d.ts +3 -5
- package/dist/workspace/points.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +5 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +10 -1
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +4 -2
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +8 -3
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/status-stewards.d.ts +31 -9
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/dist/workspace/status.d.ts +20 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/work-evidence.d.ts +1 -1
- package/dist/workspace/work-evidence.d.ts.map +1 -1
- package/dist/workspace/workspace-kinds.schema.json +26 -0
- package/package.json +1 -1
- package/src/cli/commands/carve-bridge.test.ts +7 -3
- package/src/cli/handlers/operator-steward-signal.e2e.test.ts +97 -0
- package/src/cli/handlers/operator.ts +53 -11
- package/src/cli/handlers/run.test.ts +71 -0
- package/src/cli/handlers/run.ts +65 -7
- package/src/cli/main.ts +21 -9
- package/src/cli/mcp/workspace-plugins.ts +6 -6
- package/src/cli/mcp/workspace-tools.ts +1 -1
- package/src/cli/registry.ts +2 -0
- package/src/cli/serve-mcp-workspace.test.ts +6 -6
- package/src/cli/static-config-read.test.ts +8 -2
- package/src/meta/source-is-text.test.ts +21 -3
- package/src/okf.test.ts +6 -1
- package/src/op/activities/decide.test.ts +8 -0
- package/src/op/builders.ts +14 -3
- package/src/op/index.ts +8 -2
- package/src/op/operator.ts +264 -16
- package/src/op/steward-beside.test.ts +267 -0
- package/src/op/steward-beside.ts +219 -0
- package/src/op/steward-points.test.ts +112 -1
- package/src/op/steward.ts +135 -3
- package/src/workspace/box-intent.test.ts +205 -0
- package/src/workspace/box-intent.ts +159 -0
- package/src/workspace/box-services.test.ts +129 -0
- package/src/workspace/box-services.ts +51 -0
- package/src/workspace/chant-migrations.ts +5 -0
- package/src/workspace/check.schema.json +7 -3
- package/src/workspace/checks/boxes.test.ts +3 -1
- package/src/workspace/checks/boxes.ts +66 -0
- package/src/workspace/checks.ts +12 -1
- package/src/workspace/compose-graph.test.ts +1 -0
- package/src/workspace/compose-graph.ts +11 -0
- package/src/workspace/composites.test.ts +1 -1
- package/src/workspace/composites.ts +12 -5
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -2
- package/src/workspace/decision-points.schema.json +3 -3
- package/src/workspace/declaration.schema.json +62 -1
- package/src/workspace/declaration.ts +112 -0
- package/src/workspace/declared-kinds.test.ts +26 -0
- package/src/workspace/graph-cache.test.ts +343 -0
- package/src/workspace/graph-cache.ts +409 -0
- package/src/workspace/graph-cli.ts +93 -26
- package/src/workspace/graph-contract.test.ts +129 -6
- package/src/workspace/graph.schema.json +21 -1
- package/src/workspace/intent-joins.test.ts +5 -5
- package/src/workspace/intent-joins.ts +5 -5
- package/src/workspace/kind-readers.e2e.test.ts +68 -0
- package/src/workspace/kind-readers.test.ts +134 -0
- package/src/workspace/kind-readers.ts +111 -0
- package/src/workspace/kinds.test.ts +49 -0
- package/src/workspace/kinds.ts +59 -2
- package/src/workspace/member-commands.test.ts +15 -0
- package/src/workspace/member-commands.ts +47 -7
- package/src/workspace/member-run.ts +10 -2
- package/src/workspace/points-cli.ts +3 -0
- package/src/workspace/points.schema.json +18 -0
- package/src/workspace/points.test.ts +15 -14
- package/src/workspace/points.ts +4 -16
- package/src/workspace/read-contract.test.ts +8 -0
- package/src/workspace/reason-codes.test.ts +7 -7
- package/src/workspace/reason-codes.ts +6 -1
- package/src/workspace/records-amend.schema.json +1 -0
- package/src/workspace/records-cli.ts +34 -13
- package/src/workspace/records-contract.test.ts +2 -1
- package/src/workspace/records-formats.test.ts +15 -15
- package/src/workspace/records-new.schema.json +1 -0
- package/src/workspace/records-quorum.test.ts +10 -3
- package/src/workspace/records-sessions-write.test.ts +2 -1
- package/src/workspace/records-since.test.ts +8 -7
- package/src/workspace/records-write.test.ts +101 -1
- package/src/workspace/records-write.ts +50 -6
- package/src/workspace/records.test.ts +1 -1
- package/src/workspace/records.ts +22 -5
- package/src/workspace/status-contract.test.ts +32 -1
- package/src/workspace/status-stewards.ts +88 -9
- package/src/workspace/status.schema.json +66 -4
- package/src/workspace/status.ts +32 -0
- package/src/workspace/trust/record-seal.test.ts +26 -2
- package/src/workspace/work-evidence.schema.json +1 -0
- package/src/workspace/{work-readiness-chud.test.ts → work-readiness.test.ts} +30 -32
- package/src/workspace/work.test.ts +17 -0
- package/src/workspace/workspace-kinds.schema.json +26 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A box's intent (#2850): the decision record the box block names, as
|
|
3
|
+
* `status --json` reports it, as `check` finds it (WSP126, WSP127) and as
|
|
4
|
+
* `graph --intent` shows it for the box's files.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
10
|
+
import { cleanScratch, commitAll, contract, REPO, repo } from "./__fixtures__/contract-repo";
|
|
11
|
+
import { constrainsBox, constrainsWorkspace } from "./box-intent";
|
|
12
|
+
import { runDeclarationChecks } from "./checks";
|
|
13
|
+
import { parseDeclaration } from "./declaration";
|
|
14
|
+
import { intentGraph } from "./intent";
|
|
15
|
+
import { parseFrontMatter } from "./records";
|
|
16
|
+
import { workspaceStatus, type StatusDocument } from "./status";
|
|
17
|
+
import statusSchema from "./status.schema.json";
|
|
18
|
+
|
|
19
|
+
afterAll(cleanScratch);
|
|
20
|
+
|
|
21
|
+
const REF = join(REPO, "reference-workspace", "decisions");
|
|
22
|
+
const BASE = (() => {
|
|
23
|
+
const fm = parseFrontMatter(readFileSync(join(REF, "ref-001-how-the-app-is-deployed.md"), "utf-8"));
|
|
24
|
+
if (!fm.ok) throw new Error(fm.message);
|
|
25
|
+
return fm.value as Record<string, unknown>;
|
|
26
|
+
})();
|
|
27
|
+
|
|
28
|
+
const QUESTION = "What is this box for?";
|
|
29
|
+
const CHOICE = { option: "a", reason: "The person who planted the box answered it." };
|
|
30
|
+
const ANSWER = "a chant project with the docker lexicon";
|
|
31
|
+
|
|
32
|
+
/** A decision record from ref-001: proposed with no choice, or decided by alex. JSON is YAML, so the front matter is JSON. */
|
|
33
|
+
function decision(id: string, state: "proposed" | "decided", constrains: string[]): string {
|
|
34
|
+
const data = {
|
|
35
|
+
...BASE,
|
|
36
|
+
id,
|
|
37
|
+
title: `Intent ${id}`,
|
|
38
|
+
state,
|
|
39
|
+
question: QUESTION,
|
|
40
|
+
choice: state === "decided" ? CHOICE : null,
|
|
41
|
+
rejected: [],
|
|
42
|
+
evidence: [],
|
|
43
|
+
decided_by: state === "decided" ? "alex" : null,
|
|
44
|
+
decided_on: state === "decided" ? "2026-09-26" : null,
|
|
45
|
+
constrains,
|
|
46
|
+
};
|
|
47
|
+
return `---\n${JSON.stringify(data, null, 2)}\n---\n\n# ${id}\n`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function workspace(box: Record<string, unknown>, records: Record<string, string>, declareKind = true): string {
|
|
51
|
+
return repo({
|
|
52
|
+
"chant.workspace.json": JSON.stringify(
|
|
53
|
+
{
|
|
54
|
+
name: "acme",
|
|
55
|
+
schema: 1,
|
|
56
|
+
members: [{ name: "app", dir: "app", kind: "other", because: "the box", box }],
|
|
57
|
+
...(declareKind ? { records: [{ kind: "decisions/decision.kind.mjs" }] } : {}),
|
|
58
|
+
},
|
|
59
|
+
null,
|
|
60
|
+
2,
|
|
61
|
+
),
|
|
62
|
+
"app/server.mjs": "export const port = 8080;\n",
|
|
63
|
+
"decisions/decision.kind.mjs": readFileSync(join(REF, "decision.kind.mjs"), "utf-8"),
|
|
64
|
+
"decisions/decision.schema.json": readFileSync(join(REF, "decision.schema.json"), "utf-8"),
|
|
65
|
+
...records,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A workspace shaped like the studio template (studio#112, #2857): the box
|
|
71
|
+
* block sits on member `box`, the steward and its Ops, and a separate
|
|
72
|
+
* member `app` is the app the box runs. `records` overlays the decision
|
|
73
|
+
* files, keyed the same way `repo()` takes every other file.
|
|
74
|
+
*/
|
|
75
|
+
function studioShapedWorkspace(records: Record<string, string>): string {
|
|
76
|
+
return repo({
|
|
77
|
+
"chant.workspace.json": JSON.stringify(
|
|
78
|
+
{
|
|
79
|
+
name: "acme",
|
|
80
|
+
schema: 1,
|
|
81
|
+
members: [
|
|
82
|
+
{ name: "box", dir: "box", kind: "other", because: "the box's steward and its Ops", box: { intent: "box-001" } },
|
|
83
|
+
{ name: "app", dir: "app", kind: "other", because: "the app the box runs" },
|
|
84
|
+
],
|
|
85
|
+
records: [{ kind: "decisions/decision.kind.mjs" }],
|
|
86
|
+
},
|
|
87
|
+
null,
|
|
88
|
+
2,
|
|
89
|
+
),
|
|
90
|
+
"box/ops.mjs": "export const ops = true;\n",
|
|
91
|
+
"app/server.mjs": "export const port = 8080;\n",
|
|
92
|
+
"decisions/decision.kind.mjs": readFileSync(join(REF, "decision.kind.mjs"), "utf-8"),
|
|
93
|
+
"decisions/decision.schema.json": readFileSync(join(REF, "decision.schema.json"), "utf-8"),
|
|
94
|
+
...records,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const intentFindings = async (root: string) =>
|
|
99
|
+
(await runDeclarationChecks(root)).diagnostics.filter((d) => d.ruleId === "WSP126" || d.ruleId === "WSP127").map((d) => [d.ruleId, d.severity, d.code, d.entity]);
|
|
100
|
+
|
|
101
|
+
const status = contract(statusSchema);
|
|
102
|
+
async function boxOf(root: string) {
|
|
103
|
+
const doc: StatusDocument = await workspaceStatus({ cwd: root, env: "prod" });
|
|
104
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
105
|
+
status.expectValid(doc);
|
|
106
|
+
return doc.members[0].box;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("the box block's intent", () => {
|
|
110
|
+
test("is parsed into the box declaration, and is null when the block names none", () => {
|
|
111
|
+
const parse = (box: unknown) =>
|
|
112
|
+
parseDeclaration(JSON.stringify({ name: "acme", schema: 1, members: [{ name: "app", dir: "app", kind: "other", because: "x", box }] }), "chant.workspace.json").members[0].box;
|
|
113
|
+
expect(parse({ intent: "box-001" })?.intent).toBe("box-001");
|
|
114
|
+
expect(parse({})?.intent).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("covers the box through member:, or a path: at, above or inside its directory", () => {
|
|
118
|
+
const m = { name: "app", dir: "apps/app" };
|
|
119
|
+
expect(["member:app", "path:apps/app", "path:apps", "path:apps/app/server.mjs"].map((c) => constrainsBox(c, m))).toEqual([true, true, true, true]);
|
|
120
|
+
expect(["member:web", "path:apps/application", "path:web", "issue:1"].map((c) => constrainsBox(c, m))).toEqual([false, false, false, false]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("constrainsWorkspace: an entry names a member or path of the workspace whether or not it's the box's own (#2857)", () => {
|
|
124
|
+
const members = [
|
|
125
|
+
{ name: "box", dir: "box" },
|
|
126
|
+
{ name: "app", dir: "app" },
|
|
127
|
+
];
|
|
128
|
+
expect(["member:box", "member:app", "path:app", "path:box/ops.mjs"].map((c) => constrainsWorkspace(c, members))).toEqual([true, true, true, true]);
|
|
129
|
+
expect(["member:web", "path:docs", "issue:1"].map((c) => constrainsWorkspace(c, members))).toEqual([false, false, false]);
|
|
130
|
+
expect(constrainsWorkspace("member:app", [])).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("a box planted as a question", () => {
|
|
135
|
+
test("a proposed intent reports its question and no answer, and passes the checks", async () => {
|
|
136
|
+
const root = workspace({ intent: "box-001" }, { "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["member:app"]) });
|
|
137
|
+
expect((await boxOf(root))?.intent).toEqual({ id: "box-001", state: "proposed", question: QUESTION, choice: null, answer: null, decided_by: null, decided_on: null });
|
|
138
|
+
expect(await intentFindings(root)).toEqual([]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a decided intent reports the answer and who gave it, and graph --intent shows it for the box's files", async () => {
|
|
142
|
+
const root = workspace({ intent: "box-001" }, { "decisions/box-001-what-app-is-for.md": decision("box-001", "decided", ["member:app"]) });
|
|
143
|
+
expect((await boxOf(root))?.intent).toEqual({ id: "box-001", state: "decided", question: QUESTION, choice: CHOICE, answer: ANSWER, decided_by: "alex", decided_on: "2026-09-26" });
|
|
144
|
+
expect(await intentFindings(root)).toEqual([]);
|
|
145
|
+
commitAll(root, "plant the box");
|
|
146
|
+
const { doc } = await intentGraph({ cwd: root, region: "app" });
|
|
147
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
148
|
+
expect(doc.nodes.filter((n) => n.kind === "decision").map((n) => (n.kind === "decision" ? [n.record, n.state, n.decided_by] : []))).toEqual([["box-001", "decided", "alex"]]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a decided intent whose choice names an option not in options[] reports a null answer", async () => {
|
|
152
|
+
const data = { ...BASE, id: "box-001", title: "Intent box-001", state: "decided", question: QUESTION, choice: { option: "z", reason: "no such option" }, rejected: [], evidence: [], decided_by: "alex", decided_on: "2026-09-26", constrains: ["member:app"] };
|
|
153
|
+
const record = `---\n${JSON.stringify(data, null, 2)}\n---\n\n# box-001\n`;
|
|
154
|
+
const root = workspace({ intent: "box-001" }, { "decisions/box-001-what-app-is-for.md": record });
|
|
155
|
+
expect((await boxOf(root))?.intent?.answer).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("an intent no decision record has fails WSP126, and status reports only its id", async () => {
|
|
159
|
+
const root = workspace({ intent: "box-009" }, { "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["member:app"]) });
|
|
160
|
+
expect((await boxOf(root))?.intent).toEqual({ id: "box-009", state: null, question: null, choice: null, answer: null, decided_by: null, decided_on: null });
|
|
161
|
+
expect(await intentFindings(root)).toEqual([["WSP126", "error", "box-intent-unknown", "app"]]);
|
|
162
|
+
const [d] = (await runDeclarationChecks(root)).diagnostics.filter((x) => x.ruleId === "WSP126");
|
|
163
|
+
expect(d.message).toContain("no record of decisions/decision.kind.mjs has that id");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("an intent in a workspace that declares no decision kind fails WSP126", async () => {
|
|
167
|
+
const root = workspace({ intent: "box-001" }, {}, false);
|
|
168
|
+
const [d] = (await runDeclarationChecks(root)).diagnostics.filter((x) => x.ruleId === "WSP126");
|
|
169
|
+
expect(d.message).toContain("the declaration names no record kind called decision");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("an intent whose constrains names no member or path of the workspace warns WSP127", async () => {
|
|
173
|
+
const root = workspace({ intent: "box-001" }, { "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["path:docs"]) });
|
|
174
|
+
expect(await intentFindings(root)).toEqual([["WSP127", "warning", "box-intent-unconstrained", "app"]]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("an intent with empty constrains warns WSP127", async () => {
|
|
178
|
+
const root = studioShapedWorkspace({ "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", []) });
|
|
179
|
+
expect(await intentFindings(root)).toEqual([["WSP127", "warning", "box-intent-unconstrained", "box"]]);
|
|
180
|
+
const d = (await runDeclarationChecks(root)).diagnostics.find((x) => x.ruleId === "WSP127")!;
|
|
181
|
+
expect(d.message).toContain("whose constrains is empty");
|
|
182
|
+
expect(d.message).toContain("add member:<name> for the member the intent is about");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("an intent naming an unknown member warns WSP127", async () => {
|
|
186
|
+
const root = studioShapedWorkspace({ "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["member:web"]) });
|
|
187
|
+
expect(await intentFindings(root)).toEqual([["WSP127", "warning", "box-intent-unconstrained", "box"]]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("an intent that constrains the app a box runs passes WSP127, on a workspace shaped like the studio template (#2857)", async () => {
|
|
191
|
+
const root = studioShapedWorkspace({ "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["member:app"]) });
|
|
192
|
+
expect(await intentFindings(root)).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("an intent with a path: entry inside another member's directory passes WSP127 (#2857)", async () => {
|
|
196
|
+
const root = studioShapedWorkspace({ "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["path:app/server.mjs"]) });
|
|
197
|
+
expect(await intentFindings(root)).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("a box with no intent reports null", async () => {
|
|
201
|
+
const root = workspace({ capabilities: [] }, {});
|
|
202
|
+
expect((await boxOf(root))?.intent).toBeNull();
|
|
203
|
+
expect(await intentFindings(root)).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A box's intent (#2850): the decision record its box block names.
|
|
3
|
+
*
|
|
4
|
+
* A new box starts as a question, and the first answer to it is what the box
|
|
5
|
+
* is for. That answer is a decision record, so the intent graph, hud and a
|
|
6
|
+
* box's runtime all read the same thing. The box block names the record's
|
|
7
|
+
* id in `intent`; the record is looked for among the records of every
|
|
8
|
+
* declared kind named `decision` (the entry's `name`, or the kind file's
|
|
9
|
+
* `recordKind.name`), read from the working tree through the same reader as
|
|
10
|
+
* `chant workspace records`.
|
|
11
|
+
*
|
|
12
|
+
* `chant workspace status --json` reports the record's state and answer
|
|
13
|
+
* under the member's `box.intent`, and `chant workspace check` fails when no
|
|
14
|
+
* decision record has the id (WSP126) and warns when the record constrains
|
|
15
|
+
* no member or path of this workspace at all (WSP127, #2857): the member it
|
|
16
|
+
* names need not be the box's own, since a decision naming another member
|
|
17
|
+
* can be what the box is for, such as the app it runs.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { loadDeclaredKinds, type DeclaredKind } from "./declared-kinds";
|
|
21
|
+
import type { Declaration, Member } from "./declaration";
|
|
22
|
+
import { constraintCovers, isWorkspacePath } from "./record-assets";
|
|
23
|
+
import { workingTree } from "./tree";
|
|
24
|
+
|
|
25
|
+
/** The declared kind name a box's intent is looked for in. */
|
|
26
|
+
export const INTENT_KIND_NAME = "decision";
|
|
27
|
+
|
|
28
|
+
/** A box's intent as `status --json` reports it: the record's id, state and answer. */
|
|
29
|
+
export interface BoxIntent {
|
|
30
|
+
id: string;
|
|
31
|
+
/** The record's state, such as proposed or decided, or null when no decision record has the id. */
|
|
32
|
+
state: string | null;
|
|
33
|
+
question: string | null;
|
|
34
|
+
/** The record's choice as written, such as `{ option, reason }`, or null while it is proposed. */
|
|
35
|
+
choice: unknown;
|
|
36
|
+
/** The chosen option's label, from `options[]`, or null while no option is chosen. */
|
|
37
|
+
answer: string | null;
|
|
38
|
+
decided_by: string | null;
|
|
39
|
+
decided_on: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The decision record a box's intent names, as the checks read it. */
|
|
43
|
+
export interface ResolvedBoxIntent {
|
|
44
|
+
member: string;
|
|
45
|
+
id: string;
|
|
46
|
+
/** The `intent` field's JSON Pointer in the declaration. */
|
|
47
|
+
pointer: string;
|
|
48
|
+
/** The record, or null when no decision record has the id. */
|
|
49
|
+
record: {
|
|
50
|
+
/** The kind file that holds it, from the workspace root. */
|
|
51
|
+
kind: string;
|
|
52
|
+
/** The record file, from the repository root. */
|
|
53
|
+
path: string;
|
|
54
|
+
/** The record's constrains entries, as written. */
|
|
55
|
+
constrains: string[];
|
|
56
|
+
intent: BoxIntent;
|
|
57
|
+
} | null;
|
|
58
|
+
/** Why no record was found when none was, such as no declared decision kind. Empty when one was found. */
|
|
59
|
+
why: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
|
|
63
|
+
|
|
64
|
+
/** The label of `options[]`'s entry whose id is `choice.option`, or null when no option is chosen. */
|
|
65
|
+
function answerOf(choice: unknown, options: unknown): string | null {
|
|
66
|
+
const chosen = choice && typeof choice === "object" ? (choice as { option?: unknown }).option : null;
|
|
67
|
+
if (typeof chosen !== "string" || !Array.isArray(options)) return null;
|
|
68
|
+
const found = options.find((o): o is { id: unknown; label: unknown } => !!o && typeof o === "object" && (o as { id?: unknown }).id === chosen);
|
|
69
|
+
return found && typeof found.label === "string" ? found.label : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The intent of `id` with no record: every field but the id null. */
|
|
73
|
+
export function unresolvedIntent(id: string): BoxIntent {
|
|
74
|
+
return { id, state: null, question: null, choice: null, answer: null, decided_by: null, decided_on: null };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the intent of every box that names one, reading the records of each
|
|
79
|
+
* declared decision kind once. `kinds` are the declared kinds already loaded,
|
|
80
|
+
* when the caller has them. Only the working tree is read.
|
|
81
|
+
*/
|
|
82
|
+
export async function resolveBoxIntents(declaration: Declaration, root: string, kinds?: readonly DeclaredKind[]): Promise<ResolvedBoxIntent[]> {
|
|
83
|
+
const boxes = declaration.members.filter((m): m is Member & { box: NonNullable<Member["box"]> & { intent: string } } => m.box?.intent != null);
|
|
84
|
+
if (boxes.length === 0) return [];
|
|
85
|
+
const loaded = kinds ?? (await loadDeclaredKinds(declaration, workingTree(root), root));
|
|
86
|
+
const decisionKinds = loaded.filter((k) => (k.declared.name ?? k.kind) === INTENT_KIND_NAME);
|
|
87
|
+
const found = new Map<string, NonNullable<ResolvedBoxIntent["record"]>>();
|
|
88
|
+
const problems: string[] = [];
|
|
89
|
+
const { readRecordsFor } = await import("./records-cli");
|
|
90
|
+
const { RecordReadError } = await import("./records");
|
|
91
|
+
for (const k of decisionKinds) {
|
|
92
|
+
if (k.reason) {
|
|
93
|
+
problems.push(`${k.declared.path} can't be loaded: ${k.reason.message}`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const read = await readRecordsFor({ kind: k.file, cwd: root, workGaps: false });
|
|
98
|
+
const field = read.loaded.kind.constrains?.field ?? "constrains";
|
|
99
|
+
for (const r of read.result.records) {
|
|
100
|
+
if (r.id === null || found.has(r.id)) continue;
|
|
101
|
+
const data = r.data ?? {};
|
|
102
|
+
const constrains = Array.isArray(data[field]) ? (data[field] as unknown[]).filter((c): c is string => typeof c === "string") : [];
|
|
103
|
+
found.set(r.id, {
|
|
104
|
+
kind: k.declared.path,
|
|
105
|
+
path: r.path,
|
|
106
|
+
constrains,
|
|
107
|
+
intent: {
|
|
108
|
+
id: r.id,
|
|
109
|
+
state: r.state,
|
|
110
|
+
question: str(data.question),
|
|
111
|
+
choice: data.choice ?? null,
|
|
112
|
+
answer: answerOf(data.choice, data.options),
|
|
113
|
+
decided_by: str(data.decided_by),
|
|
114
|
+
decided_on: str(data.decided_on),
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (!(err instanceof RecordReadError)) throw err;
|
|
120
|
+
problems.push(`${k.declared.path} can't be read: ${err.message}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const why =
|
|
124
|
+
decisionKinds.length === 0
|
|
125
|
+
? `the declaration names no record kind called ${INTENT_KIND_NAME}`
|
|
126
|
+
: `no record of ${decisionKinds.map((k) => k.declared.path).join(" or ")} has that id` + (problems.length > 0 ? ` (${problems.join("; ")})` : "");
|
|
127
|
+
return boxes.map((m) => {
|
|
128
|
+
const record = found.get(m.box.intent) ?? null;
|
|
129
|
+
return { member: m.name, id: m.box.intent, pointer: `${m.box.pointer}/intent`, record, why: record ? "" : why };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Whether a constrains entry covers something of the box: `member:<name>`, or
|
|
135
|
+
* a `path:` entry that is the member's directory, a directory above it or a
|
|
136
|
+
* path inside it.
|
|
137
|
+
*/
|
|
138
|
+
export function constrainsBox(entry: string, member: { name: string; dir: string }): boolean {
|
|
139
|
+
if (entry === `member:${member.name}`) return true;
|
|
140
|
+
if (!entry.startsWith("path:")) return false;
|
|
141
|
+
const path = entry.slice("path:".length);
|
|
142
|
+
if (!isWorkspacePath(path)) return false;
|
|
143
|
+
// The root member holds every path.
|
|
144
|
+
if (member.dir === ".") return true;
|
|
145
|
+
return constraintCovers(path, member.dir) || constraintCovers(member.dir, path);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Whether a constrains entry names a member or path of this workspace at
|
|
150
|
+
* all: `member:<name>` for any declared member, or a `path:` entry that is
|
|
151
|
+
* some member's directory, a directory above it or a path inside it. An
|
|
152
|
+
* intent's constrains can govern a member other than the one whose box
|
|
153
|
+
* names it (a box run by one member can be what a decision about another
|
|
154
|
+
* member's app is for), so this asks only whether the entry is real, not
|
|
155
|
+
* whether it reaches a particular member.
|
|
156
|
+
*/
|
|
157
|
+
export function constrainsWorkspace(entry: string, members: readonly { name: string; dir: string }[]): boolean {
|
|
158
|
+
return members.some((m) => constrainsBox(entry, m));
|
|
159
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2880: a box block declares its services. The declaration reads them, a
|
|
3
|
+
* `needs` naming no service of the block or forming a cycle, a name given
|
|
4
|
+
* twice and a second httpPort each fail the read (declaration-invalid), so
|
|
5
|
+
* `chant workspace check` fails with WSP001, and `readBoxServices` gives the
|
|
6
|
+
* list of the member a process runs in.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
14
|
+
import { runDeclarationChecks } from "./checks";
|
|
15
|
+
import { parseDeclaration, WorkspaceReadError } from "./declaration";
|
|
16
|
+
import { readBoxServices } from "./box-services";
|
|
17
|
+
|
|
18
|
+
const scratch: string[] = [];
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
for (const d of scratch) rmSync(d, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function repo(files: Record<string, string>): string {
|
|
24
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-box-services-")));
|
|
25
|
+
scratch.push(root);
|
|
26
|
+
execFileSync("git", ["init", "-q"], { cwd: root });
|
|
27
|
+
for (const [path, text] of Object.entries(files)) {
|
|
28
|
+
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
29
|
+
writeFileSync(join(root, path), text);
|
|
30
|
+
}
|
|
31
|
+
return root;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const declaration = (services: unknown[]) =>
|
|
35
|
+
JSON.stringify(
|
|
36
|
+
{
|
|
37
|
+
name: "acme",
|
|
38
|
+
schema: 1,
|
|
39
|
+
members: [
|
|
40
|
+
{ name: "app", dir: "app", kind: "other", because: "the app" },
|
|
41
|
+
{ name: "box", dir: "box", kind: "other", because: "the box's steward and its Ops", box: { services } },
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
null,
|
|
45
|
+
2,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const parse = (services: unknown[]) => parseDeclaration(declaration(services), "chant.workspace.json");
|
|
49
|
+
|
|
50
|
+
/** The error a declaration's read fails with, as its code and message. */
|
|
51
|
+
function readFailure(services: unknown[]): { code: string; message: string; line: number | undefined } {
|
|
52
|
+
try {
|
|
53
|
+
parse(services);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err instanceof WorkspaceReadError) return { code: err.code, message: err.message, line: err.location?.line };
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
throw new Error("the declaration read");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const TWO = [
|
|
62
|
+
{ name: "app", cmd: "${HOME}/box/run-app.sh", duration: "3s", health: "http://127.0.0.1:5173/health" },
|
|
63
|
+
{ name: "hud", cmd: "${HOME}/box/run-daemon.sh", needs: ["app"], httpPort: 8080 },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
describe("the services of a box block (#2880)", () => {
|
|
67
|
+
test("two services, one needing the other, read with every field", () => {
|
|
68
|
+
expect(parse(TWO).members[1].box?.services).toEqual([
|
|
69
|
+
{ name: "app", cmd: "${HOME}/box/run-app.sh", needs: [], httpPort: null, duration: "3s", health: "http://127.0.0.1:5173/health", optional: false, pointer: "/members/1/box/services/0" },
|
|
70
|
+
{ name: "hud", cmd: "${HOME}/box/run-daemon.sh", needs: ["app"], httpPort: 8080, duration: null, health: null, optional: false, pointer: "/members/1/box/services/1" },
|
|
71
|
+
]);
|
|
72
|
+
expect(parseDeclaration(JSON.stringify({ name: "a", schema: 1, members: [{ name: "b", dir: "b", kind: "other", because: "x", box: {} }] }), "chant.workspace.json").members[0].box?.services).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a needs naming no service of the block is declaration-invalid, at the entry", () => {
|
|
76
|
+
const f = readFailure([TWO[0], { ...TWO[1], needs: ["api"] }]);
|
|
77
|
+
expect(f.code).toBe("declaration-invalid");
|
|
78
|
+
expect(f.message).toBe(`member box's box service hud needs "api", which the block does not declare; declared services: app, hud`);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("needs that form a cycle are declaration-invalid, and so is a service that needs itself", () => {
|
|
82
|
+
const cycle = readFailure([
|
|
83
|
+
{ name: "a", cmd: "a", needs: ["c"] },
|
|
84
|
+
{ name: "b", cmd: "b", needs: ["a"] },
|
|
85
|
+
{ name: "c", cmd: "c", needs: ["b"] },
|
|
86
|
+
]);
|
|
87
|
+
expect(cycle).toMatchObject({ code: "declaration-invalid", message: "member box's box services need each other in a cycle: a -> c -> b -> a" });
|
|
88
|
+
expect(readFailure([{ name: "a", cmd: "a", needs: ["a"] }]).message).toMatch(/cycle: a -> a$/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a name given twice, and a second httpPort, are declaration-invalid", () => {
|
|
92
|
+
expect(readFailure([TWO[0], { ...TWO[0], cmd: "other" }])).toMatchObject({ code: "declaration-invalid", message: expect.stringMatching(/declares the service app twice; the first is at \/members\/1\/box\/services\/0/) });
|
|
93
|
+
expect(readFailure([{ ...TWO[0], httpPort: 5173 }, TWO[1]]).message).toMatch(/gives both app \(port 5173\) and hud \(port 8080\) an httpPort/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("the schema refuses a service with no cmd, an unknown field, a malformed duration or a health that is no URL", () => {
|
|
97
|
+
expect(readFailure([{ name: "app" }]).code).toBe("declaration-invalid");
|
|
98
|
+
expect(readFailure([{ ...TWO[0], restart: "always" }]).message).toMatch(/unknown field "restart"/);
|
|
99
|
+
expect(readFailure([{ ...TWO[0], duration: "3 seconds" }]).code).toBe("declaration-invalid");
|
|
100
|
+
expect(readFailure([{ ...TWO[0], health: "/health" }]).code).toBe("declaration-invalid");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("chant workspace check passes the valid block and fails the invalid one with WSP001 declaration-invalid", async () => {
|
|
104
|
+
const good = repo({ "chant.workspace.json": declaration(TWO), "app/.keep": "", "box/.keep": "" });
|
|
105
|
+
expect((await runDeclarationChecks(good)).diagnostics.filter((d) => d.ruleId === "WSP001")).toEqual([]);
|
|
106
|
+
const bad = repo({ "chant.workspace.json": declaration([TWO[0], { ...TWO[1], needs: ["api"] }]), "app/.keep": "", "box/.keep": "" });
|
|
107
|
+
const found = (await runDeclarationChecks(bad)).diagnostics;
|
|
108
|
+
expect(found.map((d) => [d.ruleId, d.severity, d.code])).toEqual([["WSP001", "error", "declaration-invalid"]]);
|
|
109
|
+
expect(found[0].message).toMatch(/needs "api", which the block does not declare/);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("readBoxServices (#2880)", () => {
|
|
114
|
+
test("reads the services of the member whose directory holds the working directory", () => {
|
|
115
|
+
const root = repo({ "chant.workspace.json": declaration(TWO), "app/.keep": "", "box/ops/.keep": "" });
|
|
116
|
+
const read = readBoxServices(join(root, "box", "ops"));
|
|
117
|
+
expect(read.member).toBe("box");
|
|
118
|
+
expect(read.root).toBe(root);
|
|
119
|
+
expect(read.services.map((s) => [s.name, s.needs])).toEqual([["app", []], ["hud", ["app"]]]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a member with no box block, a directory no member holds, and no workspace are errors", () => {
|
|
123
|
+
const root = repo({ "chant.workspace.json": declaration(TWO), "app/.keep": "", "box/.keep": "", "elsewhere/.keep": "" });
|
|
124
|
+
expect(() => readBoxServices(join(root, "app"))).toThrow(/member app \(app\) has no box block/);
|
|
125
|
+
expect(() => readBoxServices(join(root, "elsewhere"))).toThrow(/no member of the workspace/);
|
|
126
|
+
const none = repo({ "x/.keep": "" });
|
|
127
|
+
expect(() => readBoxServices(join(none, "x"))).toThrow(/no chant.workspace.json/);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The services a box declares, read for the process that runs in the box
|
|
3
|
+
* (#2880).
|
|
4
|
+
*
|
|
5
|
+
* A box member's block in the declaration lists the services the box runs
|
|
6
|
+
* under its supervisor (`BoxService` in `declaration.ts`). The fly lexicon's
|
|
7
|
+
* `spriteServicesObserve`, `spriteServiceRestart` and `spriteApplyServices`
|
|
8
|
+
* take `box: true` to read that list instead of one passed to them: the list
|
|
9
|
+
* of the member whose directory holds the working directory. They import
|
|
10
|
+
* this module when they run, so it stays small: it finds the workspace root,
|
|
11
|
+
* reads the declaration from the working tree and picks the member.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { realpathSync } from "node:fs";
|
|
15
|
+
import { relative } from "node:path";
|
|
16
|
+
import { findWorkspaceRoot } from "../project-root";
|
|
17
|
+
import { ownerOf, readDeclaration, WorkspaceReadError, type BoxService } from "./declaration";
|
|
18
|
+
import { workingTree } from "./tree";
|
|
19
|
+
|
|
20
|
+
export type { BoxService } from "./declaration";
|
|
21
|
+
|
|
22
|
+
/** The box block's services, and whose they are. */
|
|
23
|
+
export interface DeclaredBoxServices {
|
|
24
|
+
/** The workspace root, absolute. */
|
|
25
|
+
root: string;
|
|
26
|
+
/** The member whose directory holds the working directory. */
|
|
27
|
+
member: string;
|
|
28
|
+
/** In file order. */
|
|
29
|
+
services: BoxService[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The services of the box block of the member whose directory holds `cwd`.
|
|
34
|
+
* Throws a {@link WorkspaceReadError} when there is no workspace or its
|
|
35
|
+
* declaration can't be read, and an Error when no member holds `cwd` or the
|
|
36
|
+
* member has no box block. A block with no `services` gives an empty list.
|
|
37
|
+
*/
|
|
38
|
+
export function readBoxServices(cwd: string = process.cwd()): DeclaredBoxServices {
|
|
39
|
+
const found = findWorkspaceRoot(cwd);
|
|
40
|
+
if (!found) {
|
|
41
|
+
throw new WorkspaceReadError("declaration-missing", `no chant.workspace.json or .jsonc between ${cwd} and the git root, so there is no box block to read services from`);
|
|
42
|
+
}
|
|
43
|
+
const root = realpathSync(found.dir);
|
|
44
|
+
const declaration = readDeclaration(workingTree(root));
|
|
45
|
+
const path = relative(root, realpathSync(cwd)).split("\\").join("/") || ".";
|
|
46
|
+
const owner = ownerOf(declaration, [], path);
|
|
47
|
+
if (!owner || !("member" in owner)) throw new Error(`no member of the workspace at ${root} holds ${cwd}, so there is no box block to read services from`);
|
|
48
|
+
const m = owner.member;
|
|
49
|
+
if (!m.box) throw new Error(`member ${m.name} (${m.dir}) has no box block in ${declaration.file}, so it declares no services`);
|
|
50
|
+
return { root, member: m.name, services: m.box.services };
|
|
51
|
+
}
|
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
* still the template's, so to a later upgrade they are the project's own
|
|
27
27
|
* edits, merged per file like any other (ws-005). A deleted file keeps its
|
|
28
28
|
* entry too, so a template version that still has it leaves it deleted.
|
|
29
|
+
*
|
|
30
|
+
* chant itself carries no chud lexicon or chud-shaped code (#2830, once
|
|
31
|
+
* #2715's proof passed): `chud-lexicon-exit` and its follow-ons are the
|
|
32
|
+
* retirement tool, not a remaining dependency. They stay until no known repo
|
|
33
|
+
* still depends on chud, and are removed only in a major version.
|
|
29
34
|
*/
|
|
30
35
|
|
|
31
36
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://intentius.io/chant/schemas/workspace/check/v1/check.schema.json",
|
|
4
4
|
"title": "chant workspace check output",
|
|
5
|
-
"description": "What `chant workspace check --format json` prints, version 1 of the read contract for checking a workspace (#2524 D9, D15, D16, #2535, #2536). The lineage lock findings carry reason codes; the declaration findings carry WSP ids from the check catalog, WSP001, a declaration that can't be read, also carries the declaration's reason code, and the box checks WSP121 to
|
|
5
|
+
"description": "What `chant workspace check --format json` prints, version 1 of the read contract for checking a workspace (#2524 D9, D15, D16, #2535, #2536). The lineage lock findings carry reason codes; the declaration findings carry WSP ids from the check catalog, WSP001, a declaration that can't be read, also carries the declaration's reason code, and the box checks WSP121 to WSP127 carry theirs. Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists: a new code is a new contract version. Contract version 1 is written by chant 0.81.0 and newer; a reader that needs it refuses output whose `contract` it doesn't know. Every code is in the one closed list of `reason-codes.ts`.",
|
|
6
6
|
"oneOf": [
|
|
7
7
|
{
|
|
8
8
|
"$ref": "#/$defs/result"
|
|
@@ -228,12 +228,14 @@
|
|
|
228
228
|
"box-isolation-collision",
|
|
229
229
|
"box-isolation-literal",
|
|
230
230
|
"box-fountain-callback-undeclared",
|
|
231
|
+
"box-intent-unknown",
|
|
232
|
+
"box-intent-unconstrained",
|
|
231
233
|
"work-acceptance-unmet",
|
|
232
234
|
"diagram-source-missing",
|
|
233
235
|
"diagram-render-missing",
|
|
234
236
|
"diagram-render-drift"
|
|
235
237
|
],
|
|
236
|
-
"description": "For WSP001: why the declaration can't be read. For WSP121 to
|
|
238
|
+
"description": "For WSP001: why the declaration can't be read. For WSP121 to WSP127, the box finding (#2726, #2727, #2780, #2850). For WSP117, work-acceptance-unmet (#2772). For WSP131 to WSP133, the diagram finding (#2764)."
|
|
237
239
|
}
|
|
238
240
|
}
|
|
239
241
|
},
|
|
@@ -304,12 +306,14 @@
|
|
|
304
306
|
"box-isolation-collision",
|
|
305
307
|
"box-isolation-literal",
|
|
306
308
|
"box-fountain-callback-undeclared",
|
|
309
|
+
"box-intent-unknown",
|
|
310
|
+
"box-intent-unconstrained",
|
|
307
311
|
"work-acceptance-unmet",
|
|
308
312
|
"diagram-source-missing",
|
|
309
313
|
"diagram-render-missing",
|
|
310
314
|
"diagram-render-drift"
|
|
311
315
|
],
|
|
312
|
-
"description": "For WSP001: why the declaration can't be read. For WSP121 to
|
|
316
|
+
"description": "For WSP001: why the declaration can't be read. For WSP121 to WSP127, the box finding (#2726, #2727, #2780, #2850). For WSP117, work-acceptance-unmet (#2772). For WSP131 to WSP133, the diagram finding (#2764)."
|
|
313
317
|
},
|
|
314
318
|
"reason": {
|
|
315
319
|
"type": "string"
|
|
@@ -89,7 +89,7 @@ describe("box-credential-declared (WSP121)", () => {
|
|
|
89
89
|
test("an env file and a shell script in the box's directory are read too; prose only for credential shapes", async () => {
|
|
90
90
|
const root = repo({
|
|
91
91
|
"chant.workspace.json": declaration(BROKERED),
|
|
92
|
-
"spec/.env": `FOUNTAIN_API_KEY=abc123def456\nPORT=8080\nGIT_TOKEN=\${
|
|
92
|
+
"spec/.env": `FOUNTAIN_API_KEY=abc123def456\nPORT=8080\nGIT_TOKEN=\${ACME_GIT_TOKEN}\n`,
|
|
93
93
|
"spec/run.sh": `#!/bin/sh\nexport GITHUB_TOKEN=${GITHUB_TOKEN}\necho "password=$GIT_TOKEN"\nTOKEN=$1\ntoken=~/box/llm-token\nSECRET=/run/secrets/x\n`,
|
|
94
94
|
"spec/README.md": `Token: rotate it monthly.\n`,
|
|
95
95
|
"spec/node_modules/pkg/index.js": `export const k = ${JSON.stringify(ANTHROPIC_KEY)};\n`,
|
|
@@ -156,6 +156,8 @@ describe("the box block in the declaration", () => {
|
|
|
156
156
|
expect(d.members[0].box).toEqual({
|
|
157
157
|
pointer: "/members/0/box",
|
|
158
158
|
isolation: null,
|
|
159
|
+
intent: null,
|
|
160
|
+
services: [],
|
|
159
161
|
capabilities: [
|
|
160
162
|
{ name: "fountain", broker: "lobby", scope: ["agent", "vault"], pointer: "/members/0/box/capabilities/0" },
|
|
161
163
|
{ name: "inference", broker: null, scope: [], pointer: "/members/0/box/capabilities/1" },
|