@doquflow/server 1.3.0 → 1.4.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.
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildGraph = buildGraph;
4
+ const list_wiki_1 = require("./list-wiki");
5
+ const SEP = "\u0000";
6
+ async function buildGraph(input) {
7
+ const generatedAt = new Date().toISOString();
8
+ try {
9
+ const wiki = await (0, list_wiki_1.listWiki)({ project_path: input.project_path });
10
+ if (wiki.error) {
11
+ return {
12
+ nodes: [],
13
+ edges: [],
14
+ meta: {
15
+ total_pages: 0, total_edges: 0, orphans: 0, isolated: 0,
16
+ dangling_refs: [], generated_at: generatedAt,
17
+ },
18
+ error: wiki.error,
19
+ };
20
+ }
21
+ const pages = wiki.pages;
22
+ const nodeIds = new Set(pages.map((p) => p.id));
23
+ const edgeMap = new Map();
24
+ const dangling = new Set();
25
+ // Pass 1: outbound
26
+ for (const page of pages) {
27
+ const src = page.id;
28
+ for (const tgt of page.outbound_links ?? []) {
29
+ if (tgt === src)
30
+ continue;
31
+ if (!nodeIds.has(tgt)) {
32
+ dangling.add(tgt);
33
+ continue;
34
+ }
35
+ const key = `${src}${SEP}${tgt}`;
36
+ if (!edgeMap.has(key)) {
37
+ edgeMap.set(key, { source: src, target: tgt, kind: "outbound" });
38
+ }
39
+ }
40
+ }
41
+ // Pass 2: inbound — `n.inbound_links` lists pages that point AT n.
42
+ for (const page of pages) {
43
+ const tgt = page.id;
44
+ for (const src of page.inbound_links ?? []) {
45
+ if (src === tgt)
46
+ continue;
47
+ if (!nodeIds.has(src)) {
48
+ dangling.add(src);
49
+ continue;
50
+ }
51
+ const key = `${src}${SEP}${tgt}`;
52
+ const existing = edgeMap.get(key);
53
+ if (existing) {
54
+ if (existing.kind === "outbound")
55
+ existing.kind = "both";
56
+ // already 'inbound' or 'both' → keep
57
+ }
58
+ else {
59
+ edgeMap.set(key, { source: src, target: tgt, kind: "inbound" });
60
+ }
61
+ }
62
+ }
63
+ const edges = Array.from(edgeMap.values());
64
+ // Compute resolved in/out degree from final edge list.
65
+ const inDeg = new Map();
66
+ const outDeg = new Map();
67
+ for (const e of edges) {
68
+ outDeg.set(e.source, (outDeg.get(e.source) ?? 0) + 1);
69
+ inDeg.set(e.target, (inDeg.get(e.target) ?? 0) + 1);
70
+ }
71
+ const nodes = pages.map((p) => {
72
+ const out_degree = outDeg.get(p.id) ?? 0;
73
+ const in_degree = inDeg.get(p.id) ?? 0;
74
+ return {
75
+ id: p.id,
76
+ title: p.title,
77
+ category: p.category,
78
+ degree: in_degree + out_degree,
79
+ in_degree,
80
+ out_degree,
81
+ stale: p.stale,
82
+ updated_at: p.updated_at,
83
+ };
84
+ });
85
+ const orphans = nodes.filter((n) => n.in_degree === 0).length;
86
+ const isolated = nodes.filter((n) => n.degree === 0).length;
87
+ return {
88
+ nodes,
89
+ edges,
90
+ meta: {
91
+ total_pages: nodes.length,
92
+ total_edges: edges.length,
93
+ orphans,
94
+ isolated,
95
+ dangling_refs: Array.from(dangling).sort(),
96
+ generated_at: generatedAt,
97
+ },
98
+ };
99
+ }
100
+ catch (e) {
101
+ return {
102
+ nodes: [],
103
+ edges: [],
104
+ meta: {
105
+ total_pages: 0, total_edges: 0, orphans: 0, isolated: 0,
106
+ dangling_refs: [], generated_at: generatedAt,
107
+ },
108
+ error: e?.message ?? String(e),
109
+ };
110
+ }
111
+ }
@@ -20,34 +20,98 @@ const SINGULAR_TO_PLURAL = {
20
20
  synthesis: "syntheses",
21
21
  };
22
22
  const STALE_DAYS = 30;
23
+ function unquote(s) {
24
+ const t = s.trim();
25
+ if (t.length >= 2) {
26
+ const first = t[0];
27
+ const last = t[t.length - 1];
28
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
29
+ return t.slice(1, -1);
30
+ }
31
+ }
32
+ return t;
33
+ }
23
34
  /**
24
- * Parse frontmatter from markdown
35
+ * Parse YAML-ish frontmatter from markdown.
36
+ *
37
+ * Supports:
38
+ * - Inline JSON/flow: key: ["a", "b"] or key: {x: 1}
39
+ * - Block-style list: key:\n - "a"\n - "b"
40
+ * - Scalar: key: value (values may contain ':' — split on FIRST ':' only)
25
41
  */
26
42
  function parseFrontmatter(content) {
27
43
  const match = content.match(/^---\n([\s\S]*?)\n---/);
28
44
  if (!match)
29
45
  return {};
30
- const yaml = match[1];
46
+ const lines = match[1].split("\n");
31
47
  const result = {};
32
- for (const line of yaml.split("\n")) {
33
- if (!line.trim())
48
+ let i = 0;
49
+ while (i < lines.length) {
50
+ const line = lines[i];
51
+ const trimmed = line.trim();
52
+ if (!trimmed) {
53
+ i++;
54
+ continue;
55
+ }
56
+ // Block-list continuation handled inside the key branch below; top-level
57
+ // dashes without a preceding key are ignored.
58
+ if (trimmed.startsWith("- ")) {
59
+ i++;
60
+ continue;
61
+ }
62
+ const colonIdx = line.indexOf(":");
63
+ if (colonIdx === -1) {
64
+ i++;
34
65
  continue;
35
- const [key, ...valueParts] = line.split(":");
36
- const value = valueParts.join(":").trim();
37
- try {
38
- if (value.startsWith("[") || value.startsWith("{")) {
39
- result[key.trim()] = JSON.parse(value);
66
+ }
67
+ const key = line.slice(0, colonIdx).trim();
68
+ const rawValue = line.slice(colonIdx + 1).trim();
69
+ if (rawValue === "") {
70
+ // Possibly a block-style list on subsequent lines: " - value"
71
+ const items = [];
72
+ let j = i + 1;
73
+ while (j < lines.length) {
74
+ const next = lines[j];
75
+ if (!next.trim()) {
76
+ j++;
77
+ continue;
78
+ }
79
+ // A block-list item must be indented and start with "- ".
80
+ const m = next.match(/^(\s+)-\s+(.*)$/);
81
+ if (!m)
82
+ break;
83
+ items.push(unquote(m[2]));
84
+ j++;
85
+ }
86
+ if (items.length > 0) {
87
+ result[key] = items;
40
88
  }
41
89
  else {
42
- result[key.trim()] = value;
90
+ result[key] = "";
91
+ }
92
+ i = j;
93
+ continue;
94
+ }
95
+ if (rawValue.startsWith("[") || rawValue.startsWith("{")) {
96
+ try {
97
+ result[key] = JSON.parse(rawValue);
98
+ }
99
+ catch {
100
+ result[key] = rawValue;
43
101
  }
44
102
  }
45
- catch {
46
- result[key.trim()] = value;
103
+ else {
104
+ result[key] = unquote(rawValue);
47
105
  }
106
+ i++;
48
107
  }
49
108
  return result;
50
109
  }
110
+ function toStringArray(v) {
111
+ if (!Array.isArray(v))
112
+ return [];
113
+ return v.filter((x) => typeof x === "string");
114
+ }
51
115
  /**
52
116
  * Extract title from markdown
53
117
  */
@@ -89,6 +153,8 @@ async function listWiki(input) {
89
153
  const updatedAt = fm.updated_at ?? new Date().toISOString();
90
154
  const updatedMs = new Date(updatedAt).getTime();
91
155
  const stale = !isNaN(updatedMs) && (now - updatedMs) > STALE_DAYS * 86_400_000;
156
+ const outbound_links = toStringArray(fm.outbound_links);
157
+ const inbound_links = toStringArray(fm.inbound_links);
92
158
  pages.push({
93
159
  id: pageId,
94
160
  title,
@@ -96,9 +162,12 @@ async function listWiki(input) {
96
162
  path: node_path_1.default.relative(docuDir, filePath),
97
163
  created_at: fm.created_at ?? new Date().toISOString(),
98
164
  updated_at: updatedAt,
99
- sources: fm.sources ?? [],
100
- tags: fm.tags ?? [],
165
+ sources: toStringArray(fm.sources),
166
+ tags: toStringArray(fm.tags),
101
167
  stale,
168
+ outbound_links,
169
+ inbound_links,
170
+ degree: outbound_links.length + inbound_links.length,
102
171
  });
103
172
  categoryCount++;
104
173
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doquflow/server",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Docuflow MCP server — lets AI agents read codebases and persist living specs",
5
5
  "author": "Docuflow <hello@doquflows.dev>",
6
6
  "license": "MIT",