sysprom 1.16.1 → 1.17.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/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  /sɪs.prɒm/
4
4
 
5
+ [![npm](https://img.shields.io/badge/npm-cb3837?logo=npm)](https://www.npmjs.com/package/sysprom)[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github)](https://github.com/ExaDev/SysProM)
6
+
5
7
  A recursive, decision-driven model for recording where every part of a system came from, what decisions shaped it, and how it reached its current form.
6
8
 
7
9
  ## Install
@@ -31,6 +31,49 @@ const optsSchema = mutationOpts.extend({
31
31
  .optional()
32
32
  .describe("Option in format 'ID:description' or just 'description' (repeatable)"),
33
33
  });
34
+ /**
35
+ * Populate optional node fields from CLI options.
36
+ * @param node - The node to populate
37
+ * @param description - Optional description
38
+ * @param status - Optional (pre-validated) status
39
+ * @param context - Optional decision context
40
+ * @param rationale - Optional decision rationale
41
+ * @param scope - Optional change scope
42
+ * @param selected - Optional selected option ID
43
+ * @param options - Optional array of option descriptions
44
+ * @example
45
+ * ```ts
46
+ * const node: Node = { id: "D1", type: "decision", name: "My Decision" };
47
+ * populateNodeFromOpts(node, desc, status, ctx, rat, scope, sel, opts);
48
+ * ```
49
+ */
50
+ function populateNodeFromOpts(node, description, status, context, rationale, scope, selected, options) {
51
+ if (description)
52
+ node.description = description;
53
+ if (status)
54
+ node.status = status;
55
+ if (context)
56
+ node.context = context;
57
+ if (rationale)
58
+ node.rationale = rationale;
59
+ if (scope?.length)
60
+ node.scope = scope;
61
+ if (selected)
62
+ node.selected = selected;
63
+ if (options?.length) {
64
+ node.options = options.map((arg, i) => {
65
+ const colonIdx = arg.indexOf(":");
66
+ if (colonIdx >= 0) {
67
+ return {
68
+ id: arg.slice(0, colonIdx),
69
+ description: arg.slice(colonIdx + 1),
70
+ };
71
+ }
72
+ const letter = String.fromCharCode(65 + i);
73
+ return { id: `${node.id}-OPT-${letter}`, description: arg };
74
+ });
75
+ }
76
+ }
34
77
  export const addCommand = {
35
78
  name: "add",
36
79
  description: addNodeOp.def.description,
@@ -49,45 +92,18 @@ export const addCommand = {
49
92
  console.error(`Unknown node type: "${type}". Valid types: ${NodeType.options.join(", ")}`);
50
93
  process.exit(1);
51
94
  }
95
+ if (opts.status && !NodeStatus.is(opts.status)) {
96
+ console.error(`Unknown status: "${opts.status}". Valid statuses: ${NodeStatus.options.join(", ")}`);
97
+ process.exit(1);
98
+ }
52
99
  const id = opts.id ?? nextIdOp({ doc, type });
53
- // Build the node from CLI options
54
100
  const node = { id, type, name: opts.name };
55
- if (opts.description) {
56
- node.description = opts.description;
57
- }
58
- if (opts.status) {
59
- if (!NodeStatus.is(opts.status)) {
60
- console.error(`Unknown status: "${opts.status}". Valid statuses: ${NodeStatus.options.join(", ")}`);
61
- process.exit(1);
62
- }
63
- node.status = opts.status;
64
- }
65
- if (opts.context) {
66
- node.context = opts.context;
67
- }
68
- if (opts.rationale) {
69
- node.rationale = opts.rationale;
70
- }
71
- if (opts.scope && opts.scope.length > 0) {
72
- node.scope = opts.scope;
73
- }
74
- if (opts.selected) {
75
- node.selected = opts.selected;
76
- }
77
- if (opts.option && opts.option.length > 0) {
78
- node.options = opts.option.map((arg, i) => {
79
- const colonIdx = arg.indexOf(":");
80
- if (colonIdx >= 0) {
81
- return {
82
- id: arg.slice(0, colonIdx),
83
- description: arg.slice(colonIdx + 1),
84
- };
85
- }
86
- // Auto-generate ID: D26-OPT-A, D26-OPT-B, etc.
87
- const letter = String.fromCharCode(65 + i); // A, B, C, ...
88
- return { id: `${id}-OPT-${letter}`, description: arg };
89
- });
101
+ // Use type guard to narrow status type after validation
102
+ let status;
103
+ if (opts.status && NodeStatus.is(opts.status)) {
104
+ status = opts.status;
90
105
  }
106
+ populateNodeFromOpts(node, opts.description, status, opts.context, opts.rationale, opts.scope, opts.selected, opts.option);
91
107
  try {
92
108
  const newDoc = addNodeOp({
93
109
  doc,
@@ -5,9 +5,17 @@ import { inferCompletenessOp, inferLifecycleOp, inferImpactOp, inferDerivedOp, }
5
5
  // ---------------------------------------------------------------------------
6
6
  // Presentation helpers
7
7
  // ---------------------------------------------------------------------------
8
+ function getScoreColour(score) {
9
+ if (score === 1)
10
+ return pc.green;
11
+ if (score >= 0.5)
12
+ return pc.yellow;
13
+ return pc.red;
14
+ }
8
15
  function printCompletenessNode(r) {
9
- const scoreColour = r.score === 1 ? pc.green : r.score >= 0.5 ? pc.yellow : pc.red;
10
- console.log(`${pc.cyan(r.id.padEnd(12))} ${pc.dim(r.type.padEnd(16))} ${pc.bold(r.name)} ${scoreColour(`[${(r.score * 100).toFixed(0)}%]`)}`);
16
+ const scoreColour = getScoreColour(r.score);
17
+ const scoreText = `[${(r.score * 100).toFixed(0)}%]`;
18
+ console.log(`${pc.cyan(r.id.padEnd(12))} ${pc.dim(r.type.padEnd(16))} ${pc.bold(r.name)} ${scoreColour(scoreText)}`);
11
19
  for (const issue of r.issues) {
12
20
  console.log(` ${pc.dim("•")} ${pc.red(issue)}`);
13
21
  }
@@ -21,7 +29,8 @@ function printLifecycleNode(r) {
21
29
  unknown: pc.dim,
22
30
  };
23
31
  const colour = phaseColours[r.inferredPhase] ?? pc.dim;
24
- console.log(`${pc.cyan(r.id.padEnd(12))} ${pc.dim(r.type.padEnd(16))} ${pc.bold(r.name)} ${colour(`[${r.inferredPhase}]`)} ${pc.dim(r.inferredState)}`);
32
+ const phaseLabel = colour(`[${r.inferredPhase}]`);
33
+ console.log(`${pc.cyan(r.id.padEnd(12))} ${pc.dim(r.type.padEnd(16))} ${pc.bold(r.name)} ${phaseLabel} ${pc.dim(r.inferredState)}`);
25
34
  }
26
35
  function printImpactNode(r) {
27
36
  const typeColours = {
@@ -32,7 +41,9 @@ function printImpactNode(r) {
32
41
  const colour = typeColours[r.impactType] ?? pc.dim;
33
42
  const nodeName = r.node ? r.node.name : "(unknown)";
34
43
  const indent = " ".repeat(r.distance);
35
- console.log(`${indent}${pc.cyan(r.id)} ${pc.dim(`(${String(r.distance)})`)} ${colour(`[${r.impactType}]`)} ${pc.bold(nodeName)}`);
44
+ const distanceLabel = pc.dim(`(${String(r.distance)})`);
45
+ const typeLabel = colour(`[${r.impactType}]`);
46
+ console.log(`${indent}${pc.cyan(r.id)} ${distanceLabel} ${typeLabel} ${pc.bold(nodeName)}`);
36
47
  }
37
48
  function printDerivedRelationship(r) {
38
49
  const typeColours = {
@@ -41,7 +52,8 @@ function printDerivedRelationship(r) {
41
52
  inverse: pc.green,
42
53
  };
43
54
  const colour = typeColours[r.derivationType] ?? pc.dim;
44
- console.log(`${pc.cyan(r.from.padEnd(12))} ${colour(r.type.padEnd(20))} ${pc.cyan(r.to)} ${pc.dim(`[${r.derivationType}]`)}`);
55
+ const derivationLabel = pc.dim(`[${r.derivationType}]`);
56
+ console.log(`${pc.cyan(r.from.padEnd(12))} ${colour(r.type.padEnd(20))} ${pc.cyan(r.to)} ${derivationLabel}`);
45
57
  }
46
58
  // ---------------------------------------------------------------------------
47
59
  // Arg/opt schemas
@@ -79,12 +91,13 @@ const completenessSubcommand = {
79
91
  console.log(JSON.stringify(result, null, 2));
80
92
  }
81
93
  else {
82
- console.log(pc.bold("\nCompleteness Analysis\n") +
83
- pc.dim(`Average score: ${(result.averageScore * 100).toFixed(1)}% | `) +
84
- pc.green(`${String(result.completeNodes)} complete`) +
85
- pc.dim(" | ") +
86
- pc.red(`${String(result.incompleteNodes)} incomplete`) +
87
- "\n");
94
+ const avgScore = (result.averageScore * 100).toFixed(1);
95
+ const completeText = pc.green(`${String(result.completeNodes)} complete`);
96
+ const incompleteText = pc.red(`${String(result.incompleteNodes)} incomplete`);
97
+ const scorePrefix = pc.dim(`Average score: ${avgScore}% | `);
98
+ const separator = pc.dim(" | ");
99
+ const summary = scorePrefix + completeText + separator + incompleteText;
100
+ console.log(pc.bold("\nCompleteness Analysis\n") + summary + "\n");
88
101
  // Show incomplete nodes first
89
102
  const incomplete = result.nodes.filter((n) => n.score < 1);
90
103
  const complete = result.nodes.filter((n) => n.score === 1);
@@ -111,9 +124,9 @@ const lifecycleSubcommand = {
111
124
  console.log(JSON.stringify(result, null, 2));
112
125
  }
113
126
  else {
114
- console.log(pc.bold("\nLifecycle Analysis\n") +
115
- pc.dim(`Early: ${String(result.summary.early)} | Middle: ${String(result.summary.middle)} | Late: ${String(result.summary.late)} | Terminal: ${String(result.summary.terminal)} | Unknown: ${String(result.summary.unknown)}`) +
116
- "\n");
127
+ const { early, middle, late, terminal, unknown } = result.summary;
128
+ const summary = pc.dim(`Early: ${String(early)} | Middle: ${String(middle)} | Late: ${String(late)} | Terminal: ${String(terminal)} | Unknown: ${String(unknown)}`);
129
+ console.log(pc.bold("\nLifecycle Analysis\n") + summary + "\n");
117
130
  for (const n of result.nodes)
118
131
  printLifecycleNode(n);
119
132
  }
@@ -149,9 +162,10 @@ const impactSubcommand = {
149
162
  ? ` [depth: ${String(args.maxDepth)}]`
150
163
  : "";
151
164
  const filterLabel = args.filter ? ` [filter: ${args.filter}]` : "";
152
- console.log(pc.bold(`\nImpact Analysis from ${args.id}${directionLabel}${depthLabel}${filterLabel}\n`) +
153
- pc.dim(`Direct: ${String(result.summary.direct)} | Transitive: ${String(result.summary.transitive)} | Potential: ${String(result.summary.potential)} | Total: ${String(result.summary.total)}`) +
154
- "\n");
165
+ const title = `\nImpact Analysis from ${args.id}${directionLabel}${depthLabel}${filterLabel}\n`;
166
+ const { direct, transitive, potential, total } = result.summary;
167
+ const summary = pc.dim(`Direct: ${String(direct)} | Transitive: ${String(transitive)} | Potential: ${String(potential)} | Total: ${String(total)}`);
168
+ console.log(pc.bold(title) + summary + "\n");
155
169
  if (result.impactedNodes.length === 0) {
156
170
  console.log(pc.dim("No impacted nodes found"));
157
171
  }
@@ -175,9 +189,9 @@ const derivedSubcommand = {
175
189
  console.log(JSON.stringify(result, null, 2));
176
190
  }
177
191
  else {
178
- console.log(pc.bold("\nDerived Relationships\n") +
179
- pc.dim(`Transitive: ${String(result.summary.transitive)} | Composite: ${String(result.summary.composite)} | Inverse: ${String(result.summary.inverse)} | Total: ${String(result.summary.total)}`) +
180
- "\n");
192
+ const { transitive, composite, inverse, total } = result.summary;
193
+ const summary = pc.dim(`Transitive: ${String(transitive)} | Composite: ${String(composite)} | Inverse: ${String(inverse)} | Total: ${String(total)}`);
194
+ console.log(pc.bold("\nDerived Relationships\n") + summary + "\n");
181
195
  if (result.derivedRelationships.length === 0) {
182
196
  console.log(pc.dim("No derived relationships found"));
183
197
  }
@@ -198,18 +212,23 @@ const allSubcommand = {
198
212
  // Completeness
199
213
  console.log(pc.bold("\n=== Completeness ===\n"));
200
214
  const completeness = inferCompletenessOp({ doc });
201
- console.log(pc.dim(`Average score: ${(completeness.averageScore * 100).toFixed(1)}% | `) +
202
- pc.green(`${String(completeness.completeNodes)} complete`) +
215
+ const completeScore = (completeness.averageScore * 100).toFixed(1);
216
+ const completeText = pc.green(`${String(completeness.completeNodes)} complete`);
217
+ const incompleteText = pc.red(`${String(completeness.incompleteNodes)} incomplete`);
218
+ console.log(pc.dim(`Average score: ${completeScore}% | `) +
219
+ completeText +
203
220
  pc.dim(" | ") +
204
- pc.red(`${String(completeness.incompleteNodes)} incomplete`));
221
+ incompleteText);
205
222
  // Lifecycle
206
223
  console.log(pc.bold("\n=== Lifecycle ===\n"));
207
224
  const lifecycle = inferLifecycleOp({ doc });
208
- console.log(pc.dim(`Early: ${String(lifecycle.summary.early)} | Middle: ${String(lifecycle.summary.middle)} | Late: ${String(lifecycle.summary.late)} | Terminal: ${String(lifecycle.summary.terminal)} | Unknown: ${String(lifecycle.summary.unknown)}`));
225
+ const { early, middle, late, terminal, unknown } = lifecycle.summary;
226
+ console.log(pc.dim(`Early: ${String(early)} | Middle: ${String(middle)} | Late: ${String(late)} | Terminal: ${String(terminal)} | Unknown: ${String(unknown)}`));
209
227
  // Derived
210
228
  console.log(pc.bold("\n=== Derived Relationships ===\n"));
211
229
  const derived = inferDerivedOp({ doc });
212
- console.log(pc.dim(`Transitive: ${String(derived.summary.transitive)} | Composite: ${String(derived.summary.composite)} | Inverse: ${String(derived.summary.inverse)} | Total: ${String(derived.summary.total)}`));
230
+ const { transitive, composite, inverse, total } = derived.summary;
231
+ console.log(pc.dim(`Transitive: ${String(transitive)} | Composite: ${String(composite)} | Inverse: ${String(inverse)} | Total: ${String(total)}`));
213
232
  if (opts.json) {
214
233
  console.log(JSON.stringify({
215
234
  completeness,
@@ -7,43 +7,41 @@ import { NodeType, NodeStatus } from "../../schema.js";
7
7
  // ---------------------------------------------------------------------------
8
8
  // Presentation helpers
9
9
  // ---------------------------------------------------------------------------
10
- function printNode(r, verbose) {
11
- if (verbose) {
12
- console.log(`${pc.cyan(r.id)}${pc.bold(r.name)}`);
13
- console.log(` ${pc.dim("Type")}: ${r.type}`);
14
- if (r.status)
15
- console.log(` ${pc.dim("Status")}: ${pc.yellow(r.status)}`);
16
- if (r.description)
17
- console.log(` ${pc.dim("Description")}: ${textToString(r.description)}`);
18
- if (r.context)
19
- console.log(` ${pc.dim("Context")}: ${textToString(r.context)}`);
20
- if (r.rationale)
21
- console.log(` ${pc.dim("Rationale")}: ${textToString(r.rationale)}`);
22
- if (r.selected)
23
- console.log(` ${pc.dim("Selected")}: ${r.selected}`);
24
- if (r.options) {
25
- console.log(` ${pc.dim("Options")}:`);
26
- for (const o of r.options)
27
- console.log(` ${o.id}: ${textToString(o.description)}`);
28
- }
29
- if (r.scope)
30
- console.log(` ${pc.dim("Scope")}: ${r.scope.join(", ")}`);
31
- if (r.lifecycle) {
32
- const states = Object.entries(r.lifecycle)
33
- .map(([k, v]) => `${k}=${String(v)}`)
34
- .join(", ");
35
- console.log(` ${pc.dim("Lifecycle")}: ${states}`);
36
- }
37
- if (r.includes)
38
- console.log(` ${pc.dim("Includes")}: ${r.includes.join(", ")}`);
39
- console.log("");
10
+ function printNodeCompact(r) {
11
+ const desc = r.description
12
+ ? " " + textToString(r.description).slice(0, 60)
13
+ : "";
14
+ console.log(`${pc.cyan(r.id.padEnd(12))} ${pc.dim(r.type.padEnd(16))} ${pc.bold(r.name)}${desc}`);
15
+ }
16
+ function printNodeVerbose(r) {
17
+ console.log(`${pc.cyan(r.id)} ${pc.bold(r.name)}`);
18
+ console.log(` ${pc.dim("Type")}: ${r.type}`);
19
+ if (r.status)
20
+ console.log(` ${pc.dim("Status")}: ${pc.yellow(r.status)}`);
21
+ if (r.description)
22
+ console.log(` ${pc.dim("Description")}: ${textToString(r.description)}`);
23
+ if (r.context)
24
+ console.log(` ${pc.dim("Context")}: ${textToString(r.context)}`);
25
+ if (r.rationale)
26
+ console.log(` ${pc.dim("Rationale")}: ${textToString(r.rationale)}`);
27
+ if (r.selected)
28
+ console.log(` ${pc.dim("Selected")}: ${r.selected}`);
29
+ if (r.options) {
30
+ console.log(` ${pc.dim("Options")}:`);
31
+ for (const o of r.options)
32
+ console.log(` ${o.id}: ${textToString(o.description)}`);
40
33
  }
41
- else {
42
- const desc = r.description
43
- ? " — " + textToString(r.description).slice(0, 60)
44
- : "";
45
- console.log(`${pc.cyan(r.id.padEnd(12))} ${pc.dim(r.type.padEnd(16))} ${pc.bold(r.name)}${desc}`);
34
+ if (r.scope)
35
+ console.log(` ${pc.dim("Scope")}: ${r.scope.join(", ")}`);
36
+ if (r.lifecycle) {
37
+ const states = Object.entries(r.lifecycle)
38
+ .map(([k, v]) => `${k}=${String(v)}`)
39
+ .join(", ");
40
+ console.log(` ${pc.dim("Lifecycle")}: ${states}`);
46
41
  }
42
+ if (r.includes)
43
+ console.log(` ${pc.dim("Includes")}: ${r.includes.join(", ")}`);
44
+ console.log("");
47
45
  }
48
46
  function isTraceTreeNode(value) {
49
47
  if (typeof value !== "object" || value === null)
@@ -103,7 +101,7 @@ const nodesSubcommand = {
103
101
  }
104
102
  else {
105
103
  for (const n of nodes)
106
- printNode(n, false);
104
+ printNodeCompact(n);
107
105
  console.log(`\n${String(nodes.length)} node(s)`);
108
106
  }
109
107
  },
@@ -127,7 +125,7 @@ const nodeSubcommand = {
127
125
  console.log(JSON.stringify(result.node, null, 2));
128
126
  }
129
127
  else {
130
- printNode(result.node, true);
128
+ printNodeVerbose(result.node);
131
129
  if (result.outgoing.length > 0) {
132
130
  console.log(`${pc.dim("Outgoing relationships")}:`);
133
131
  for (const r of result.outgoing)
@@ -18,6 +18,82 @@ function detectFormat(outputPath) {
18
18
  }
19
19
  return "json"; // default
20
20
  }
21
+ /**
22
+ * Validate that both paths exist, exits with error if not.
23
+ * @param inputPath - Path to SysProM document
24
+ * @param specKitDir - Path to Spec-Kit directory
25
+ * @example
26
+ * ```ts
27
+ * validatePaths(inputPath, specKitDir);
28
+ * ```
29
+ */
30
+ function validatePaths(inputPath, specKitDir) {
31
+ if (!existsSync(inputPath)) {
32
+ console.error(`Error: Input file does not exist: ${inputPath}`);
33
+ process.exit(1);
34
+ }
35
+ if (!existsSync(specKitDir)) {
36
+ console.error(`Error: Spec-Kit directory does not exist: ${specKitDir}`);
37
+ process.exit(1);
38
+ }
39
+ }
40
+ /**
41
+ * Search up to 5 parent directories for Spec-Kit constitution file.
42
+ * @param specKitDir - Path to Spec-Kit directory to start search from
43
+ * @returns Path to constitution file if found, undefined otherwise
44
+ * @example
45
+ * ```ts
46
+ * const path = findConstitutionPath(specKitDir);
47
+ * ```
48
+ */
49
+ function findConstitutionPath(specKitDir) {
50
+ let searchDir = dirname(specKitDir);
51
+ for (let i = 0; i < 5; i++) {
52
+ const project = detectSpecKitProject(searchDir);
53
+ if (project.constitutionPath)
54
+ return project.constitutionPath;
55
+ const parent = dirname(searchDir);
56
+ if (parent === searchDir)
57
+ break;
58
+ searchDir = parent;
59
+ }
60
+ return undefined;
61
+ }
62
+ /**
63
+ * Print diff results in human-readable format.
64
+ * @param diff - The diff structure containing added, modified, and removed nodes
65
+ * @example
66
+ * ```ts
67
+ * printDiffResults(diff);
68
+ * ```
69
+ */
70
+ function printDiffResults(diff) {
71
+ console.log(`Diff between SysProM document and Spec-Kit directory:`);
72
+ if (diff.added.length === 0 &&
73
+ diff.modified.length === 0 &&
74
+ diff.removed.length === 0) {
75
+ console.log(` (no changes)`);
76
+ return;
77
+ }
78
+ if (diff.added.length > 0) {
79
+ console.log(` Added: ${String(diff.added.length)} node(s)`);
80
+ for (const node of diff.added) {
81
+ console.log(` - ${node.id}: ${node.name}`);
82
+ }
83
+ }
84
+ if (diff.modified.length > 0) {
85
+ console.log(` Modified: ${String(diff.modified.length)} node(s)`);
86
+ for (const { old } of diff.modified) {
87
+ console.log(` - ${old.id}: ${old.name}`);
88
+ }
89
+ }
90
+ if (diff.removed.length > 0) {
91
+ console.log(` Removed: ${String(diff.removed.length)} node(s)`);
92
+ for (const node of diff.removed) {
93
+ console.log(` - ${node.id}: ${node.name}`);
94
+ }
95
+ }
96
+ }
21
97
  function compareDocuments(oldDoc, newDoc) {
22
98
  const oldNodes = new Map(oldDoc.nodes.map((n) => [n.id, n]));
23
99
  const newNodes = new Map(newDoc.nodes.map((n) => [n.id, n]));
@@ -134,32 +210,10 @@ const syncSubcommand = {
134
210
  action(_args, opts) {
135
211
  const inputPath = resolve(opts.input);
136
212
  const specKitDir = resolve(opts.speckitDir);
137
- if (!existsSync(inputPath)) {
138
- console.error(`Error: Input file does not exist: ${inputPath}`);
139
- process.exit(1);
140
- }
141
- if (!existsSync(specKitDir)) {
142
- console.error(`Error: Spec-Kit directory does not exist: ${specKitDir}`);
143
- process.exit(1);
144
- }
145
- // Determine the prefix: use flag if provided, otherwise use directory name
213
+ validatePaths(inputPath, specKitDir);
146
214
  const idPrefix = opts.prefix ?? specKitDir.split("/").pop() ?? "FEAT";
147
- // Load SysProM document
148
215
  const { doc: syspromDoc, format } = loadDocument(inputPath);
149
- // Find constitution file
150
- let constitutionPath;
151
- let searchDir = dirname(specKitDir);
152
- for (let i = 0; i < 5; i++) {
153
- const project = detectSpecKitProject(searchDir);
154
- if (project.constitutionPath) {
155
- constitutionPath = project.constitutionPath;
156
- break;
157
- }
158
- const parent = dirname(searchDir);
159
- if (parent === searchDir)
160
- break;
161
- searchDir = parent;
162
- }
216
+ const constitutionPath = findConstitutionPath(specKitDir);
163
217
  // Parse Spec-Kit feature
164
218
  const specKitDoc = parseSpecKitFeature(specKitDir, idPrefix, constitutionPath);
165
219
  // Compare documents
@@ -232,63 +286,13 @@ const diffSubcommand = {
232
286
  action(_args, opts) {
233
287
  const inputPath = resolve(opts.input);
234
288
  const specKitDir = resolve(opts.speckitDir);
235
- if (!existsSync(inputPath)) {
236
- console.error(`Error: Input file does not exist: ${inputPath}`);
237
- process.exit(1);
238
- }
239
- if (!existsSync(specKitDir)) {
240
- console.error(`Error: Spec-Kit directory does not exist: ${specKitDir}`);
241
- process.exit(1);
242
- }
243
- // Determine the prefix: use flag if provided, otherwise use directory name
289
+ validatePaths(inputPath, specKitDir);
244
290
  const idPrefix = opts.prefix ?? specKitDir.split("/").pop() ?? "FEAT";
245
- // Load SysProM document
246
291
  const { doc: syspromDoc } = loadDocument(inputPath);
247
- // Find constitution file
248
- let constitutionPath;
249
- let searchDir = dirname(specKitDir);
250
- for (let i = 0; i < 5; i++) {
251
- const project = detectSpecKitProject(searchDir);
252
- if (project.constitutionPath) {
253
- constitutionPath = project.constitutionPath;
254
- break;
255
- }
256
- const parent = dirname(searchDir);
257
- if (parent === searchDir)
258
- break;
259
- searchDir = parent;
260
- }
261
- // Parse Spec-Kit feature
292
+ const constitutionPath = findConstitutionPath(specKitDir);
262
293
  const specKitDoc = parseSpecKitFeature(specKitDir, idPrefix, constitutionPath);
263
- // Compare documents
264
294
  const diff = compareDocuments(syspromDoc, specKitDoc);
265
- // Print what would change (read-only)
266
- console.log(`Diff between SysProM document and Spec-Kit directory:`);
267
- if (diff.added.length === 0 &&
268
- diff.modified.length === 0 &&
269
- diff.removed.length === 0) {
270
- console.log(` (no changes)`);
271
- }
272
- else {
273
- if (diff.added.length > 0) {
274
- console.log(` Added: ${String(diff.added.length)} node(s)`);
275
- for (const node of diff.added) {
276
- console.log(` - ${node.id}: ${node.name}`);
277
- }
278
- }
279
- if (diff.modified.length > 0) {
280
- console.log(` Modified: ${String(diff.modified.length)} node(s)`);
281
- for (const { old } of diff.modified) {
282
- console.log(` - ${old.id}: ${old.name}`);
283
- }
284
- }
285
- if (diff.removed.length > 0) {
286
- console.log(` Removed: ${String(diff.removed.length)} node(s)`);
287
- for (const node of diff.removed) {
288
- console.log(` - ${node.id}: ${node.name}`);
289
- }
290
- }
291
- }
295
+ printDiffResults(diff);
292
296
  },
293
297
  };
294
298
  // ============================================================================
@@ -12,13 +12,13 @@ export const statsCommand = {
12
12
  console.log(`${pc.bold("SysProM Document")}: ${s.title}`);
13
13
  console.log("");
14
14
  console.log(pc.bold("Nodes by type:"));
15
- for (const [type, count] of Object.entries(s.nodesByType).sort()) {
15
+ for (const [type, count] of Object.entries(s.nodesByType).sort(([a], [b]) => a.localeCompare(b))) {
16
16
  console.log(` ${type.padEnd(20)} ${pc.cyan(String(count))}`);
17
17
  }
18
18
  console.log(` ${"TOTAL".padEnd(20)} ${pc.cyan(String(s.totalNodes))}`);
19
19
  console.log("");
20
20
  console.log(pc.bold("Relationships by type:"));
21
- for (const [type, count] of Object.entries(s.relationshipsByType).sort()) {
21
+ for (const [type, count] of Object.entries(s.relationshipsByType).sort(([a], [b]) => a.localeCompare(b))) {
22
22
  console.log(` ${type.padEnd(20)} ${pc.cyan(String(count))}`);
23
23
  }
24
24
  console.log(` ${"TOTAL".padEnd(20)} ${pc.cyan(String(s.totalRelationships))}`);
@@ -30,14 +30,14 @@ export const statsCommand = {
30
30
  if (Object.keys(s.decisionLifecycle).length > 0) {
31
31
  console.log("");
32
32
  console.log(pc.bold("Decision lifecycle:"));
33
- for (const [state, count] of Object.entries(s.decisionLifecycle).sort()) {
33
+ for (const [state, count] of Object.entries(s.decisionLifecycle).sort(([a], [b]) => a.localeCompare(b))) {
34
34
  console.log(` ${state.padEnd(20)} ${pc.cyan(String(count))}`);
35
35
  }
36
36
  }
37
37
  if (Object.keys(s.changeLifecycle).length > 0) {
38
38
  console.log("");
39
39
  console.log(pc.bold("Change lifecycle:"));
40
- for (const [state, count] of Object.entries(s.changeLifecycle).sort()) {
40
+ for (const [state, count] of Object.entries(s.changeLifecycle).sort(([a], [b]) => a.localeCompare(b))) {
41
41
  console.log(` ${state.padEnd(20)} ${pc.cyan(String(count))}`);
42
42
  }
43
43
  }
@@ -6,21 +6,44 @@ import { mutationOpts, loadDoc, persistDoc } from "../shared.js";
6
6
  // CLI helper functions
7
7
  // ---------------------------------------------------------------------------
8
8
  function parseLifecycleValue(rawVal) {
9
- if (rawVal === "true") {
9
+ if (rawVal === "true")
10
10
  return true;
11
- }
12
- if (rawVal === "false") {
11
+ if (rawVal === "false")
13
12
  return false;
14
- }
15
- if (/^\d{4}-\d{2}-\d{2}/.test(rawVal)) {
16
- return rawVal; // ISO date string
17
- }
13
+ if (/^\d{4}-\d{2}-\d{2}/.test(rawVal))
14
+ return rawVal;
18
15
  return rawVal;
19
16
  }
20
17
  function parseMetaValue(val) {
21
18
  const numVal = Number(val);
22
19
  return Number.isFinite(numVal) && val === String(numVal) ? numVal : val;
23
20
  }
21
+ /**
22
+ * Parse lifecycle field updates from key=value format.
23
+ * @param nodeLifecycle - Optional existing lifecycle object from the node
24
+ * @param lifecycleArgs - Array of key=value strings to parse
25
+ * @returns Lifecycle object with parsed values, or null if no args
26
+ * @example
27
+ * ```ts
28
+ * const lifecycle = parseLifecycleFields(node.lifecycle, ["created=2026-01-01"]);
29
+ * ```
30
+ */
31
+ function parseLifecycleFields(nodeLifecycle, lifecycleArgs) {
32
+ if (!lifecycleArgs.length)
33
+ return null;
34
+ const lifecycle = { ...nodeLifecycle };
35
+ for (const kv of lifecycleArgs) {
36
+ const eqIdx = kv.indexOf("=");
37
+ if (eqIdx < 0) {
38
+ console.error(`Invalid --lifecycle format: ${kv} (expected key=value)`);
39
+ process.exit(1);
40
+ }
41
+ const key = kv.slice(0, eqIdx);
42
+ const rawVal = kv.slice(eqIdx + 1);
43
+ lifecycle[key] = parseLifecycleValue(rawVal);
44
+ }
45
+ return lifecycle;
46
+ }
24
47
  // ---------------------------------------------------------------------------
25
48
  // Arg/opt schemas
26
49
  // ---------------------------------------------------------------------------
@@ -80,19 +103,9 @@ const nodeSubcommand = {
80
103
  fields.context = opts.context;
81
104
  if (opts.rationale !== undefined)
82
105
  fields.rationale = opts.rationale;
83
- if (opts.lifecycle && opts.lifecycle.length > 0) {
84
- const lifecycle = { ...node.lifecycle };
85
- for (const kv of opts.lifecycle) {
86
- const eqIdx = kv.indexOf("=");
87
- if (eqIdx < 0) {
88
- console.error(`Invalid --lifecycle format: ${kv} (expected key=value)`);
89
- process.exit(1);
90
- }
91
- const key = kv.slice(0, eqIdx);
92
- const rawVal = kv.slice(eqIdx + 1);
93
- lifecycle[key] = parseLifecycleValue(rawVal);
94
- }
95
- fields.lifecycle = lifecycle;
106
+ const lifecycleFields = parseLifecycleFields(node.lifecycle, opts.lifecycle ?? []);
107
+ if (lifecycleFields) {
108
+ fields.lifecycle = lifecycleFields;
96
109
  }
97
110
  if (Object.keys(fields).length === 0) {
98
111
  console.error("No fields specified to update.");
package/dist/src/io.js CHANGED
@@ -12,6 +12,22 @@ function detectFormat(input) {
12
12
  return "json";
13
13
  return "single-md";
14
14
  }
15
+ /**
16
+ * Wrap an error with a descriptive prefix and attach the original as cause.
17
+ * @param prefix - The error prefix (e.g., "Failed to parse JSON")
18
+ * @param error - The caught error
19
+ * @example
20
+ * ```ts
21
+ * try {
22
+ * const data = JSON.parse(content);
23
+ * } catch (error) {
24
+ * wrapError("Failed to parse JSON", error);
25
+ * }
26
+ * ```
27
+ */
28
+ function wrapError(prefix, error) {
29
+ throw new Error(`${prefix}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
30
+ }
15
31
  /**
16
32
  * Load a SysProM document from a file (JSON or Markdown).
17
33
  * @param input - File path or directory to load from.
@@ -23,25 +39,60 @@ function detectFormat(input) {
23
39
  */
24
40
  export function loadDocument(input) {
25
41
  const path = resolve(input);
42
+ // Validate file/directory exists
43
+ try {
44
+ statSync(path);
45
+ }
46
+ catch (error) {
47
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
48
+ wrapError(`Document not found at ${path}. Create one first with init-document`, error);
49
+ }
50
+ throw error;
51
+ }
26
52
  const format = detectFormat(path);
27
53
  let doc;
28
54
  switch (format) {
29
55
  case "json": {
30
- const raw = JSON.parse(readFileSync(path, "utf8"));
31
- const result = SysProMDocument.safeParse(raw);
32
- if (!result.success) {
33
- throw new Error(`Invalid SysProM document:\n${result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n")}`);
56
+ try {
57
+ const content = readFileSync(path, "utf8");
58
+ let raw;
59
+ try {
60
+ raw = JSON.parse(content);
61
+ }
62
+ catch (error) {
63
+ wrapError("Failed to parse JSON", error);
64
+ }
65
+ const result = SysProMDocument.safeParse(raw);
66
+ if (!result.success) {
67
+ const issues = result.error.issues
68
+ .map((i) => ` ${i.path.join(".")}: ${i.message}`)
69
+ .join("\n");
70
+ throw new Error(`Invalid SysProM document:\n${issues}`);
71
+ }
72
+ doc = result.data;
73
+ }
74
+ catch (error) {
75
+ wrapError("Failed to load JSON document", error);
34
76
  }
35
- doc = result.data;
36
77
  break;
37
78
  }
38
79
  case "single-md": {
39
- const content = readFileSync(path, "utf8");
40
- doc = markdownSingleToJson(content);
80
+ try {
81
+ const content = readFileSync(path, "utf8");
82
+ doc = markdownSingleToJson(content);
83
+ }
84
+ catch (error) {
85
+ wrapError("Failed to load Markdown document", error);
86
+ }
41
87
  break;
42
88
  }
43
89
  case "multi-md": {
44
- doc = markdownMultiDocToJson(path);
90
+ try {
91
+ doc = markdownMultiDocToJson(path);
92
+ }
93
+ catch (error) {
94
+ wrapError("Failed to load Markdown documents", error);
95
+ }
45
96
  break;
46
97
  }
47
98
  }
@@ -5,11 +5,76 @@ import * as z from "zod";
5
5
  import { loadDocument, saveDocument } from "../io.js";
6
6
  import { NodeType, RelationshipType } from "../schema.js";
7
7
  import { validateOp, statsOp, queryNodesOp, queryNodeOp, queryRelationshipsOp, traceFromNodeOp, addNodeOp, removeNodeOp, updateNodeOp, addRelationshipOp, removeRelationshipOp, nextIdOp, inferCompletenessOp, inferLifecycleOp, inferImpactOp, impactSummaryOp, inferDerivedOp, } from "../operations/index.js";
8
+ /**
9
+ * Wrap an error with a descriptive prefix and attach the original as cause.
10
+ * @param prefix - The error prefix (e.g., "Failed to add node")
11
+ * @param error - The caught error
12
+ * @example
13
+ * ```ts
14
+ * try {
15
+ * someOperation();
16
+ * } catch (error) {
17
+ * wrapError("Failed to do X", error);
18
+ * }
19
+ * ```
20
+ */
21
+ function wrapError(prefix, error) {
22
+ throw new Error(`${prefix}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
23
+ }
8
24
  // Create MCP server instance
9
25
  const server = new McpServer({
10
26
  name: "sysprom-mcp",
11
27
  version: "1.0.0",
12
28
  });
29
+ // Register init-document tool
30
+ server.registerTool("init-document", {
31
+ description: "Initialise a new SysProM document with metadata and empty structure",
32
+ inputSchema: z.object({
33
+ path: z
34
+ .string()
35
+ .describe("Output path for the document (must end in .json)"),
36
+ title: z.string().describe("Document title"),
37
+ description: z.string().optional().describe("Document description"),
38
+ scope: z.string().optional().describe("Document scope"),
39
+ }),
40
+ }, ({ path, title, description, scope }) => {
41
+ try {
42
+ const now = new Date().toISOString().split("T")[0];
43
+ const doc = {
44
+ $schema: "https://sysprom.org/schema.json",
45
+ metadata: {
46
+ title,
47
+ ...(description && { description }),
48
+ ...(scope && { scope }),
49
+ version: "1.0.0",
50
+ created: now,
51
+ },
52
+ nodes: [],
53
+ relationships: [],
54
+ };
55
+ try {
56
+ saveDocument(doc, "json", path);
57
+ }
58
+ catch (error) {
59
+ wrapError("Failed to save document", error);
60
+ }
61
+ return {
62
+ content: [
63
+ {
64
+ type: "text",
65
+ text: JSON.stringify({
66
+ message: "Document initialised",
67
+ path,
68
+ title,
69
+ }, null, 2),
70
+ },
71
+ ],
72
+ };
73
+ }
74
+ catch (error) {
75
+ wrapError("init-document", error);
76
+ }
77
+ });
13
78
  // Register validate tool
14
79
  server.registerTool("validate", {
15
80
  description: "Validate a SysProM document and return any validation issues",
@@ -145,34 +210,50 @@ server.registerTool("add-node", {
145
210
  description: z.string().optional().describe("Node description"),
146
211
  }),
147
212
  }, ({ path, type, id, name, description }) => {
148
- const loaded = loadDocument(path);
149
- const nodeType = NodeType.safeParse(type);
150
- if (!nodeType.success) {
151
- throw new Error(`Invalid node type: "${type}". Valid types: ${NodeType.options.join(", ")}`);
152
- }
153
- const nodeId = id ?? nextIdOp({ doc: loaded.doc, type: nodeType.data });
154
- const updated = addNodeOp({
155
- doc: loaded.doc,
156
- node: {
157
- id: nodeId,
158
- type: nodeType.data,
159
- name,
160
- ...(description && { description }),
161
- },
162
- });
163
- saveDocument(updated, loaded.format, loaded.path);
164
- return {
165
- content: [
166
- {
167
- type: "text",
168
- text: JSON.stringify({
169
- message: "Node added",
213
+ try {
214
+ const loaded = loadDocument(path);
215
+ const nodeType = NodeType.safeParse(type);
216
+ if (!nodeType.success) {
217
+ throw new Error(`Invalid node type: "${type}". Valid types: ${NodeType.options.join(", ")}`);
218
+ }
219
+ const nodeId = id ?? nextIdOp({ doc: loaded.doc, type: nodeType.data });
220
+ let updated;
221
+ try {
222
+ updated = addNodeOp({
223
+ doc: loaded.doc,
224
+ node: {
170
225
  id: nodeId,
171
- nodeCount: updated.nodes.length,
172
- }, null, 2),
173
- },
174
- ],
175
- };
226
+ type: nodeType.data,
227
+ name,
228
+ ...(description && { description }),
229
+ },
230
+ });
231
+ }
232
+ catch (error) {
233
+ wrapError("Failed to add node", error);
234
+ }
235
+ try {
236
+ saveDocument(updated, loaded.format, loaded.path);
237
+ }
238
+ catch (error) {
239
+ throw new Error(`Failed to save document: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
240
+ }
241
+ return {
242
+ content: [
243
+ {
244
+ type: "text",
245
+ text: JSON.stringify({
246
+ message: "Node added",
247
+ id: nodeId,
248
+ nodeCount: updated.nodes.length,
249
+ }, null, 2),
250
+ },
251
+ ],
252
+ };
253
+ }
254
+ catch (error) {
255
+ wrapError("add-node", error);
256
+ }
176
257
  });
177
258
  // Register remove-node tool
178
259
  server.registerTool("remove-node", {
@@ -182,21 +263,37 @@ server.registerTool("remove-node", {
182
263
  id: z.string().describe("Node ID"),
183
264
  }),
184
265
  }, ({ path, id }) => {
185
- const loaded = loadDocument(path);
186
- const result = removeNodeOp({ doc: loaded.doc, id });
187
- saveDocument(result.doc, loaded.format, loaded.path);
188
- return {
189
- content: [
190
- {
191
- type: "text",
192
- text: JSON.stringify({
193
- message: `Node ${id} removed`,
194
- nodeCount: result.doc.nodes.length,
195
- warnings: result.warnings,
196
- }, null, 2),
197
- },
198
- ],
199
- };
266
+ try {
267
+ const loaded = loadDocument(path);
268
+ let result;
269
+ try {
270
+ result = removeNodeOp({ doc: loaded.doc, id });
271
+ }
272
+ catch (error) {
273
+ wrapError("Failed to remove node", error);
274
+ }
275
+ try {
276
+ saveDocument(result.doc, loaded.format, loaded.path);
277
+ }
278
+ catch (error) {
279
+ throw new Error(`Failed to save document: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
280
+ }
281
+ return {
282
+ content: [
283
+ {
284
+ type: "text",
285
+ text: JSON.stringify({
286
+ message: `Node ${id} removed`,
287
+ nodeCount: result.doc.nodes.length,
288
+ warnings: result.warnings,
289
+ }, null, 2),
290
+ },
291
+ ],
292
+ };
293
+ }
294
+ catch (error) {
295
+ wrapError("remove-node", error);
296
+ }
200
297
  });
201
298
  // Register update-node tool
202
299
  server.registerTool("update-node", {
@@ -207,11 +304,10 @@ server.registerTool("update-node", {
207
304
  fields: z.record(z.string(), z.unknown()).describe("Fields to update"),
208
305
  }),
209
306
  }, ({ path, id, fields }) => {
210
- const loaded = loadDocument(path);
211
- // Validate fields are valid node property updates
212
- const validFields = Object.entries(fields).reduce((acc, [key, value]) => {
213
- // Allow common node fields; unknown fields are silently ignored
214
- if ([
307
+ try {
308
+ const loaded = loadDocument(path);
309
+ // Track which fields are valid
310
+ const allowedFields = [
215
311
  "name",
216
312
  "description",
217
313
  "status",
@@ -227,26 +323,55 @@ server.registerTool("update-node", {
227
323
  "input",
228
324
  "output",
229
325
  "external_references",
230
- ].includes(key)) {
231
- acc[key] = value;
326
+ ];
327
+ const droppedFields = [];
328
+ const validFields = Object.entries(fields).reduce((acc, [key, value]) => {
329
+ if (allowedFields.includes(key)) {
330
+ acc[key] = value;
331
+ }
332
+ else {
333
+ droppedFields.push(key);
334
+ }
335
+ return acc;
336
+ }, {});
337
+ let updated;
338
+ try {
339
+ updated = updateNodeOp({
340
+ doc: loaded.doc,
341
+ id,
342
+ fields: validFields,
343
+ });
232
344
  }
233
- return acc;
234
- }, {});
235
- const updated = updateNodeOp({
236
- doc: loaded.doc,
237
- id,
238
- fields: validFields,
239
- });
240
- saveDocument(updated, loaded.format, loaded.path);
241
- const node = updated.nodes.find((n) => n.id === id);
242
- return {
243
- content: [
244
- {
245
- type: "text",
246
- text: JSON.stringify({ message: "Node updated", node }, null, 2),
247
- },
248
- ],
249
- };
345
+ catch (error) {
346
+ wrapError("Failed to update node", error);
347
+ }
348
+ try {
349
+ saveDocument(updated, loaded.format, loaded.path);
350
+ }
351
+ catch (error) {
352
+ throw new Error(`Failed to save document: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
353
+ }
354
+ const node = updated.nodes.find((n) => n.id === id);
355
+ return {
356
+ content: [
357
+ {
358
+ type: "text",
359
+ text: JSON.stringify({
360
+ message: "Node updated",
361
+ node,
362
+ ...(droppedFields.length > 0 && {
363
+ warnings: [
364
+ `These fields are not updateable and were ignored: ${droppedFields.join(", ")}`,
365
+ ],
366
+ }),
367
+ }, null, 2),
368
+ },
369
+ ],
370
+ };
371
+ }
372
+ catch (error) {
373
+ wrapError("update-node", error);
374
+ }
250
375
  });
251
376
  // Register add-relationship tool
252
377
  server.registerTool("add-relationship", {
@@ -258,31 +383,47 @@ server.registerTool("add-relationship", {
258
383
  type: z.string().describe("Relationship type"),
259
384
  }),
260
385
  }, ({ path, from, to, type }) => {
261
- const loaded = loadDocument(path);
262
- const relType = RelationshipType.safeParse(type);
263
- if (!relType.success) {
264
- throw new Error(`Invalid relationship type: "${type}". Valid types: ${RelationshipType.options.join(", ")}`);
386
+ try {
387
+ const loaded = loadDocument(path);
388
+ const relType = RelationshipType.safeParse(type);
389
+ if (!relType.success) {
390
+ throw new Error(`Invalid relationship type: "${type}". Valid types: ${RelationshipType.options.join(", ")}`);
391
+ }
392
+ let updated;
393
+ try {
394
+ updated = addRelationshipOp({
395
+ doc: loaded.doc,
396
+ rel: {
397
+ from,
398
+ to,
399
+ type: relType.data,
400
+ },
401
+ });
402
+ }
403
+ catch (error) {
404
+ wrapError("Failed to add relationship", error);
405
+ }
406
+ try {
407
+ saveDocument(updated, loaded.format, loaded.path);
408
+ }
409
+ catch (error) {
410
+ throw new Error(`Failed to save document: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
411
+ }
412
+ return {
413
+ content: [
414
+ {
415
+ type: "text",
416
+ text: JSON.stringify({
417
+ message: "Relationship added",
418
+ relationshipCount: (updated.relationships ?? []).length,
419
+ }, null, 2),
420
+ },
421
+ ],
422
+ };
423
+ }
424
+ catch (error) {
425
+ wrapError("add-relationship", error);
265
426
  }
266
- const updated = addRelationshipOp({
267
- doc: loaded.doc,
268
- rel: {
269
- from,
270
- to,
271
- type: relType.data,
272
- },
273
- });
274
- saveDocument(updated, loaded.format, loaded.path);
275
- return {
276
- content: [
277
- {
278
- type: "text",
279
- text: JSON.stringify({
280
- message: "Relationship added",
281
- relationshipCount: (updated.relationships ?? []).length,
282
- }, null, 2),
283
- },
284
- ],
285
- };
286
427
  });
287
428
  // Register remove-relationship tool
288
429
  server.registerTool("remove-relationship", {
@@ -294,29 +435,45 @@ server.registerTool("remove-relationship", {
294
435
  type: z.string().describe("Relationship type"),
295
436
  }),
296
437
  }, ({ path, from, to, type }) => {
297
- const loaded = loadDocument(path);
298
- const relType = RelationshipType.safeParse(type);
299
- if (!relType.success) {
300
- throw new Error(`Invalid relationship type: "${type}". Valid types: ${RelationshipType.options.join(", ")}`);
438
+ try {
439
+ const loaded = loadDocument(path);
440
+ const relType = RelationshipType.safeParse(type);
441
+ if (!relType.success) {
442
+ throw new Error(`Invalid relationship type: "${type}". Valid types: ${RelationshipType.options.join(", ")}`);
443
+ }
444
+ let result;
445
+ try {
446
+ result = removeRelationshipOp({
447
+ doc: loaded.doc,
448
+ from,
449
+ to,
450
+ type: relType.data,
451
+ });
452
+ }
453
+ catch (error) {
454
+ wrapError("Failed to remove relationship", error);
455
+ }
456
+ try {
457
+ saveDocument(result.doc, loaded.format, loaded.path);
458
+ }
459
+ catch (error) {
460
+ throw new Error(`Failed to save document: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
461
+ }
462
+ return {
463
+ content: [
464
+ {
465
+ type: "text",
466
+ text: JSON.stringify({
467
+ message: "Relationship removed",
468
+ relationshipCount: (result.doc.relationships ?? []).length,
469
+ }, null, 2),
470
+ },
471
+ ],
472
+ };
473
+ }
474
+ catch (error) {
475
+ wrapError("remove-relationship", error);
301
476
  }
302
- const result = removeRelationshipOp({
303
- doc: loaded.doc,
304
- from,
305
- to,
306
- type: relType.data,
307
- });
308
- saveDocument(result.doc, loaded.format, loaded.path);
309
- return {
310
- content: [
311
- {
312
- type: "text",
313
- text: JSON.stringify({
314
- message: "Relationship removed",
315
- relationshipCount: (result.doc.relationships ?? []).length,
316
- }, null, 2),
317
- },
318
- ],
319
- };
320
477
  });
321
478
  // Register infer-completeness tool
322
479
  server.registerTool("infer-completeness", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sysprom",
3
- "version": "1.16.1",
3
+ "version": "1.17.0",
4
4
  "description": "SysProM — System Provenance Model CLI and library",
5
5
  "author": "ExaDev",
6
6
  "homepage": "https://exadev.github.io/SysProM",