Skip to content

Commit bec6764

Browse files
committed
feat(add-node): require elementId for realisations (INV10) and governedById for gates (INV8)
addNodeOp now enforces three invariants at creation time: - change → decision via implements (INV2, existing) - realisation → element via implements (INV10) - gate → invariant/policy via governed_by (INV8) CLI gains --element and --governed-by flags. Also adds gate to the governed_by.from endpoint types, which was previously missing.
1 parent 8bd2cad commit bec6764

4 files changed

Lines changed: 232 additions & 30 deletions

File tree

src/cli/commands/add.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ const optsSchema = mutationOpts.extend({
2121
.string()
2222
.optional()
2323
.describe("Decision ID this change implements (required for change nodes)"),
24+
element: z
25+
.string()
26+
.optional()
27+
.describe(
28+
"Element ID this realisation implements (required for realisation nodes)",
29+
),
30+
governedBy: z
31+
.string()
32+
.optional()
33+
.describe(
34+
"Invariant or policy ID this gate enforces (required for gate nodes)",
35+
),
2436
option: z
2537
.array(z.string())
2638
.optional()
@@ -100,7 +112,13 @@ export const addCommand: CommandDef<typeof argsSchema, typeof optsSchema> = {
100112
}
101113

102114
try {
103-
const newDoc = addNodeOp({ doc, node, decisionId: opts.decision });
115+
const newDoc = addNodeOp({
116+
doc,
117+
node,
118+
decisionId: opts.decision,
119+
elementId: opts.element,
120+
governedById: opts.governedBy,
121+
});
104122

105123
persistDoc(newDoc, loaded, opts);
106124

src/endpoint-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ export const RELATIONSHIP_ENDPOINT_TYPES: Record<
244244
"element",
245245
"realisation",
246246
"stage",
247+
"gate",
247248
"change",
248249
"policy",
249250
],

src/operations/add-node.ts

Lines changed: 80 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,61 +2,117 @@ import * as z from "zod";
22
import { defineOperation } from "./define-operation.js";
33
import { SysProMDocument, Node } from "../schema.js";
44

5+
/**
6+
* Validate that a referenced node exists and has the expected type.
7+
* @param doc - Document to search for the target node.
8+
* @param doc.nodes - Array of nodes in the document.
9+
* @param id - ID of the target node to resolve.
10+
* @param expectedTypes - Allowed node types for the target.
11+
* @param label - Human-readable label for error messages (e.g. "Decision").
12+
* @returns The resolved node.
13+
* @throws {Error} If the node does not exist or has an unexpected type.
14+
* @example
15+
* resolveTarget(doc, "D1", ["decision"], "Decision");
16+
*/
17+
function resolveTarget(
18+
doc: { nodes: readonly { id: string; type: string }[] },
19+
id: string,
20+
expectedTypes: readonly string[],
21+
label: string,
22+
): { id: string; type: string } {
23+
const target = doc.nodes.find((n) => n.id === id);
24+
if (!target) {
25+
throw new Error(
26+
`${label} not found: ${id}. The referenced node must exist before adding this node.`,
27+
);
28+
}
29+
if (!expectedTypes.includes(target.type)) {
30+
const typeList = expectedTypes.join(" or ");
31+
const article = /^[aeiou]/i.test(typeList) ? "an" : "a";
32+
throw new Error(
33+
`Node ${id} is not ${article} ${typeList} (type: ${target.type}).`,
34+
);
35+
}
36+
return target;
37+
}
38+
539
/**
640
* Add a node to a SysProM document. Returns a new document with the node appended.
741
*
8-
* When adding a `change` node, `decisionId` is required. The operation validates
9-
* that the referenced node exists and is a decision, then automatically creates
10-
* an `implements` relationship from the change to the decision (INV2).
42+
* Certain node types require a companion relationship at creation time:
43+
* - `change` requires `decisionId` → creates `implements` relationship (INV2)
44+
* - `realisation` requires `elementId` → creates `implements` relationship (INV10)
45+
* - `gate` requires `governedById` → creates `governed_by` relationship (INV8)
1146
* @throws {Error} If a node with the same ID already exists.
12-
* @throws {Error} If adding a change without a decisionId.
13-
* @throws {Error} If decisionId references a missing or non-decision node.
47+
* @throws {Error} If a required relationship target is missing or has the wrong type.
1448
*/
1549
export const addNodeOp = defineOperation({
1650
name: "addNode",
1751
description:
18-
"Add a node to the document. Throws if the ID already exists. Change nodes require a decisionId.",
52+
"Add a node to the document. Throws if the ID already exists. Change, realisation, and gate nodes require companion relationship targets.",
1953
input: z.object({
2054
doc: SysProMDocument,
2155
node: Node,
2256
decisionId: z.string().optional(),
57+
elementId: z.string().optional(),
58+
governedById: z.string().optional(),
2359
}),
2460
output: SysProMDocument,
25-
fn({ doc, node, decisionId }) {
61+
fn({ doc, node, decisionId, elementId, governedById }) {
2662
if (doc.nodes.some((n) => n.id === node.id)) {
2763
throw new Error(`Node with ID '${node.id}' already exists.`);
2864
}
2965

66+
const newNodes = [...doc.nodes, node];
67+
const newRels = [...(doc.relationships ?? [])];
68+
3069
if (node.type === "change") {
3170
if (!decisionId) {
3271
throw new Error(
3372
`Adding a change requires a decisionId. Use --decision <ID> to link this change to its decision.`,
3473
);
3574
}
36-
const target = doc.nodes.find((n) => n.id === decisionId);
37-
if (!target) {
75+
resolveTarget(doc, decisionId, ["decision"], "Decision");
76+
newRels.push({
77+
from: node.id,
78+
to: decisionId,
79+
type: "implements" as const,
80+
});
81+
}
82+
83+
if (node.type === "realisation") {
84+
if (!elementId) {
3885
throw new Error(
39-
`Decision not found: ${decisionId}. The referenced decision must exist before adding a change.`,
86+
`Adding a realisation requires an elementId. Use --element <ID> to link this realisation to its element.`,
4087
);
4188
}
42-
if (target.type !== "decision") {
89+
resolveTarget(doc, elementId, ["element"], "Element");
90+
newRels.push({
91+
from: node.id,
92+
to: elementId,
93+
type: "implements" as const,
94+
});
95+
}
96+
97+
if (node.type === "gate") {
98+
if (!governedById) {
4399
throw new Error(
44-
`Node ${decisionId} is not a decision (type: ${target.type}). Changes must reference a decision node.`,
100+
`Adding a gate requires a governedById. Use --governed-by <ID> to link this gate to the invariant or policy it enforces.`,
45101
);
46102
}
47-
return {
48-
...doc,
49-
nodes: [...doc.nodes, node],
50-
relationships: [
51-
...(doc.relationships ?? []),
52-
{ from: node.id, to: decisionId, type: "implements" as const },
53-
],
54-
};
103+
resolveTarget(
104+
doc,
105+
governedById,
106+
["invariant", "policy"],
107+
"Invariant/policy",
108+
);
109+
newRels.push({
110+
from: node.id,
111+
to: governedById,
112+
type: "governed_by" as const,
113+
});
55114
}
56115

57-
return {
58-
...doc,
59-
nodes: [...doc.nodes, node],
60-
};
116+
return { ...doc, nodes: newNodes, relationships: newRels };
61117
},
62118
});

tests/add-cli.unit.test.ts

Lines changed: 132 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,23 @@ function makeDoc(): SysProMDocument {
88
nodes: [
99
{ id: "D1", type: "decision", name: "Existing Decision" },
1010
{ id: "I1", type: "intent", name: "Some Intent" },
11+
{ id: "EL1", type: "element", name: "Some Element" },
12+
{ id: "INV1", type: "invariant", name: "Some Invariant" },
13+
{ id: "POL1", type: "policy", name: "Some Policy" },
1114
],
1215
relationships: [],
1316
};
1417
}
1518

16-
describe("addNode change-decision guard", () => {
19+
describe("addNode change-decision guard (INV2)", () => {
1720
it("adds change node with implements relationship when decisionId provided", () => {
1821
const doc = makeDoc();
1922
const result = addNodeOp({
2023
doc,
2124
node: { id: "CH1", type: "change", name: "My Change" },
2225
decisionId: "D1",
2326
});
24-
assert.equal(result.nodes.length, 3);
27+
assert.equal(result.nodes.length, 6);
2528
assert.ok(result.nodes.find((n) => n.id === "CH1"));
2629
const rel = result.relationships?.find(
2730
(r) => r.from === "CH1" && r.to === "D1" && r.type === "implements",
@@ -73,7 +76,7 @@ describe("addNode change-decision guard", () => {
7376
doc,
7477
node: { id: "CN1", type: "concept", name: "My Concept" },
7578
});
76-
assert.equal(result.nodes.length, 3);
79+
assert.equal(result.nodes.length, 6);
7780
assert.ok(result.nodes.find((n) => n.id === "CN1"));
7881
});
7982

@@ -84,11 +87,135 @@ describe("addNode change-decision guard", () => {
8487
node: { id: "CN1", type: "concept", name: "My Concept" },
8588
decisionId: "D1",
8689
});
87-
assert.equal(result.nodes.length, 3);
88-
// Should not create a relationship for non-change nodes
90+
assert.equal(result.nodes.length, 6);
8991
assert.equal(
9092
result.relationships?.filter((r) => r.from === "CN1").length ?? 0,
9193
0,
9294
);
9395
});
9496
});
97+
98+
describe("addNode realisation-element guard (INV10)", () => {
99+
it("adds realisation with implements relationship when elementId provided", () => {
100+
const doc = makeDoc();
101+
const result = addNodeOp({
102+
doc,
103+
node: { id: "R1", type: "realisation", name: "My Realisation" },
104+
elementId: "EL1",
105+
});
106+
assert.equal(result.nodes.length, 6);
107+
assert.ok(result.nodes.find((n) => n.id === "R1"));
108+
const rel = result.relationships?.find(
109+
(r) => r.from === "R1" && r.to === "EL1" && r.type === "implements",
110+
);
111+
assert.ok(rel, "implements relationship should be created");
112+
});
113+
114+
it("throws when realisation added without elementId", () => {
115+
const doc = makeDoc();
116+
assert.throws(
117+
() =>
118+
addNodeOp({
119+
doc,
120+
node: { id: "R1", type: "realisation", name: "My Realisation" },
121+
}),
122+
/realisation.*requires.*elementId/i,
123+
);
124+
});
125+
126+
it("throws when elementId does not exist", () => {
127+
const doc = makeDoc();
128+
assert.throws(
129+
() =>
130+
addNodeOp({
131+
doc,
132+
node: { id: "R1", type: "realisation", name: "My Realisation" },
133+
elementId: "EL999",
134+
}),
135+
/not found.*EL999/i,
136+
);
137+
});
138+
139+
it("throws when elementId references a non-element node", () => {
140+
const doc = makeDoc();
141+
assert.throws(
142+
() =>
143+
addNodeOp({
144+
doc,
145+
node: { id: "R1", type: "realisation", name: "My Realisation" },
146+
elementId: "D1",
147+
}),
148+
/not an element/i,
149+
);
150+
});
151+
});
152+
153+
describe("addNode gate-invariant guard (INV8)", () => {
154+
it("adds gate with governed_by relationship when governedById provided with invariant", () => {
155+
const doc = makeDoc();
156+
const result = addNodeOp({
157+
doc,
158+
node: { id: "G1", type: "gate", name: "My Gate" },
159+
governedById: "INV1",
160+
});
161+
assert.equal(result.nodes.length, 6);
162+
assert.ok(result.nodes.find((n) => n.id === "G1"));
163+
const rel = result.relationships?.find(
164+
(r) =>
165+
r.from === "G1" && r.to === "INV1" && r.type === "governed_by",
166+
);
167+
assert.ok(rel, "governed_by relationship should be created");
168+
});
169+
170+
it("adds gate with governed_by relationship when governedById provided with policy", () => {
171+
const doc = makeDoc();
172+
const result = addNodeOp({
173+
doc,
174+
node: { id: "G1", type: "gate", name: "My Gate" },
175+
governedById: "POL1",
176+
});
177+
const rel = result.relationships?.find(
178+
(r) =>
179+
r.from === "G1" && r.to === "POL1" && r.type === "governed_by",
180+
);
181+
assert.ok(rel, "governed_by relationship should be created for policy");
182+
});
183+
184+
it("throws when gate added without governedById", () => {
185+
const doc = makeDoc();
186+
assert.throws(
187+
() =>
188+
addNodeOp({
189+
doc,
190+
node: { id: "G1", type: "gate", name: "My Gate" },
191+
}),
192+
/gate.*requires.*governedById/i,
193+
);
194+
});
195+
196+
it("throws when governedById does not exist", () => {
197+
const doc = makeDoc();
198+
assert.throws(
199+
() =>
200+
addNodeOp({
201+
doc,
202+
node: { id: "G1", type: "gate", name: "My Gate" },
203+
governedById: "INV999",
204+
}),
205+
/not found.*INV999/i,
206+
);
207+
});
208+
209+
it("throws when governedById references neither invariant nor policy", () => {
210+
const doc = makeDoc();
211+
assert.throws(
212+
() =>
213+
addNodeOp({
214+
doc,
215+
node: { id: "G1", type: "gate", name: "My Gate" },
216+
governedById: "D1",
217+
}),
218+
/not an invariant or policy/i,
219+
);
220+
});
221+
});

0 commit comments

Comments
 (0)