sysprom 1.0.0 → 1.0.6
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 +207 -0
- package/dist/schema.json +510 -0
- package/dist/src/canonical-json.d.ts +23 -0
- package/dist/src/canonical-json.js +120 -0
- package/dist/src/cli/commands/add.d.ts +22 -0
- package/dist/src/cli/commands/add.js +95 -0
- package/dist/src/cli/commands/check.d.ts +10 -0
- package/dist/src/cli/commands/check.js +33 -0
- package/dist/src/cli/commands/graph.d.ts +15 -0
- package/dist/src/cli/commands/graph.js +32 -0
- package/dist/src/cli/commands/init.d.ts +2 -0
- package/dist/src/cli/commands/init.js +44 -0
- package/dist/src/cli/commands/json2md.d.ts +2 -0
- package/dist/src/cli/commands/json2md.js +60 -0
- package/dist/src/cli/commands/md2json.d.ts +2 -0
- package/dist/src/cli/commands/md2json.js +29 -0
- package/dist/src/cli/commands/plan.d.ts +2 -0
- package/dist/src/cli/commands/plan.js +227 -0
- package/dist/src/cli/commands/query.d.ts +2 -0
- package/dist/src/cli/commands/query.js +275 -0
- package/dist/src/cli/commands/remove.d.ts +13 -0
- package/dist/src/cli/commands/remove.js +50 -0
- package/dist/src/cli/commands/rename.d.ts +14 -0
- package/dist/src/cli/commands/rename.js +34 -0
- package/dist/src/cli/commands/search.d.ts +11 -0
- package/dist/src/cli/commands/search.js +37 -0
- package/dist/src/cli/commands/speckit.d.ts +2 -0
- package/dist/src/cli/commands/speckit.js +318 -0
- package/dist/src/cli/commands/stats.d.ts +10 -0
- package/dist/src/cli/commands/stats.js +51 -0
- package/dist/src/cli/commands/task.d.ts +2 -0
- package/dist/src/cli/commands/task.js +162 -0
- package/dist/src/cli/commands/update.d.ts +2 -0
- package/dist/src/cli/commands/update.js +219 -0
- package/dist/src/cli/commands/validate.d.ts +10 -0
- package/dist/src/cli/commands/validate.js +30 -0
- package/dist/src/cli/define-command.d.ts +34 -0
- package/dist/src/cli/define-command.js +237 -0
- package/dist/src/cli/index.d.ts +2 -0
- package/dist/src/cli/index.js +3 -0
- package/dist/src/cli/program.d.ts +4 -0
- package/dist/src/cli/program.js +46 -0
- package/dist/src/cli/shared.d.ts +26 -0
- package/dist/src/cli/shared.js +41 -0
- package/dist/src/generate-schema.d.ts +1 -0
- package/dist/src/generate-schema.js +9 -0
- package/dist/src/index.d.ts +48 -0
- package/dist/src/index.js +99 -0
- package/dist/src/io.d.ts +22 -0
- package/dist/src/io.js +66 -0
- package/dist/src/json-to-md.d.ts +26 -0
- package/dist/src/json-to-md.js +498 -0
- package/dist/src/md-to-json.d.ts +22 -0
- package/dist/src/md-to-json.js +548 -0
- package/dist/src/operations/add-node.d.ts +887 -0
- package/dist/src/operations/add-node.js +21 -0
- package/dist/src/operations/add-plan-task.d.ts +594 -0
- package/dist/src/operations/add-plan-task.js +25 -0
- package/dist/src/operations/add-relationship.d.ts +635 -0
- package/dist/src/operations/add-relationship.js +25 -0
- package/dist/src/operations/check.d.ts +301 -0
- package/dist/src/operations/check.js +66 -0
- package/dist/src/operations/define-operation.d.ts +14 -0
- package/dist/src/operations/define-operation.js +21 -0
- package/dist/src/operations/graph.d.ts +303 -0
- package/dist/src/operations/graph.js +71 -0
- package/dist/src/operations/index.d.ts +38 -0
- package/dist/src/operations/index.js +45 -0
- package/dist/src/operations/init-document.d.ts +299 -0
- package/dist/src/operations/init-document.js +26 -0
- package/dist/src/operations/json-to-markdown.d.ts +298 -0
- package/dist/src/operations/json-to-markdown.js +13 -0
- package/dist/src/operations/mark-task-done.d.ts +594 -0
- package/dist/src/operations/mark-task-done.js +26 -0
- package/dist/src/operations/mark-task-undone.d.ts +594 -0
- package/dist/src/operations/mark-task-undone.js +26 -0
- package/dist/src/operations/markdown-to-json.d.ts +298 -0
- package/dist/src/operations/markdown-to-json.js +13 -0
- package/dist/src/operations/next-id.d.ts +322 -0
- package/dist/src/operations/next-id.js +29 -0
- package/dist/src/operations/node-history.d.ts +313 -0
- package/dist/src/operations/node-history.js +55 -0
- package/dist/src/operations/plan-add-task.d.ts +595 -0
- package/dist/src/operations/plan-add-task.js +18 -0
- package/dist/src/operations/plan-gate.d.ts +351 -0
- package/dist/src/operations/plan-gate.js +41 -0
- package/dist/src/operations/plan-init.d.ts +299 -0
- package/dist/src/operations/plan-init.js +17 -0
- package/dist/src/operations/plan-progress.d.ts +313 -0
- package/dist/src/operations/plan-progress.js +23 -0
- package/dist/src/operations/plan-status.d.ts +349 -0
- package/dist/src/operations/plan-status.js +41 -0
- package/dist/src/operations/query-node.d.ts +1065 -0
- package/dist/src/operations/query-node.js +27 -0
- package/dist/src/operations/query-nodes.d.ts +594 -0
- package/dist/src/operations/query-nodes.js +23 -0
- package/dist/src/operations/query-relationships.d.ts +343 -0
- package/dist/src/operations/query-relationships.js +27 -0
- package/dist/src/operations/remove-node.d.ts +895 -0
- package/dist/src/operations/remove-node.js +58 -0
- package/dist/src/operations/remove-relationship.d.ts +622 -0
- package/dist/src/operations/remove-relationship.js +26 -0
- package/dist/src/operations/rename.d.ts +594 -0
- package/dist/src/operations/rename.js +113 -0
- package/dist/src/operations/search.d.ts +593 -0
- package/dist/src/operations/search.js +39 -0
- package/dist/src/operations/speckit-diff.d.ts +330 -0
- package/dist/src/operations/speckit-diff.js +89 -0
- package/dist/src/operations/speckit-export.d.ts +300 -0
- package/dist/src/operations/speckit-export.js +17 -0
- package/dist/src/operations/speckit-import.d.ts +299 -0
- package/dist/src/operations/speckit-import.js +39 -0
- package/dist/src/operations/speckit-sync.d.ts +900 -0
- package/dist/src/operations/speckit-sync.js +116 -0
- package/dist/src/operations/state-at.d.ts +309 -0
- package/dist/src/operations/state-at.js +53 -0
- package/dist/src/operations/stats.d.ts +324 -0
- package/dist/src/operations/stats.js +85 -0
- package/dist/src/operations/task-list.d.ts +305 -0
- package/dist/src/operations/task-list.js +44 -0
- package/dist/src/operations/timeline.d.ts +312 -0
- package/dist/src/operations/timeline.js +46 -0
- package/dist/src/operations/trace-from-node.d.ts +1197 -0
- package/dist/src/operations/trace-from-node.js +36 -0
- package/dist/src/operations/update-metadata.d.ts +593 -0
- package/dist/src/operations/update-metadata.js +18 -0
- package/dist/src/operations/update-node.d.ts +957 -0
- package/dist/src/operations/update-node.js +24 -0
- package/dist/src/operations/update-plan-task.d.ts +595 -0
- package/dist/src/operations/update-plan-task.js +31 -0
- package/dist/src/operations/validate.d.ts +310 -0
- package/dist/src/operations/validate.js +82 -0
- package/dist/src/schema.d.ts +891 -0
- package/dist/src/schema.js +356 -0
- package/dist/src/speckit/generate.d.ts +7 -0
- package/dist/src/speckit/generate.js +546 -0
- package/dist/src/speckit/index.d.ts +4 -0
- package/dist/src/speckit/index.js +4 -0
- package/dist/src/speckit/parse.d.ts +11 -0
- package/dist/src/speckit/parse.js +712 -0
- package/dist/src/speckit/plan.d.ts +125 -0
- package/dist/src/speckit/plan.js +636 -0
- package/dist/src/speckit/project.d.ts +39 -0
- package/dist/src/speckit/project.js +141 -0
- package/dist/src/text.d.ts +23 -0
- package/dist/src/text.js +32 -0
- package/package.json +86 -8
- package/schema.json +510 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { join, basename } from "node:path";
|
|
4
|
+
import { NODE_FILE_MAP, NODE_LABEL_TO_TYPE, RELATIONSHIP_TYPE_LABELS, RELATIONSHIP_LABEL_TO_TYPE, NodeType, RelationshipType, NodeStatus, ExternalReferenceRole, } from "./schema.js";
|
|
5
|
+
const LABEL_TO_TYPE = Object.fromEntries(Object.entries(NODE_LABEL_TO_TYPE).map(([k, v]) => [k.toLowerCase(), v]));
|
|
6
|
+
const operationType = z.enum(["add", "update", "remove", "link"]);
|
|
7
|
+
function parseNodeType(s) {
|
|
8
|
+
const result = NodeType.safeParse(s);
|
|
9
|
+
if (!result.success)
|
|
10
|
+
throw new Error(`Unknown node type: ${s}`);
|
|
11
|
+
return result.data;
|
|
12
|
+
}
|
|
13
|
+
function parseRelType(s) {
|
|
14
|
+
const result = RelationshipType.safeParse(s);
|
|
15
|
+
if (!result.success)
|
|
16
|
+
throw new Error(`Unknown relationship type: ${s}`);
|
|
17
|
+
return result.data;
|
|
18
|
+
}
|
|
19
|
+
function parseNodeStatus(s) {
|
|
20
|
+
const result = NodeStatus.safeParse(s);
|
|
21
|
+
if (!result.success)
|
|
22
|
+
throw new Error(`Unknown node status: ${s}`);
|
|
23
|
+
return result.data;
|
|
24
|
+
}
|
|
25
|
+
function parseExtRefRole(s) {
|
|
26
|
+
const result = ExternalReferenceRole.safeParse(s);
|
|
27
|
+
if (!result.success)
|
|
28
|
+
throw new Error(`Unknown external reference role: ${s}`);
|
|
29
|
+
return result.data;
|
|
30
|
+
}
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Text helpers
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
function parseText(raw) {
|
|
35
|
+
const lines = raw.split("\n");
|
|
36
|
+
return lines.length === 1 ? lines[0] : lines;
|
|
37
|
+
}
|
|
38
|
+
function parseFrontMatter(content) {
|
|
39
|
+
if (!content.startsWith("---\n"))
|
|
40
|
+
return { front: {}, body: content };
|
|
41
|
+
const end = content.indexOf("\n---\n", 4);
|
|
42
|
+
if (end === -1)
|
|
43
|
+
return { front: {}, body: content };
|
|
44
|
+
const yaml = content.slice(4, end);
|
|
45
|
+
const front = {};
|
|
46
|
+
for (const line of yaml.split("\n")) {
|
|
47
|
+
const match = /^(\w+):\s*(.+)$/.exec(line);
|
|
48
|
+
if (!match)
|
|
49
|
+
continue;
|
|
50
|
+
const [, key, raw] = match;
|
|
51
|
+
if (raw.startsWith('"') && raw.endsWith('"')) {
|
|
52
|
+
front[key] = raw.slice(1, -1);
|
|
53
|
+
}
|
|
54
|
+
else if (/^\d+$/.test(raw)) {
|
|
55
|
+
front[key] = Number.parseInt(raw, 10);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
front[key] = raw;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { front, body: content.slice(end + 5) };
|
|
62
|
+
}
|
|
63
|
+
function parseSections(body) {
|
|
64
|
+
const lines = body.split("\n");
|
|
65
|
+
const all = [];
|
|
66
|
+
// First pass: find all headings and their body text (until next heading of any level)
|
|
67
|
+
for (let i = 0; i < lines.length; i++) {
|
|
68
|
+
const hMatch = /^(#{1,6})\s+(.+)$/.exec(lines[i]);
|
|
69
|
+
if (hMatch) {
|
|
70
|
+
const level = hMatch[1].length;
|
|
71
|
+
const heading = hMatch[2];
|
|
72
|
+
const bodyLines = [];
|
|
73
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
74
|
+
if (/^#{1,6}\s/.exec(lines[j]))
|
|
75
|
+
break;
|
|
76
|
+
bodyLines.push(lines[j]);
|
|
77
|
+
}
|
|
78
|
+
all.push({
|
|
79
|
+
level,
|
|
80
|
+
heading,
|
|
81
|
+
body: bodyLines.join("\n").trim(),
|
|
82
|
+
children: [],
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Second pass: build tree
|
|
87
|
+
const root = [];
|
|
88
|
+
const stack = [];
|
|
89
|
+
for (const section of all) {
|
|
90
|
+
while (stack.length > 0 && stack[stack.length - 1].level >= section.level) {
|
|
91
|
+
stack.pop();
|
|
92
|
+
}
|
|
93
|
+
if (stack.length > 0) {
|
|
94
|
+
stack[stack.length - 1].children.push(section);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
root.push(section);
|
|
98
|
+
}
|
|
99
|
+
stack.push(section);
|
|
100
|
+
}
|
|
101
|
+
return root;
|
|
102
|
+
}
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Node parsing from sections
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
function parseNodeId(heading) {
|
|
107
|
+
const match = /^(\S+)\s+—\s+(.+)$/.exec(heading);
|
|
108
|
+
if (!match)
|
|
109
|
+
return null;
|
|
110
|
+
return { id: match[1], name: match[2] };
|
|
111
|
+
}
|
|
112
|
+
function parseLifecycle(section) {
|
|
113
|
+
const lifecycle = {};
|
|
114
|
+
let found = false;
|
|
115
|
+
for (const line of section.body.split("\n")) {
|
|
116
|
+
const m = /^- \[([ x])\] (.+)$/.exec(line);
|
|
117
|
+
if (m) {
|
|
118
|
+
const isChecked = m[1] === "x";
|
|
119
|
+
const text = m[2];
|
|
120
|
+
// Check if the text ends with a parenthesised date
|
|
121
|
+
const dateMatch = /(.+?)\s*\((\d{4}-\d{2}-\d{2}(?:T[^)]+)?)\)$/.exec(text);
|
|
122
|
+
const key = dateMatch
|
|
123
|
+
? dateMatch[1].replace(/ /g, "_")
|
|
124
|
+
: text.replace(/ /g, "_");
|
|
125
|
+
// If a date is found, use the date string as the value regardless of checkbox state
|
|
126
|
+
if (dateMatch) {
|
|
127
|
+
lifecycle[key] = dateMatch[2];
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
// Otherwise, use boolean value
|
|
131
|
+
lifecycle[key] = isChecked;
|
|
132
|
+
}
|
|
133
|
+
found = true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return found ? lifecycle : undefined;
|
|
137
|
+
}
|
|
138
|
+
const RELATIONSHIP_LABELS = Object.values(RELATIONSHIP_TYPE_LABELS);
|
|
139
|
+
function isRelationshipLabel(line) {
|
|
140
|
+
return RELATIONSHIP_LABELS.some((label) => line.startsWith(`- ${label}:`));
|
|
141
|
+
}
|
|
142
|
+
function parseListItems(body, prefix) {
|
|
143
|
+
const items = [];
|
|
144
|
+
let collecting = false;
|
|
145
|
+
for (const line of body.split("\n")) {
|
|
146
|
+
if (line.startsWith(`${prefix}:`)) {
|
|
147
|
+
collecting = true;
|
|
148
|
+
const inline = line.slice(prefix.length + 1).trim();
|
|
149
|
+
if (inline) {
|
|
150
|
+
items.push(inline);
|
|
151
|
+
collecting = false;
|
|
152
|
+
}
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (collecting && line.startsWith(" - ")) {
|
|
156
|
+
items.push(line.slice(4));
|
|
157
|
+
}
|
|
158
|
+
else if (collecting &&
|
|
159
|
+
line.startsWith("- ") &&
|
|
160
|
+
!isRelationshipLabel(line)) {
|
|
161
|
+
items.push(line.slice(2));
|
|
162
|
+
}
|
|
163
|
+
else if (collecting) {
|
|
164
|
+
collecting = false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return items;
|
|
168
|
+
}
|
|
169
|
+
function parseSingleValue(body, prefix) {
|
|
170
|
+
for (const line of body.split("\n")) {
|
|
171
|
+
if (line.startsWith(`${prefix}: `)) {
|
|
172
|
+
return line.slice(prefix.length + 2);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
function parseRelationshipsFromBody(body, nodeId) {
|
|
178
|
+
const rels = [];
|
|
179
|
+
for (const [label, type] of Object.entries(RELATIONSHIP_LABEL_TO_TYPE)) {
|
|
180
|
+
const relType = parseRelType(type);
|
|
181
|
+
const items = parseListItems(body, `- ${label}`);
|
|
182
|
+
if (items.length === 0) {
|
|
183
|
+
const val = parseSingleValue(body, `- ${label}`);
|
|
184
|
+
if (val) {
|
|
185
|
+
rels.push({ from: nodeId, to: val, type: relType });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
for (const target of items) {
|
|
190
|
+
rels.push({ from: nodeId, to: target, type: relType });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return rels;
|
|
195
|
+
}
|
|
196
|
+
function parseNodeFromSection(section) {
|
|
197
|
+
const parsed = parseNodeId(section.heading);
|
|
198
|
+
if (!parsed)
|
|
199
|
+
return null;
|
|
200
|
+
const { id, name } = parsed;
|
|
201
|
+
const body = section.body;
|
|
202
|
+
const node = { id, type: parseNodeType("intent"), name }; // type overwritten by caller
|
|
203
|
+
// Description is the first paragraph(s) before any list or sub-heading content
|
|
204
|
+
const descLines = [];
|
|
205
|
+
for (const line of body.split("\n")) {
|
|
206
|
+
if (line.startsWith("- ") ||
|
|
207
|
+
line.startsWith("Context:") ||
|
|
208
|
+
line.startsWith("Options:") ||
|
|
209
|
+
line.startsWith("Chosen:") ||
|
|
210
|
+
line.startsWith("Rationale:") ||
|
|
211
|
+
line.startsWith("Scope:") ||
|
|
212
|
+
line.startsWith("Operations:") ||
|
|
213
|
+
line.startsWith("Includes:") ||
|
|
214
|
+
line === "") {
|
|
215
|
+
if (descLines.length > 0)
|
|
216
|
+
break;
|
|
217
|
+
if (line === "")
|
|
218
|
+
continue;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
descLines.push(line);
|
|
222
|
+
}
|
|
223
|
+
if (descLines.length > 0) {
|
|
224
|
+
node.description = parseText(descLines.join("\n"));
|
|
225
|
+
}
|
|
226
|
+
// Status
|
|
227
|
+
const status = parseSingleValue(body, "- Status");
|
|
228
|
+
if (status)
|
|
229
|
+
node.status = parseNodeStatus(status);
|
|
230
|
+
// Decision fields
|
|
231
|
+
const context = parseSingleValue(body, "Context");
|
|
232
|
+
if (context)
|
|
233
|
+
node.context = parseText(context);
|
|
234
|
+
const chosen = parseSingleValue(body, "Chosen");
|
|
235
|
+
if (chosen)
|
|
236
|
+
node.selected = chosen;
|
|
237
|
+
const rationale = parseSingleValue(body, "Rationale");
|
|
238
|
+
if (rationale)
|
|
239
|
+
node.rationale = parseText(rationale);
|
|
240
|
+
// Options
|
|
241
|
+
const optionLines = parseListItems(body, "Options");
|
|
242
|
+
if (optionLines.length > 0) {
|
|
243
|
+
node.options = optionLines.map((line) => {
|
|
244
|
+
const m = /^(\S+):\s+(.+)$/.exec(line);
|
|
245
|
+
return m
|
|
246
|
+
? { id: m[1], description: m[2] }
|
|
247
|
+
: { id: line, description: line };
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
// Change fields
|
|
251
|
+
const scopeItems = parseListItems(body, "Scope");
|
|
252
|
+
if (scopeItems.length > 0)
|
|
253
|
+
node.scope = scopeItems;
|
|
254
|
+
const opLines = parseListItems(body, "Operations");
|
|
255
|
+
if (opLines.length > 0) {
|
|
256
|
+
node.operations = opLines.map((line) => {
|
|
257
|
+
const parts = line.split(" ");
|
|
258
|
+
const rawType = parts[0];
|
|
259
|
+
const parsed = operationType.safeParse(rawType);
|
|
260
|
+
if (!parsed.success) {
|
|
261
|
+
throw new Error(`Unknown operation type: ${rawType}`);
|
|
262
|
+
}
|
|
263
|
+
const type = parsed.data;
|
|
264
|
+
const rest = parts.slice(1);
|
|
265
|
+
const dashIdx = rest.indexOf("—");
|
|
266
|
+
if (dashIdx >= 0) {
|
|
267
|
+
return {
|
|
268
|
+
type,
|
|
269
|
+
target: rest.slice(0, dashIdx).join(" ") || undefined,
|
|
270
|
+
description: rest.slice(dashIdx + 1).join(" "),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return { type, target: rest.join(" ") || undefined };
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
// View includes
|
|
277
|
+
const includes = parseListItems(body, "Includes");
|
|
278
|
+
if (includes.length > 0)
|
|
279
|
+
node.includes = includes;
|
|
280
|
+
// Artefact flow
|
|
281
|
+
const input = parseSingleValue(body, "- Input");
|
|
282
|
+
if (input)
|
|
283
|
+
node.input = input;
|
|
284
|
+
const output = parseSingleValue(body, "- Output");
|
|
285
|
+
if (output)
|
|
286
|
+
node.output = output;
|
|
287
|
+
// Lifecycle and propagation from child sections
|
|
288
|
+
for (const child of section.children) {
|
|
289
|
+
if (child.heading === "Lifecycle") {
|
|
290
|
+
node.lifecycle = parseLifecycle(child);
|
|
291
|
+
}
|
|
292
|
+
if (child.heading === "Propagation") {
|
|
293
|
+
const parsed = parseLifecycle(child);
|
|
294
|
+
// Propagation values are always boolean — coerce any date strings to true.
|
|
295
|
+
if (parsed) {
|
|
296
|
+
const booleanOnly = {};
|
|
297
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
298
|
+
booleanOnly[k] = !!v;
|
|
299
|
+
}
|
|
300
|
+
node.propagation = booleanOnly;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (child.heading === "Plan") {
|
|
304
|
+
const plan = [];
|
|
305
|
+
for (const line of child.body.split("\n")) {
|
|
306
|
+
const m = /^- \[([ x])\] (.+)$/.exec(line);
|
|
307
|
+
if (m)
|
|
308
|
+
plan.push({ description: m[2], done: m[1] === "x" });
|
|
309
|
+
}
|
|
310
|
+
if (plan.length > 0)
|
|
311
|
+
node.plan = plan;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Relationships
|
|
315
|
+
const rels = parseRelationshipsFromBody(body, id);
|
|
316
|
+
return { node, rels };
|
|
317
|
+
}
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// File-level parsing
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
function findTypeSections(sections) {
|
|
322
|
+
// Type sections (## Intent, ## Concepts, etc.) may be at root level
|
|
323
|
+
// or nested under a top-level # heading. Flatten to find them.
|
|
324
|
+
const result = [];
|
|
325
|
+
for (const s of sections) {
|
|
326
|
+
if (LABEL_TO_TYPE[s.heading.toLowerCase()]) {
|
|
327
|
+
result.push(s);
|
|
328
|
+
}
|
|
329
|
+
for (const child of s.children) {
|
|
330
|
+
if (LABEL_TO_TYPE[child.heading.toLowerCase()]) {
|
|
331
|
+
result.push(child);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return result;
|
|
336
|
+
}
|
|
337
|
+
function parseDocFile(content, types) {
|
|
338
|
+
const { body } = parseFrontMatter(content);
|
|
339
|
+
const sections = parseSections(body);
|
|
340
|
+
const typeSections = findTypeSections(sections);
|
|
341
|
+
const nodes = [];
|
|
342
|
+
const rels = [];
|
|
343
|
+
for (const typeSection of typeSections) {
|
|
344
|
+
const type = LABEL_TO_TYPE[typeSection.heading.toLowerCase()] ??
|
|
345
|
+
types.find((t) => typeSection.heading.toLowerCase() === t);
|
|
346
|
+
for (const child of typeSection.children) {
|
|
347
|
+
const result = parseNodeFromSection(child);
|
|
348
|
+
if (result) {
|
|
349
|
+
result.node.type = parseNodeType(type);
|
|
350
|
+
nodes.push(result.node);
|
|
351
|
+
rels.push(...result.rels);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return { nodes, rels };
|
|
356
|
+
}
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// External references from README
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
function parseExternalReferences(body) {
|
|
361
|
+
const refs = [];
|
|
362
|
+
const lines = body.split("\n");
|
|
363
|
+
let inSection = false;
|
|
364
|
+
for (let i = 0; i < lines.length; i++) {
|
|
365
|
+
if (/^##\s+External References/.exec(lines[i])) {
|
|
366
|
+
inSection = true;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (inSection && /^##\s/.exec(lines[i]))
|
|
370
|
+
break;
|
|
371
|
+
if (inSection && lines[i].startsWith("- ")) {
|
|
372
|
+
const m = /^- (\w+): (.+)$/.exec(lines[i]);
|
|
373
|
+
if (m) {
|
|
374
|
+
const ref = {
|
|
375
|
+
role: parseExtRefRole(m[1]),
|
|
376
|
+
identifier: m[2],
|
|
377
|
+
};
|
|
378
|
+
// Check for indented sub-items
|
|
379
|
+
for (let j = i + 1; j < lines.length && lines[j].startsWith(" - "); j++) {
|
|
380
|
+
const sub = lines[j].slice(4);
|
|
381
|
+
if (sub.startsWith("Node: "))
|
|
382
|
+
ref.node_id = sub.slice(6);
|
|
383
|
+
else
|
|
384
|
+
ref.description = sub;
|
|
385
|
+
i = j;
|
|
386
|
+
}
|
|
387
|
+
refs.push(ref);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return refs;
|
|
392
|
+
}
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// Relationship table from single file
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
function parseRelationshipTable(body) {
|
|
397
|
+
const rels = [];
|
|
398
|
+
const lines = body.split("\n");
|
|
399
|
+
let inTable = false;
|
|
400
|
+
for (const line of lines) {
|
|
401
|
+
if (line.startsWith("| From |")) {
|
|
402
|
+
inTable = true;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (inTable && line.startsWith("|---"))
|
|
406
|
+
continue;
|
|
407
|
+
if (inTable && line.startsWith("|")) {
|
|
408
|
+
const cells = line
|
|
409
|
+
.split("|")
|
|
410
|
+
.map((c) => c.trim())
|
|
411
|
+
.filter(Boolean);
|
|
412
|
+
if (cells.length >= 3) {
|
|
413
|
+
rels.push({
|
|
414
|
+
from: cells[0],
|
|
415
|
+
to: cells[2],
|
|
416
|
+
type: parseRelType(cells[1]),
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
else if (inTable) {
|
|
421
|
+
inTable = false;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return rels;
|
|
425
|
+
}
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
// Public API
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
/**
|
|
430
|
+
* Parse a single Markdown file into a SysProM document.
|
|
431
|
+
*
|
|
432
|
+
* @param content - The Markdown content to parse.
|
|
433
|
+
* @returns The parsed SysProM document.
|
|
434
|
+
*/
|
|
435
|
+
export function markdownSingleToJson(content) {
|
|
436
|
+
const { front, body } = parseFrontMatter(content);
|
|
437
|
+
const allTypes = [
|
|
438
|
+
...NODE_FILE_MAP.INTENT,
|
|
439
|
+
...NODE_FILE_MAP.INVARIANTS,
|
|
440
|
+
...NODE_FILE_MAP.STATE,
|
|
441
|
+
...NODE_FILE_MAP.DECISIONS,
|
|
442
|
+
...NODE_FILE_MAP.CHANGES,
|
|
443
|
+
"view",
|
|
444
|
+
"milestone",
|
|
445
|
+
"version",
|
|
446
|
+
];
|
|
447
|
+
const { nodes, rels } = parseDocFile(content, allTypes);
|
|
448
|
+
const tableRels = parseRelationshipTable(body);
|
|
449
|
+
const extRefs = parseExternalReferences(body);
|
|
450
|
+
const doc = {
|
|
451
|
+
metadata: Object.keys(front).length > 0 ? front : undefined,
|
|
452
|
+
nodes,
|
|
453
|
+
relationships: [...rels, ...tableRels].length > 0 ? [...rels, ...tableRels] : undefined,
|
|
454
|
+
external_references: extRefs.length > 0 ? extRefs : undefined,
|
|
455
|
+
};
|
|
456
|
+
if (front.title && typeof front.title === "string") {
|
|
457
|
+
doc.metadata = { ...front };
|
|
458
|
+
}
|
|
459
|
+
return doc;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Parse a multi-document Markdown folder into a SysProM document.
|
|
463
|
+
*
|
|
464
|
+
* @param dir - Path to the directory containing Markdown files.
|
|
465
|
+
* @returns The parsed SysProM document.
|
|
466
|
+
*/
|
|
467
|
+
export function markdownMultiDocToJson(dir) {
|
|
468
|
+
const readmeContent = readFileSync(join(dir, "README.md"), "utf8");
|
|
469
|
+
const { front, body } = parseFrontMatter(readmeContent);
|
|
470
|
+
const nodes = [];
|
|
471
|
+
const rels = [];
|
|
472
|
+
// Parse each document file
|
|
473
|
+
for (const [fileName, types] of Object.entries(NODE_FILE_MAP)) {
|
|
474
|
+
const filePath = join(dir, `${fileName}.md`);
|
|
475
|
+
if (!existsSync(filePath))
|
|
476
|
+
continue;
|
|
477
|
+
const content = readFileSync(filePath, "utf8");
|
|
478
|
+
const result = parseDocFile(content, types);
|
|
479
|
+
nodes.push(...result.nodes);
|
|
480
|
+
rels.push(...result.rels);
|
|
481
|
+
}
|
|
482
|
+
// Parse views, milestones, versions from README
|
|
483
|
+
const readmeSections = parseSections(body);
|
|
484
|
+
const readmeTypeSections = findTypeSections(readmeSections);
|
|
485
|
+
for (const typeSection of readmeTypeSections) {
|
|
486
|
+
const type = LABEL_TO_TYPE[typeSection.heading.toLowerCase()];
|
|
487
|
+
if (!type)
|
|
488
|
+
continue;
|
|
489
|
+
for (const child of typeSection.children) {
|
|
490
|
+
const result = parseNodeFromSection(child);
|
|
491
|
+
if (result) {
|
|
492
|
+
result.node.type = parseNodeType(type);
|
|
493
|
+
nodes.push(result.node);
|
|
494
|
+
rels.push(...result.rels);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// External references from README
|
|
499
|
+
const extRefs = parseExternalReferences(body);
|
|
500
|
+
// Subsystem folders and .spm.md files (including inside grouping directories)
|
|
501
|
+
function scanForSubsystems(scanDir) {
|
|
502
|
+
for (const entry of readdirSync(scanDir)) {
|
|
503
|
+
const entryPath = join(scanDir, entry);
|
|
504
|
+
if (statSync(entryPath).isDirectory() &&
|
|
505
|
+
existsSync(join(entryPath, "README.md"))) {
|
|
506
|
+
// Folder-based subsystem
|
|
507
|
+
const idPrefix = entry.split("-")[0];
|
|
508
|
+
const parentNode = nodes.find((n) => n.id === idPrefix);
|
|
509
|
+
if (parentNode) {
|
|
510
|
+
parentNode.subsystem = markdownMultiDocToJson(entryPath);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
else if (entry.endsWith(".spm.md")) {
|
|
514
|
+
// Single-file subsystem
|
|
515
|
+
const fileIdPrefix = basename(entry, ".spm.md").split("-")[0];
|
|
516
|
+
const parentNode = nodes.find((n) => n.id === fileIdPrefix);
|
|
517
|
+
if (parentNode) {
|
|
518
|
+
parentNode.subsystem = markdownSingleToJson(readFileSync(entryPath, "utf8"));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
else if (statSync(entryPath).isDirectory() &&
|
|
522
|
+
!existsSync(join(entryPath, "README.md"))) {
|
|
523
|
+
// Grouping directory (no README = not a subsystem, just organisational)
|
|
524
|
+
scanForSubsystems(entryPath);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
scanForSubsystems(dir);
|
|
529
|
+
const doc = {
|
|
530
|
+
metadata: Object.keys(front).length > 0 ? front : undefined,
|
|
531
|
+
nodes,
|
|
532
|
+
relationships: rels.length > 0 ? rels : undefined,
|
|
533
|
+
external_references: extRefs.length > 0 ? extRefs : undefined,
|
|
534
|
+
};
|
|
535
|
+
return doc;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Parse Markdown into a SysProM document, auto-detecting single-file or multi-doc format.
|
|
539
|
+
*
|
|
540
|
+
* @param input - File path or directory path to parse.
|
|
541
|
+
* @returns The parsed SysProM document.
|
|
542
|
+
*/
|
|
543
|
+
export function markdownToJson(input) {
|
|
544
|
+
if (statSync(input).isDirectory()) {
|
|
545
|
+
return markdownMultiDocToJson(input);
|
|
546
|
+
}
|
|
547
|
+
return markdownSingleToJson(readFileSync(input, "utf8"));
|
|
548
|
+
}
|