sysprom 1.13.2 → 1.15.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.
@@ -22,6 +22,58 @@ function renderFrontMatter(fields) {
22
22
  lines.push("---");
23
23
  return lines.join("\n");
24
24
  }
25
+ // ---------------------------------------------------------------------------
26
+ // Node location map (for hyperlinking)
27
+ // ---------------------------------------------------------------------------
28
+ /** GitHub-compatible heading anchor slug. */
29
+ function slugify(text) {
30
+ return text
31
+ .toLowerCase()
32
+ .replace(/[^\w\s-]/g, "")
33
+ .replace(/\s/g, "-");
34
+ }
35
+ /** Heading anchor for a node: `id--name` slugified from `### ID — Name`. */
36
+ function nodeAnchor(n) {
37
+ return slugify(`${n.id} — ${n.name}`);
38
+ }
39
+ /** Build a map from node ID to its markdown file and heading anchor. */
40
+ function buildNodeLocationMap(nodes, mode) {
41
+ const map = new Map();
42
+ for (const n of nodes) {
43
+ const anchor = nodeAnchor(n);
44
+ if (mode === "single-file") {
45
+ map.set(n.id, { file: "", anchor });
46
+ }
47
+ else {
48
+ const file = fileForNodeType(n.type);
49
+ map.set(n.id, { file, anchor });
50
+ }
51
+ }
52
+ return map;
53
+ }
54
+ /** Determine the markdown file a node type belongs to in multi-doc mode. */
55
+ function fileForNodeType(type) {
56
+ for (const [fileName, types] of Object.entries(NODE_FILE_MAP)) {
57
+ if (types.includes(type))
58
+ return `${fileName}.md`;
59
+ }
60
+ return "README.md";
61
+ }
62
+ /**
63
+ * Format a node ID as a markdown hyperlink.
64
+ * In single-file mode: `[ID](#anchor)`
65
+ * In multi-doc mode: `[ID](./FILE.md#anchor)`
66
+ * Falls back to plain ID if the node isn't in the map.
67
+ */
68
+ function linkNodeId(id, nodeMap, currentFile) {
69
+ const loc = nodeMap.get(id);
70
+ if (!loc)
71
+ return id;
72
+ if (loc.file === "" || loc.file === currentFile) {
73
+ return `[${id}](#${loc.anchor})`;
74
+ }
75
+ return `[${id}](./${loc.file}#${loc.anchor})`;
76
+ }
25
77
  function indexRelationshipsFrom(rels) {
26
78
  const idx = new Map();
27
79
  for (const r of rels) {
@@ -79,7 +131,7 @@ function renderLifecycle(lifecycle) {
79
131
  return `- [${checkbox}] ${label}`;
80
132
  });
81
133
  }
82
- function renderNodeRelationships(nodeId, fromIdx) {
134
+ function renderNodeRelationships(nodeId, fromIdx, nodeMap, currentFile) {
83
135
  const rels = fromIdx.get(nodeId);
84
136
  if (!rels || rels.length === 0)
85
137
  return [];
@@ -97,12 +149,12 @@ function renderNodeRelationships(nodeId, fromIdx) {
97
149
  ? RELATIONSHIP_TYPE_LABELS[type]
98
150
  : type;
99
151
  if (targets.length === 1) {
100
- lines.push(`- ${label}: ${targets[0]}`);
152
+ lines.push(`- ${label}: ${linkNodeId(targets[0], nodeMap, currentFile)}`);
101
153
  }
102
154
  else {
103
155
  lines.push(`- ${label}:`);
104
156
  for (const t of targets) {
105
- lines.push(` - ${t}`);
157
+ lines.push(` - ${linkNodeId(t, nodeMap, currentFile)}`);
106
158
  }
107
159
  }
108
160
  }
@@ -123,7 +175,7 @@ function renderExternalReferences(refs) {
123
175
  }
124
176
  return lines;
125
177
  }
126
- function renderNode(n, headingLevel, fromIdx) {
178
+ function renderNode(n, headingLevel, fromIdx, nodeMap, currentFile) {
127
179
  const prefix = "#".repeat(headingLevel);
128
180
  const lines = [];
129
181
  lines.push(`${prefix} ${n.id} — ${n.name}`);
@@ -132,7 +184,7 @@ function renderNode(n, headingLevel, fromIdx) {
132
184
  lines.push(renderText(n.description));
133
185
  lines.push("");
134
186
  }
135
- const rels = renderNodeRelationships(n.id, fromIdx);
187
+ const rels = renderNodeRelationships(n.id, fromIdx, nodeMap, currentFile);
136
188
  if (rels.length > 0) {
137
189
  lines.push(...rels);
138
190
  lines.push("");
@@ -207,7 +259,7 @@ function renderNode(n, headingLevel, fromIdx) {
207
259
  if (n.includes && n.includes.length > 0) {
208
260
  lines.push("Includes:");
209
261
  for (const inc of n.includes) {
210
- lines.push(`- ${inc}`);
262
+ lines.push(`- ${linkNodeId(inc, nodeMap, currentFile)}`);
211
263
  }
212
264
  lines.push("");
213
265
  }
@@ -233,8 +285,9 @@ function renderNode(n, headingLevel, fromIdx) {
233
285
  const subNodes = n.subsystem.nodes;
234
286
  const subRels = n.subsystem.relationships ?? [];
235
287
  const subIdx = indexRelationshipsFrom(subRels);
288
+ const subMap = buildNodeLocationMap(subNodes, "single-file");
236
289
  for (const sub of subNodes) {
237
- lines.push(...renderNode(sub, headingLevel + 2, subIdx));
290
+ lines.push(...renderNode(sub, headingLevel + 2, subIdx, subMap));
238
291
  }
239
292
  }
240
293
  return lines;
@@ -242,7 +295,7 @@ function renderNode(n, headingLevel, fromIdx) {
242
295
  // ---------------------------------------------------------------------------
243
296
  // File generators
244
297
  // ---------------------------------------------------------------------------
245
- function renderNodesGrouped(nodes, types, fromIdx, headingLevel) {
298
+ function renderNodesGrouped(nodes, types, fromIdx, headingLevel, nodeMap, currentFile) {
246
299
  const lines = [];
247
300
  for (const type of types) {
248
301
  const matching = nodes.filter((n) => n.type === type);
@@ -252,12 +305,12 @@ function renderNodesGrouped(nodes, types, fromIdx, headingLevel) {
252
305
  lines.push(`${"#".repeat(headingLevel)} ${label}`);
253
306
  lines.push("");
254
307
  for (const n of matching) {
255
- lines.push(...renderNode(n, headingLevel + 1, fromIdx));
308
+ lines.push(...renderNode(n, headingLevel + 1, fromIdx, nodeMap, currentFile));
256
309
  }
257
310
  }
258
311
  return lines;
259
312
  }
260
- function generateReadme(doc, fromIdx) {
313
+ function generateReadme(doc, fromIdx, nodeMap) {
261
314
  const lines = [];
262
315
  const title = doc.metadata?.title ?? "SysProM";
263
316
  lines.push(renderFrontMatter({
@@ -329,7 +382,7 @@ function generateReadme(doc, fromIdx) {
329
382
  // Views
330
383
  const views = doc.nodes.filter((n) => n.type === "view");
331
384
  if (views.length > 0) {
332
- lines.push(...renderNodesGrouped(doc.nodes, ["view"], fromIdx, 2));
385
+ lines.push(...renderNodesGrouped(doc.nodes, ["view"], fromIdx, 2, nodeMap, "README.md"));
333
386
  }
334
387
  // Graph-level external references
335
388
  if (doc.external_references && doc.external_references.length > 0) {
@@ -347,7 +400,7 @@ function generateReadme(doc, fromIdx) {
347
400
  }
348
401
  return lines.join("\n") + "\n";
349
402
  }
350
- function generateDocFile(doc, fileName, types, fromIdx) {
403
+ function generateDocFile(doc, fileName, types, fromIdx, nodeMap) {
351
404
  const lines = [];
352
405
  lines.push(renderFrontMatter({
353
406
  title: fileName.replace(".md", ""),
@@ -356,7 +409,7 @@ function generateDocFile(doc, fileName, types, fromIdx) {
356
409
  lines.push("");
357
410
  lines.push(`# ${fileName.replace(".md", "")}`);
358
411
  lines.push("");
359
- lines.push(...renderNodesGrouped(doc.nodes, types, fromIdx, 2));
412
+ lines.push(...renderNodesGrouped(doc.nodes, types, fromIdx, 2, nodeMap, `${fileName}.md`));
360
413
  return lines.join("\n") + "\n";
361
414
  }
362
415
  /**
@@ -371,6 +424,7 @@ function generateDocFile(doc, fileName, types, fromIdx) {
371
424
  */
372
425
  export function jsonToMarkdownSingle(doc) {
373
426
  const fromIdx = indexRelationshipsFrom(doc.relationships ?? []);
427
+ const nodeMap = buildNodeLocationMap(doc.nodes, "single-file");
374
428
  const lines = [];
375
429
  const title = doc.metadata?.title ?? "SysProM";
376
430
  lines.push(renderFrontMatter({
@@ -394,7 +448,7 @@ export function jsonToMarkdownSingle(doc) {
394
448
  "milestone",
395
449
  "version",
396
450
  ];
397
- lines.push(...renderNodesGrouped(doc.nodes, allTypes, fromIdx, 2));
451
+ lines.push(...renderNodesGrouped(doc.nodes, allTypes, fromIdx, 2, nodeMap));
398
452
  // Relationships summary
399
453
  if (doc.relationships && doc.relationships.length > 0) {
400
454
  lines.push("## Relationships");
@@ -433,12 +487,13 @@ export function jsonToMarkdownSingle(doc) {
433
487
  export function jsonToMarkdownMultiDoc(doc, outDir) {
434
488
  mkdirSync(outDir, { recursive: true });
435
489
  const fromIdx = indexRelationshipsFrom(doc.relationships ?? []);
436
- writeFileSync(join(outDir, "README.md"), generateReadme(doc, fromIdx));
490
+ const nodeMap = buildNodeLocationMap(doc.nodes, "multi-doc");
491
+ writeFileSync(join(outDir, "README.md"), generateReadme(doc, fromIdx, nodeMap));
437
492
  for (const [fileName, types] of Object.entries(NODE_FILE_MAP)) {
438
493
  const hasNodes = doc.nodes.some((n) => types.includes(n.type));
439
494
  if (!hasNodes)
440
495
  continue;
441
- writeFileSync(join(outDir, `${fileName}.md`), generateDocFile(doc, fileName, types, fromIdx));
496
+ writeFileSync(join(outDir, `${fileName}.md`), generateDocFile(doc, fileName, types, fromIdx, nodeMap));
442
497
  }
443
498
  // Subsystem folders or single files
444
499
  const subsystemNodes = doc.nodes.filter((n) => n.subsystem);
@@ -2,9 +2,9 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import * as z from "zod";
5
- import { loadDocument } from "../io.js";
5
+ import { loadDocument, saveDocument } from "../io.js";
6
6
  import { NodeType, RelationshipType } from "../schema.js";
7
- import { validateOp, statsOp, queryNodesOp, queryNodeOp, queryRelationshipsOp, traceFromNodeOp, addNodeOp, removeNodeOp, updateNodeOp, addRelationshipOp, removeRelationshipOp, nextIdOp, } from "../operations/index.js";
7
+ import { validateOp, statsOp, queryNodesOp, queryNodeOp, queryRelationshipsOp, traceFromNodeOp, addNodeOp, removeNodeOp, updateNodeOp, addRelationshipOp, removeRelationshipOp, nextIdOp, inferCompletenessOp, inferLifecycleOp, inferImpactOp, inferDerivedOp, } from "../operations/index.js";
8
8
  // Create MCP server instance
9
9
  const server = new McpServer({
10
10
  name: "sysprom-mcp",
@@ -145,14 +145,14 @@ server.registerTool("add-node", {
145
145
  description: z.string().optional().describe("Node description"),
146
146
  }),
147
147
  }, ({ path, type, id, name, description }) => {
148
- const { doc } = loadDocument(path);
148
+ const loaded = loadDocument(path);
149
149
  const nodeType = NodeType.safeParse(type);
150
150
  if (!nodeType.success) {
151
151
  throw new Error(`Invalid node type: "${type}". Valid types: ${NodeType.options.join(", ")}`);
152
152
  }
153
- const nodeId = id ?? nextIdOp({ doc, type: nodeType.data });
153
+ const nodeId = id ?? nextIdOp({ doc: loaded.doc, type: nodeType.data });
154
154
  const updated = addNodeOp({
155
- doc,
155
+ doc: loaded.doc,
156
156
  node: {
157
157
  id: nodeId,
158
158
  type: nodeType.data,
@@ -160,6 +160,7 @@ server.registerTool("add-node", {
160
160
  ...(description && { description }),
161
161
  },
162
162
  });
163
+ saveDocument(updated, loaded.format, loaded.path);
163
164
  return {
164
165
  content: [
165
166
  {
@@ -181,8 +182,9 @@ server.registerTool("remove-node", {
181
182
  id: z.string().describe("Node ID"),
182
183
  }),
183
184
  }, ({ path, id }) => {
184
- const { doc } = loadDocument(path);
185
- const result = removeNodeOp({ doc, id });
185
+ const loaded = loadDocument(path);
186
+ const result = removeNodeOp({ doc: loaded.doc, id });
187
+ saveDocument(result.doc, loaded.format, loaded.path);
186
188
  return {
187
189
  content: [
188
190
  {
@@ -205,7 +207,7 @@ server.registerTool("update-node", {
205
207
  fields: z.record(z.string(), z.unknown()).describe("Fields to update"),
206
208
  }),
207
209
  }, ({ path, id, fields }) => {
208
- const { doc } = loadDocument(path);
210
+ const loaded = loadDocument(path);
209
211
  // Validate fields are valid node property updates
210
212
  const validFields = Object.entries(fields).reduce((acc, [key, value]) => {
211
213
  // Allow common node fields; unknown fields are silently ignored
@@ -231,10 +233,11 @@ server.registerTool("update-node", {
231
233
  return acc;
232
234
  }, {});
233
235
  const updated = updateNodeOp({
234
- doc,
236
+ doc: loaded.doc,
235
237
  id,
236
238
  fields: validFields,
237
239
  });
240
+ saveDocument(updated, loaded.format, loaded.path);
238
241
  const node = updated.nodes.find((n) => n.id === id);
239
242
  return {
240
243
  content: [
@@ -255,19 +258,20 @@ server.registerTool("add-relationship", {
255
258
  type: z.string().describe("Relationship type"),
256
259
  }),
257
260
  }, ({ path, from, to, type }) => {
258
- const { doc } = loadDocument(path);
261
+ const loaded = loadDocument(path);
259
262
  const relType = RelationshipType.safeParse(type);
260
263
  if (!relType.success) {
261
264
  throw new Error(`Invalid relationship type: "${type}". Valid types: ${RelationshipType.options.join(", ")}`);
262
265
  }
263
266
  const updated = addRelationshipOp({
264
- doc,
267
+ doc: loaded.doc,
265
268
  rel: {
266
269
  from,
267
270
  to,
268
271
  type: relType.data,
269
272
  },
270
273
  });
274
+ saveDocument(updated, loaded.format, loaded.path);
271
275
  return {
272
276
  content: [
273
277
  {
@@ -290,17 +294,18 @@ server.registerTool("remove-relationship", {
290
294
  type: z.string().describe("Relationship type"),
291
295
  }),
292
296
  }, ({ path, from, to, type }) => {
293
- const { doc } = loadDocument(path);
297
+ const loaded = loadDocument(path);
294
298
  const relType = RelationshipType.safeParse(type);
295
299
  if (!relType.success) {
296
300
  throw new Error(`Invalid relationship type: "${type}". Valid types: ${RelationshipType.options.join(", ")}`);
297
301
  }
298
302
  const result = removeRelationshipOp({
299
- doc,
303
+ doc: loaded.doc,
300
304
  from,
301
305
  to,
302
306
  type: relType.data,
303
307
  });
308
+ saveDocument(result.doc, loaded.format, loaded.path);
304
309
  return {
305
310
  content: [
306
311
  {
@@ -313,6 +318,79 @@ server.registerTool("remove-relationship", {
313
318
  ],
314
319
  };
315
320
  });
321
+ // Register infer-completeness tool
322
+ server.registerTool("infer-completeness", {
323
+ description: "Infer completeness of nodes based on expected refinement relationships",
324
+ inputSchema: z.object({
325
+ path: z.string().describe("Path to SysProM file"),
326
+ }),
327
+ }, ({ path }) => {
328
+ const { doc } = loadDocument(path);
329
+ const result = inferCompletenessOp({ doc });
330
+ return {
331
+ content: [
332
+ {
333
+ type: "text",
334
+ text: JSON.stringify(result, null, 2),
335
+ },
336
+ ],
337
+ };
338
+ });
339
+ // Register infer-lifecycle tool
340
+ server.registerTool("infer-lifecycle", {
341
+ description: "Infer lifecycle state for nodes based on status and lifecycle fields",
342
+ inputSchema: z.object({
343
+ path: z.string().describe("Path to SysProM file"),
344
+ }),
345
+ }, ({ path }) => {
346
+ const { doc } = loadDocument(path);
347
+ const result = inferLifecycleOp({ doc });
348
+ return {
349
+ content: [
350
+ {
351
+ type: "text",
352
+ text: JSON.stringify(result, null, 2),
353
+ },
354
+ ],
355
+ };
356
+ });
357
+ // Register infer-impact tool
358
+ server.registerTool("infer-impact", {
359
+ description: "Infer impact from a node through the graph following impact relationships",
360
+ inputSchema: z.object({
361
+ path: z.string().describe("Path to SysProM file"),
362
+ startId: z.string().describe("Node ID to start impact analysis from"),
363
+ }),
364
+ }, ({ path, startId }) => {
365
+ const { doc } = loadDocument(path);
366
+ const result = inferImpactOp({ doc, startId });
367
+ return {
368
+ content: [
369
+ {
370
+ type: "text",
371
+ text: JSON.stringify(result, null, 2),
372
+ },
373
+ ],
374
+ };
375
+ });
376
+ // Register infer-derived tool
377
+ server.registerTool("infer-derived", {
378
+ description: "Infer derived relationships from transitive closure, inverses, and composites",
379
+ inputSchema: z.object({
380
+ path: z.string().describe("Path to SysProM file"),
381
+ }),
382
+ }, ({ path }) => {
383
+ const { doc } = loadDocument(path);
384
+ const result = inferDerivedOp({ doc });
385
+ return {
386
+ content: [
387
+ {
388
+ type: "text",
389
+ text: JSON.stringify(result, null, 2),
390
+ },
391
+ ],
392
+ };
393
+ });
316
394
  // Start server
317
395
  async function main() {
318
396
  const transport = new StdioServerTransport();
@@ -2,6 +2,10 @@ import * as z from "zod";
2
2
  import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
3
3
  import { join, basename } from "node:path";
4
4
  import { NODE_FILE_MAP, NODE_LABEL_TO_TYPE, RELATIONSHIP_TYPE_LABELS, RELATIONSHIP_LABEL_TO_TYPE, NodeType, RelationshipType, NodeStatus, ExternalReferenceRole, } from "./schema.js";
5
+ /** Strip markdown link syntax `[text](url)` → `text`. */
6
+ function stripMarkdownLink(s) {
7
+ return s.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
8
+ }
5
9
  const LABEL_TO_TYPE = Object.fromEntries(Object.entries(NODE_LABEL_TO_TYPE).map(([k, v]) => [k.toLowerCase(), v]));
6
10
  const operationType = z.enum(["add", "update", "remove", "link"]);
7
11
  function parseNodeType(s) {
@@ -162,18 +166,18 @@ function parseListItems(body, prefix) {
162
166
  collecting = true;
163
167
  const inline = line.slice(prefix.length + 1).trim();
164
168
  if (inline) {
165
- items.push(inline);
169
+ items.push(stripMarkdownLink(inline));
166
170
  collecting = false;
167
171
  }
168
172
  continue;
169
173
  }
170
174
  if (collecting && line.startsWith(" - ")) {
171
- items.push(line.slice(4));
175
+ items.push(stripMarkdownLink(line.slice(4)));
172
176
  }
173
177
  else if (collecting &&
174
178
  line.startsWith("- ") &&
175
179
  !isRelationshipLabel(line)) {
176
- items.push(line.slice(2));
180
+ items.push(stripMarkdownLink(line.slice(2)));
177
181
  }
178
182
  else if (collecting) {
179
183
  collecting = false;
@@ -210,7 +214,7 @@ function parseRelationshipsFromBody(body, nodeId) {
210
214
  if (items.length === 0) {
211
215
  const val = parseSingleValue(body, `- ${label}`);
212
216
  if (val) {
213
- rels.push({ from: nodeId, to: val, type: relType });
217
+ rels.push({ from: nodeId, to: stripMarkdownLink(val), type: relType });
214
218
  }
215
219
  }
216
220
  else {
@@ -37,3 +37,7 @@ export { speckitImportOp } from "./speckit-import.js";
37
37
  export { speckitExportOp } from "./speckit-export.js";
38
38
  export { speckitSyncOp, type SyncResult } from "./speckit-sync.js";
39
39
  export { speckitDiffOp, type DiffResult } from "./speckit-diff.js";
40
+ export { inferCompletenessOp, type CompletenessResult, type CompletenessOutput, } from "./infer-completeness.js";
41
+ export { inferLifecycleOp, type LifecycleResult, type LifecycleOutput, } from "./infer-lifecycle.js";
42
+ export { inferImpactOp, type ImpactNode, type ImpactOutput, } from "./infer-impact.js";
43
+ export { inferDerivedOp, type DerivedRelationship, type DerivedOutput, } from "./infer-derived.js";
@@ -45,3 +45,8 @@ export { speckitImportOp } from "./speckit-import.js";
45
45
  export { speckitExportOp } from "./speckit-export.js";
46
46
  export { speckitSyncOp } from "./speckit-sync.js";
47
47
  export { speckitDiffOp } from "./speckit-diff.js";
48
+ // Inference operations
49
+ export { inferCompletenessOp, } from "./infer-completeness.js";
50
+ export { inferLifecycleOp, } from "./infer-lifecycle.js";
51
+ export { inferImpactOp, } from "./infer-impact.js";
52
+ export { inferDerivedOp, } from "./infer-derived.js";