mdboard 1.3.0 → 2.1.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.
Files changed (53) hide show
  1. package/bin.js +117 -59
  2. package/index.html +2161 -1579
  3. package/package.json +7 -5
  4. package/presets/kanban/api.json +91 -0
  5. package/presets/kanban/cli.json +69 -0
  6. package/presets/kanban/docs.json +29 -0
  7. package/presets/kanban/entities.json +128 -0
  8. package/presets/kanban/structure.json +15 -0
  9. package/presets/kanban/ui.json +86 -0
  10. package/presets/scrum/api.json +98 -0
  11. package/presets/scrum/cli.json +120 -0
  12. package/presets/scrum/docs.json +43 -0
  13. package/presets/scrum/entities.json +268 -0
  14. package/presets/scrum/structure.json +32 -0
  15. package/presets/scrum/ui.json +201 -0
  16. package/presets/shape-up/api.json +40 -0
  17. package/presets/shape-up/cli.json +44 -0
  18. package/presets/shape-up/docs.json +32 -0
  19. package/presets/shape-up/entities.json +140 -0
  20. package/presets/shape-up/structure.json +28 -0
  21. package/presets/shape-up/ui.json +114 -0
  22. package/src/cli/cli.js +186 -210
  23. package/src/cli/config.js +234 -0
  24. package/src/cli/init.js +128 -76
  25. package/src/cli/preset.js +849 -0
  26. package/src/cli/skill.js +417 -0
  27. package/src/cli/status.js +126 -96
  28. package/src/core/config.js +491 -38
  29. package/src/core/history.js +17 -1
  30. package/src/core/scanner.js +373 -463
  31. package/src/core/workspace.js +0 -15
  32. package/src/server/api.js +464 -741
  33. package/src/server/server.js +105 -130
  34. package/build.js +0 -44
  35. package/defaults.json +0 -43
  36. package/src/cli/sync.js +0 -194
  37. package/src/cli/theme.js +0 -142
  38. package/src/client/app.js +0 -266
  39. package/src/client/board.js +0 -157
  40. package/src/client/core.js +0 -331
  41. package/src/client/editor.js +0 -318
  42. package/src/client/history.js +0 -137
  43. package/src/client/metrics.js +0 -38
  44. package/src/client/milestones.js +0 -77
  45. package/src/client/notes.js +0 -183
  46. package/src/client/overview.js +0 -104
  47. package/src/client/panel.js +0 -637
  48. package/src/client/styles.css +0 -471
  49. package/src/client/table.js +0 -111
  50. package/src/client/template.html +0 -144
  51. package/src/client/themes.js +0 -261
  52. package/src/client/workspace.js +0 -164
  53. package/src/core/agent-scanner.js +0 -260
@@ -2,23 +2,21 @@
2
2
  /**
3
3
  * mdboard — Project Dashboard Server
4
4
  *
5
- * Zero-dependency Node.js server that reads markdown project management
6
- * files and serves a visual dashboard + JSON API.
7
- *
8
- * Supports multi-repo workspaces via workspace.json.
5
+ * Config-driven server that reads markdown project management files
6
+ * and serves a JSON API. No hardcoded entity names.
9
7
  *
10
8
  * Usage:
11
9
  * mdboard --project /path/to/workspace --port 3333
12
10
  */
13
11
 
12
+ 'use strict';
13
+
14
14
  const http = require('http');
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
17
  const os = require('os');
18
18
  const { loadConfig, deepMerge } = require('../core/config');
19
- const { createModel, scanSource, computeProgress, computeAgentProps, updateMarkdownFile, mergeResults,
20
- createTask, createMilestone, createEpic, createSprint, createNote, archiveItem } = require('../core/scanner');
21
- const { scanAgentConfigs } = require('../core/agent-scanner');
19
+ const { createModel, scanSource, computeProgress, updateEntity, createEntity, deleteEntity, mergeResults } = require('../core/scanner');
22
20
  const { setupWatchers, closeWatchers } = require('./watcher');
23
21
  const { handleApi } = require('./api');
24
22
  const { loadWorkspace, syncAllRemotes, getGitAuthor } = require('../core/workspace');
@@ -58,7 +56,7 @@ let projectPath = path.join(projectDir, 'project');
58
56
  const boardDir = path.resolve(__dirname, '..', '..');
59
57
 
60
58
  // ---------------------------------------------------------------------------
61
- // Load configuration
59
+ // Load configuration (new config engine)
62
60
  // ---------------------------------------------------------------------------
63
61
  let config = loadConfig(projectDir, process.env.MDBOARD_CONFIG);
64
62
 
@@ -88,17 +86,10 @@ function broadcast(data) {
88
86
  // ---------------------------------------------------------------------------
89
87
  // Model & scanning — sourceStates tracks per-source state
90
88
  // ---------------------------------------------------------------------------
91
- let model = createModel();
89
+ let model = createModel(config);
92
90
  const sourceStates = new Map();
93
- let aiSuggestions = null;
94
-
95
- function refreshAiSuggestions() {
96
- try { aiSuggestions = scanAgentConfigs(projectDir); }
97
- catch { aiSuggestions = { skills: [], agents: [], mcps: [], commands: [], context: [] }; }
98
- }
99
91
 
100
92
  function loadSourceConfig(source) {
101
- // Try to load per-source mdboard.json
102
93
  if (!source._repoRoot) return config;
103
94
  const candidates = [
104
95
  path.join(source._resolvedPath, 'mdboard.json'),
@@ -115,11 +106,10 @@ function loadSourceConfig(source) {
115
106
  }
116
107
 
117
108
  function scanAll() {
118
- model = createModel();
109
+ model = createModel(config);
119
110
  sourceStates.clear();
120
111
 
121
112
  if (workspace && sources.length > 0) {
122
- // Multi-source mode
123
113
  const results = [];
124
114
  for (const source of sources) {
125
115
  const sourcePath = source._resolvedPath;
@@ -134,21 +124,21 @@ function scanAll() {
134
124
  readonly,
135
125
  };
136
126
 
137
- // Load per-source config
138
127
  const srcConfig = loadSourceConfig(source);
139
128
  const result = scanSource(sourcePath, srcConfig, meta);
140
129
 
141
- // Add author info if showAuthor is enabled
142
130
  if (workspace.settings && workspace.settings.showAuthor) {
143
131
  const repoRoot = source._repoRoot;
144
132
  if (repoRoot) {
145
- for (const task of result.tasks) {
146
- task._author = getGitAuthor(repoRoot, task._file);
133
+ const leafType = getLeafType(srcConfig);
134
+ if (leafType && result.entities[leafType]) {
135
+ for (const item of result.entities[leafType]) {
136
+ item._author = getGitAuthor(repoRoot, item._file);
137
+ }
147
138
  }
148
139
  }
149
140
  }
150
141
 
151
- // Store per-source state
152
142
  sourceStates.set(source.name, {
153
143
  model: result,
154
144
  config: srcConfig,
@@ -161,27 +151,59 @@ function scanAll() {
161
151
  }
162
152
  mergeResults(model, results);
163
153
  } else {
164
- // Legacy single-source mode
165
154
  const result = scanSource(projectPath, config, {});
166
155
  mergeResults(model, [result]);
167
156
  }
168
157
 
169
- computeProgress(model, config.completedStatus);
170
- computeAgentProps(model);
171
- refreshAiSuggestions();
158
+ computeProgress(model, config);
159
+ }
160
+
161
+ function getLeafType(cfg) {
162
+ const { flattenHierarchy } = require('../core/config');
163
+ const flat = flattenHierarchy(cfg);
164
+ const h = flat.filter(e => !e.standalone);
165
+ return h.length > 0 ? h[h.length - 1].type : null;
172
166
  }
173
167
 
174
168
  // ---------------------------------------------------------------------------
175
- // File update helper — resolves correct projectPath per source
169
+ // File update helper
176
170
  // ---------------------------------------------------------------------------
177
171
  function updateFile(sourceName, relFile, updates) {
172
+ let sourcePath = projectPath;
173
+ if (sourceName && sources.length > 0) {
174
+ const source = sources.find(s => s.name === sourceName);
175
+ if (source && source._resolvedPath) sourcePath = source._resolvedPath;
176
+ }
177
+ return updateEntity(sourcePath, relFile, updates);
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // CRUD helpers — dynamic, uses entityType not collection name
182
+ // ---------------------------------------------------------------------------
183
+ function createItemFn(sourceName, entityType, data) {
184
+ let sourcePath = projectPath;
185
+ let cfg = config;
186
+
178
187
  if (sourceName && sources.length > 0) {
179
188
  const source = sources.find(s => s.name === sourceName);
180
189
  if (source && source._resolvedPath) {
181
- return updateMarkdownFile(source._resolvedPath, relFile, updates);
190
+ sourcePath = source._resolvedPath;
191
+ const state = sourceStates.get(sourceName);
192
+ if (state) cfg = state.config;
182
193
  }
183
194
  }
184
- return updateMarkdownFile(projectPath, relFile, updates);
195
+
196
+ const existingItems = model.entities[entityType] || [];
197
+ return createEntity(sourcePath, cfg, entityType, data, existingItems);
198
+ }
199
+
200
+ function deleteItemFn(sourceName, item) {
201
+ let sourcePath = projectPath;
202
+ if (sourceName && sources.length > 0) {
203
+ const source = sources.find(s => s.name === sourceName);
204
+ if (source && source._resolvedPath) sourcePath = source._resolvedPath;
205
+ }
206
+ return deleteEntity(sourcePath, item);
185
207
  }
186
208
 
187
209
  // ---------------------------------------------------------------------------
@@ -189,7 +211,6 @@ function updateFile(sourceName, relFile, updates) {
189
211
  // ---------------------------------------------------------------------------
190
212
  async function doSyncRemotes() {
191
213
  if (!workspace || sources.length === 0) return;
192
-
193
214
  const remoteSources = sources.filter(s => s.type === 'remote');
194
215
  if (remoteSources.length === 0) return;
195
216
 
@@ -235,6 +256,15 @@ function findCustomCss() {
235
256
  return null;
236
257
  }
237
258
 
259
+ // Cache index.html at startup
260
+ let _indexHtmlCache = null;
261
+ function getIndexHtml() {
262
+ if (!_indexHtmlCache) {
263
+ try { _indexHtmlCache = fs.readFileSync(path.join(boardDir, 'index.html')); } catch { /* */ }
264
+ }
265
+ return _indexHtmlCache;
266
+ }
267
+
238
268
  function serveStatic(req, res) {
239
269
  const url = new URL(req.url, `http://localhost:${port}`);
240
270
 
@@ -253,94 +283,19 @@ function serveStatic(req, res) {
253
283
  return;
254
284
  }
255
285
 
256
- if (url.pathname === '/logo') {
257
- if (config.logo && !config.logo.startsWith('http')) {
258
- const logoPath = path.resolve(projectDir, config.logo);
259
- if (logoPath.startsWith(projectDir) && fs.existsSync(logoPath)) {
260
- try {
261
- const logoContent = fs.readFileSync(logoPath);
262
- const logoExt = path.extname(logoPath);
263
- const logoMime = MIME_TYPES[logoExt] || 'application/octet-stream';
264
- res.writeHead(200, { 'Content-Type': logoMime });
265
- res.end(logoContent);
266
- return;
267
- } catch { /* fall through */ }
268
- }
269
- }
270
- res.writeHead(404);
271
- res.end('Not found');
272
- return;
273
- }
274
-
275
- let filePath = path.join(boardDir, 'index.html');
276
- if (!filePath.startsWith(boardDir)) {
277
- res.writeHead(403);
278
- res.end('Forbidden');
279
- return;
280
- }
281
-
282
- const ext = path.extname(filePath);
283
- const contentType = MIME_TYPES[ext] || 'text/plain';
284
-
285
- try {
286
- const content = fs.readFileSync(filePath);
287
- res.writeHead(200, { 'Content-Type': contentType });
286
+ const content = getIndexHtml();
287
+ if (content) {
288
+ res.writeHead(200, { 'Content-Type': 'text/html' });
288
289
  res.end(content);
289
- } catch {
290
+ } else {
290
291
  res.writeHead(404);
291
292
  res.end('Not found');
292
293
  }
293
294
  }
294
295
 
295
296
  // ---------------------------------------------------------------------------
296
- // HTTP server
297
- // ---------------------------------------------------------------------------
298
- // ---------------------------------------------------------------------------
299
- // CRUD helpers — resolve source path and delegate to scanner
297
+ // API context
300
298
  // ---------------------------------------------------------------------------
301
- function createItemFn(sourceName, collection, data) {
302
- let sourcePath = projectPath;
303
- let cfg = config;
304
-
305
- if (sourceName && sources.length > 0) {
306
- const source = sources.find(s => s.name === sourceName);
307
- if (source && source._resolvedPath) {
308
- sourcePath = source._resolvedPath;
309
- const state = sourceStates.get(sourceName);
310
- if (state) cfg = state.config;
311
- }
312
- }
313
-
314
- // Use ALL items across sources to compute next ID — prevents cross-source collisions
315
- switch (collection) {
316
- case 'tasks':
317
- return createTask(sourcePath, cfg, data, model.tasks);
318
- case 'milestones':
319
- return createMilestone(sourcePath, cfg, data, model.milestones);
320
- case 'epics':
321
- return createEpic(sourcePath, cfg, data, model.epics);
322
- case 'sprints':
323
- return createSprint(sourcePath, cfg, data, model.sprints);
324
- case 'notes':
325
- return createNote(sourcePath, data, model.notes);
326
- default:
327
- throw new Error('Unknown collection: ' + collection);
328
- }
329
- }
330
-
331
- function archiveItemFn(sourceName, item) {
332
- let sourcePath = projectPath;
333
-
334
- if (sourceName && sources.length > 0) {
335
- const source = sources.find(s => s.name === sourceName);
336
- if (source && source._resolvedPath) {
337
- sourcePath = source._resolvedPath;
338
- }
339
- }
340
-
341
- return archiveItem(sourcePath, item);
342
- }
343
-
344
299
  const apiCtx = {
345
300
  get model() { return model; },
346
301
  get config() { return config; },
@@ -352,18 +307,33 @@ const apiCtx = {
352
307
  broadcast,
353
308
  updateFile,
354
309
  rescanAll: () => scanAll(),
310
+ reloadConfig: () => reloadConfig(),
355
311
  syncRemotes: doSyncRemotes,
356
312
  createItem: createItemFn,
357
- archiveItem: archiveItemFn,
358
- getAiSuggestions: () => aiSuggestions || { skills: [], agents: [], mcps: [], commands: [], context: [] },
313
+ deleteItem: deleteItemFn,
359
314
  get hasProject() {
360
315
  return fs.existsSync(path.join(projectDir, 'project')) ||
361
316
  (workspace && sources.length > 0);
362
317
  },
363
318
  loadProject: loadProject,
364
319
  getProjectHistory: () => getHistory(projectDir),
320
+ get workspacePath() {
321
+ return workspace ? workspace._path : (workspacePath || path.join(projectDir, 'workspace.json'));
322
+ },
323
+ onWorkspaceChange() {
324
+ workspace = loadWorkspace(projectDir, workspacePath);
325
+ sources = workspace ? workspace.sources : [];
326
+ closeWatchers(activeWatchers);
327
+ scanAll();
328
+ activeWatchers = setupWatchers(buildWatcherOpts());
329
+ doSyncRemotes().catch(() => {});
330
+ broadcast({ type: 'update', timestamp: new Date().toISOString() });
331
+ },
365
332
  };
366
333
 
334
+ // ---------------------------------------------------------------------------
335
+ // HTTP server
336
+ // ---------------------------------------------------------------------------
367
337
  const server = http.createServer(async (req, res) => {
368
338
  try {
369
339
  if (req.url.startsWith('/api/')) {
@@ -428,44 +398,31 @@ if (isValidProject(projectDir)) {
428
398
 
429
399
  /**
430
400
  * Load a different project directory dynamically.
431
- *
432
- * @param {string} newProjectDir - Absolute path to the new project
433
401
  */
434
402
  function loadProject(newProjectDir) {
435
- // 1. Close existing watchers
436
403
  closeWatchers(activeWatchers);
437
-
438
- // 2. Stop sync interval
439
404
  if (syncInterval) clearInterval(syncInterval);
440
405
  syncInterval = null;
441
406
 
442
- // 3. Update paths
443
407
  projectDir = path.resolve(newProjectDir);
444
408
  projectPath = path.join(projectDir, 'project');
445
409
  workspacePath = null;
446
410
 
447
- // 4. Reload config
448
- config = loadConfig(projectDir);
411
+ // Clear startup preset so each project resolves its own
412
+ delete process.env.MDBOARD_PRESET;
449
413
 
450
- // 5. Reload workspace
414
+ config = loadConfig(projectDir);
451
415
  workspace = loadWorkspace(projectDir, null);
452
416
  sources = workspace ? workspace.sources : [];
453
417
 
454
- // 6. Rescan
455
418
  scanAll();
456
-
457
- // 7. Setup new watchers
458
419
  activeWatchers = setupWatchers(buildWatcherOpts());
459
420
 
460
- // 8. Restart remote sync
461
421
  doSyncRemotes().catch(() => {});
462
422
  startSyncInterval();
463
423
 
464
- // 9. Register in history
465
424
  const projName = (model.project && model.project.name) || path.basename(projectDir);
466
425
  registerProject(projectDir, projName);
467
-
468
- // 10. Broadcast to SSE clients
469
426
  broadcast({ type: 'project-changed', projectDir });
470
427
  }
471
428
 
@@ -478,11 +435,26 @@ server.listen(port, () => {
478
435
  ? `\n Sources: ${sources.map(s => s.label || s.name).join(', ')}`
479
436
  : '';
480
437
  const watchCount = activeWatchers.length;
438
+
439
+ // Resolve leaf type for tips
440
+ const { flattenHierarchy } = require('../core/config');
441
+ const flat = flattenHierarchy(config);
442
+ const hierarchy = flat.filter(e => !e.standalone);
443
+ const leafType = hierarchy.length > 0 ? hierarchy[hierarchy.length - 1].type : null;
444
+
481
445
  console.log(`
482
446
  mdboard — Project Dashboard
483
447
  Project: ${projectDir}
484
- Server: http://localhost:${port}${config._path ? '\n Config: ' + config._path : ''}${sourceInfo}
448
+ Server: http://localhost:${port}${config._entitiesPath ? '\n Config: ' + config._entitiesPath : ''}${sourceInfo}
485
449
  ${watchCount > 0 ? '\n Watching ' + watchCount + ' director' + (watchCount === 1 ? 'y' : 'ies') + ' for changes...' : ''}
450
+ Open in browser:
451
+ http://localhost:${port}
452
+
453
+ Quick start:
454
+ mdboard config Setup preferences & AI skill
455
+ mdboard preset list Switch methodology (scrum, kanban, ...)
456
+ mdboard skill Add AI skill to your IDE${leafType ? '\n mdboard create ' + leafType + ' "title" Create a new ' + leafType : ''}
457
+ mdboard --help Show all commands
486
458
  `);
487
459
  });
488
460
 
@@ -509,3 +481,6 @@ process.on('SIGTERM', () => {
509
481
  closeWatchers(activeWatchers);
510
482
  server.close(() => process.exit(0));
511
483
  });
484
+
485
+ // Export for programmatic use in tests
486
+ module.exports = { server, scanAll, apiCtx };
package/build.js DELETED
@@ -1,44 +0,0 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- const fs = require('fs');
5
- const path = require('path');
6
-
7
- const CLIENT_DIR = path.join(__dirname, 'src', 'client');
8
- const OUT_FILE = path.join(__dirname, 'index.html');
9
-
10
- const JS_FILES = [
11
- 'themes.js',
12
- 'core.js',
13
- 'workspace.js',
14
- 'editor.js',
15
- 'board.js',
16
- 'table.js',
17
- 'milestones.js',
18
- 'metrics.js',
19
- 'overview.js',
20
- 'notes.js',
21
- 'panel.js',
22
- 'history.js',
23
- 'app.js',
24
- ];
25
-
26
- // Read template
27
- const template = fs.readFileSync(path.join(CLIENT_DIR, 'template.html'), 'utf8');
28
-
29
- // Read CSS
30
- const css = fs.readFileSync(path.join(CLIENT_DIR, 'styles.css'), 'utf8');
31
-
32
- // Read and concatenate JS files
33
- const js = JS_FILES
34
- .map(f => fs.readFileSync(path.join(CLIENT_DIR, f), 'utf8'))
35
- .join('\n\n');
36
-
37
- // Replace placeholders
38
- let output = template.replace('<!-- STYLES -->', css);
39
- output = output.replace('<!-- SCRIPTS -->', js);
40
-
41
- fs.writeFileSync(OUT_FILE, output, 'utf8');
42
-
43
- const lines = output.split('\n').length;
44
- console.log(`Built index.html (${lines} lines, ${Buffer.byteLength(output)} bytes)`);
package/defaults.json DELETED
@@ -1,43 +0,0 @@
1
- {
2
- "entities": {
3
- "milestone": { "singular": "Milestone", "plural": "Milestones", "dir": "milestones" },
4
- "epic": { "singular": "Epic", "plural": "Epics", "dir": "epics" },
5
- "task": { "singular": "Task", "plural": "Tasks", "dir": "backlog", "prefix": "TASK", "legacyPrefixes": ["FEAT"] },
6
- "sprint": { "singular": "Sprint", "plural": "Sprints", "dir": "sprints" }
7
- },
8
- "statuses": {
9
- "task": [
10
- { "key": "backlog", "label": "Backlog", "color": "#5A5A63", "icon": "dashed-circle" },
11
- { "key": "todo", "label": "Todo", "color": "#8B8B93", "icon": "circle" },
12
- { "key": "in-progress", "label": "In Progress", "color": "#D4A72C", "icon": "half-circle" },
13
- { "key": "in-review", "label": "In Review", "color": "#8B5CF6", "icon": "three-quarter-circle" },
14
- { "key": "done", "label": "Done", "color": "#2EA043", "icon": "check-circle" },
15
- { "key": "blocked", "label": "Blocked", "color": "#DA3633", "icon": "x-circle" },
16
- { "key": "cancelled", "label": "Cancelled", "color": "#5A5A63", "icon": "slash-circle" }
17
- ],
18
- "milestone": [
19
- { "key": "planned", "label": "Planned", "color": "#8B8B93" },
20
- { "key": "active", "label": "Active", "color": "#5B6EF5" },
21
- { "key": "completed", "label": "Completed", "color": "#2EA043" }
22
- ],
23
- "epic": [
24
- { "key": "active", "label": "Active", "color": "#5B6EF5" },
25
- { "key": "completed", "label": "Completed", "color": "#2EA043" },
26
- { "key": "blocked", "label": "Blocked", "color": "#DA3633" }
27
- ],
28
- "sprint": [
29
- { "key": "planned", "label": "Planned", "color": "#8B8B93" },
30
- { "key": "active", "label": "Active", "color": "#5B6EF5" },
31
- { "key": "completed", "label": "Completed", "color": "#2EA043" },
32
- { "key": "cancelled", "label": "Cancelled", "color": "#DA3633" }
33
- ]
34
- },
35
- "boardColumns": ["backlog", "todo", "in-progress", "in-review", "done"],
36
- "priorities": [
37
- { "key": "urgent", "label": "Urgent", "color": "#F97316", "bars": 4 },
38
- { "key": "high", "label": "High", "color": "#F97316", "bars": 3 },
39
- { "key": "medium", "label": "Medium", "color": "#D4A72C", "bars": 2 },
40
- { "key": "low", "label": "Low", "color": "#5B6EF5", "bars": 1 }
41
- ],
42
- "completedStatus": "done"
43
- }
package/src/cli/sync.js DELETED
@@ -1,194 +0,0 @@
1
- /**
2
- * mdboard — Consistency checker
3
- *
4
- * Detects dangling references and inconsistencies in the project model.
5
- * With --fix, auto-corrects fixable issues.
6
- *
7
- * Usage:
8
- * mdboard sync Report issues
9
- * mdboard sync --fix Auto-fix fixable issues
10
- */
11
-
12
- const fs = require('fs');
13
- const path = require('path');
14
- const { buildModel } = require('./cli');
15
- const { updateMarkdownFile } = require('../core/scanner');
16
-
17
- /**
18
- * Run consistency checks on the project model.
19
- *
20
- * @param {string} projectDir - Workspace root directory
21
- * @param {{ fix?: boolean }} opts - Options
22
- */
23
- function runSync(projectDir, opts) {
24
- const fix = opts && opts.fix;
25
- const { model, config, projectPath } = buildModel(projectDir);
26
-
27
- const issues = [];
28
-
29
- const taskIds = new Set(model.tasks.map(t => t.id));
30
- const sprintIds = new Set(model.sprints.map(s => s.id));
31
- const epicIds = new Set(model.epics.map(e => e.id));
32
- const milestoneIds = new Set(model.milestones.map(m => m.id));
33
-
34
- // 1. sprint.features[] references tasks that don't exist
35
- for (const sprint of model.sprints) {
36
- if (!sprint.features || !Array.isArray(sprint.features)) continue;
37
- const missing = sprint.features.filter(f => !taskIds.has(f));
38
- if (missing.length > 0) {
39
- issues.push({
40
- type: 'error',
41
- fixable: true,
42
- message: `Sprint ${sprint.id}: features[] references missing tasks: ${missing.join(', ')}`,
43
- fix: () => {
44
- const newFeatures = sprint.features.filter(f => taskIds.has(f));
45
- updateMarkdownFile(projectPath, sprint._file, { features: newFeatures });
46
- },
47
- });
48
- }
49
- }
50
-
51
- // 2. task.sprint references sprint that doesn't exist
52
- for (const task of model.tasks) {
53
- if (!task.sprint) continue;
54
- if (!sprintIds.has(task.sprint)) {
55
- issues.push({
56
- type: 'error',
57
- fixable: true,
58
- message: `Task ${task.id}: sprint "${task.sprint}" does not exist`,
59
- fix: () => {
60
- updateMarkdownFile(projectPath, task._file, { sprint: null });
61
- },
62
- });
63
- }
64
- }
65
-
66
- // 3. epic.dependencies[] references epics that don't exist
67
- for (const epic of model.epics) {
68
- if (!epic.dependencies || !Array.isArray(epic.dependencies)) continue;
69
- const missing = epic.dependencies.filter(d => !epicIds.has(d));
70
- if (missing.length > 0) {
71
- issues.push({
72
- type: 'error',
73
- fixable: true,
74
- message: `Epic ${epic.id}: dependencies[] references missing epics: ${missing.join(', ')}`,
75
- fix: () => {
76
- const newDeps = epic.dependencies.filter(d => epicIds.has(d));
77
- updateMarkdownFile(projectPath, epic._file, { dependencies: newDeps });
78
- },
79
- });
80
- }
81
- }
82
-
83
- // 4. milestone.tracks[] references milestones that don't exist
84
- for (const ms of model.milestones) {
85
- if (!ms.tracks || !Array.isArray(ms.tracks)) continue;
86
- const missing = ms.tracks.filter(t => {
87
- // tracks can reference source:MS-ID format
88
- if (String(t).includes(':')) {
89
- const parts = String(t).split(':');
90
- const refId = parts[1];
91
- return !model.milestones.some(m => m.id === t || m._originalId === refId || m.id === refId);
92
- }
93
- return !milestoneIds.has(t);
94
- });
95
- if (missing.length > 0) {
96
- issues.push({
97
- type: 'warning',
98
- fixable: false,
99
- message: `Milestone ${ms.id}: tracks[] references missing milestones: ${missing.join(', ')}`,
100
- });
101
- }
102
- }
103
-
104
- // 5. Duplicate IDs in the same collection
105
- const collections = [
106
- { name: 'tasks', items: model.tasks },
107
- { name: 'milestones', items: model.milestones },
108
- { name: 'epics', items: model.epics },
109
- { name: 'sprints', items: model.sprints },
110
- ];
111
-
112
- for (const col of collections) {
113
- const seen = new Map();
114
- for (const item of col.items) {
115
- if (!item.id) continue;
116
- if (seen.has(item.id)) {
117
- issues.push({
118
- type: 'error',
119
- fixable: false,
120
- message: `Duplicate ${col.name.slice(0, -1)} ID "${item.id}": ${seen.get(item.id)} and ${item._file}`,
121
- });
122
- } else {
123
- seen.set(item.id, item._file);
124
- }
125
- }
126
- }
127
-
128
- // 6. Tasks under epic directory without epic README.md
129
- const msDir = config.entities.milestone.dir;
130
- const epicDir = config.entities.epic.dir;
131
- const taskDir = config.entities.task.dir;
132
-
133
- for (const task of model.tasks) {
134
- if (!task._epic || !task._milestone) continue;
135
- const epicReadmePath = path.join(projectPath, msDir, task._milestone, epicDir, task._epic, 'README.md');
136
- if (!fs.existsSync(epicReadmePath)) {
137
- issues.push({
138
- type: 'warning',
139
- fixable: false,
140
- message: `Task ${task.id}: parent epic directory "${task._epic}" has no README.md`,
141
- });
142
- }
143
- }
144
-
145
- // 7. Sprints under milestone without milestone README.md
146
- for (const sprint of model.sprints) {
147
- if (!sprint._milestone) continue;
148
- const msReadmePath = path.join(projectPath, msDir, sprint._milestone, 'README.md');
149
- if (!fs.existsSync(msReadmePath)) {
150
- issues.push({
151
- type: 'warning',
152
- fixable: false,
153
- message: `Sprint ${sprint.id}: parent milestone directory "${sprint._milestone}" has no README.md`,
154
- });
155
- }
156
- }
157
-
158
- // Report
159
- if (issues.length === 0) {
160
- console.log('\n mdboard sync — No issues found.\n');
161
- return;
162
- }
163
-
164
- const errors = issues.filter(i => i.type === 'error');
165
- const warnings = issues.filter(i => i.type === 'warning');
166
- const fixable = issues.filter(i => i.fixable);
167
-
168
- console.log('');
169
- for (const issue of issues) {
170
- const prefix = issue.type === 'error' ? ' ERROR' : ' WARN ';
171
- const suffix = issue.fixable ? ' [fixable]' : '';
172
- console.log(`${prefix}: ${issue.message}${suffix}`);
173
- }
174
- console.log('');
175
-
176
- if (fix && fixable.length > 0) {
177
- for (const issue of fixable) {
178
- issue.fix();
179
- }
180
- console.log(` mdboard sync --fix — Fixed ${fixable.length} issue${fixable.length > 1 ? 's' : ''}.`);
181
- if (warnings.length > 0) {
182
- console.log(` ${warnings.length} warning${warnings.length > 1 ? 's' : ''} remain (not auto-fixable).`);
183
- }
184
- console.log('');
185
- } else {
186
- console.log(` mdboard sync — ${issues.length} issue${issues.length > 1 ? 's' : ''} (${errors.length} error${errors.length > 1 ? 's' : ''}, ${warnings.length} warning${warnings.length > 1 ? 's' : ''}).${fixable.length > 0 ? ` ${fixable.length} fixable.` : ''}`);
187
- if (fixable.length > 0) {
188
- console.log(' Run `mdboard sync --fix` to auto-correct.');
189
- }
190
- console.log('');
191
- }
192
- }
193
-
194
- module.exports = { runSync };