create-project-arch 1.0.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 (90) hide show
  1. package/README.md +58 -0
  2. package/dist/cli.js +232 -0
  3. package/dist/cli.test.js +8 -0
  4. package/package.json +29 -0
  5. package/templates/arch-ui/.arch/edges/decision_to_domain.json +4 -0
  6. package/templates/arch-ui/.arch/edges/milestone_to_task.json +4 -0
  7. package/templates/arch-ui/.arch/edges/task_to_decision.json +4 -0
  8. package/templates/arch-ui/.arch/edges/task_to_module.json +4 -0
  9. package/templates/arch-ui/.arch/graph.json +17 -0
  10. package/templates/arch-ui/.arch/nodes/decisions.json +4 -0
  11. package/templates/arch-ui/.arch/nodes/domains.json +4 -0
  12. package/templates/arch-ui/.arch/nodes/milestones.json +4 -0
  13. package/templates/arch-ui/.arch/nodes/modules.json +4 -0
  14. package/templates/arch-ui/.arch/nodes/tasks.json +4 -0
  15. package/templates/arch-ui/app/api/architecture/map/route.ts +13 -0
  16. package/templates/arch-ui/app/api/decisions/route.ts +23 -0
  17. package/templates/arch-ui/app/api/domain-docs/route.ts +89 -0
  18. package/templates/arch-ui/app/api/domains/route.ts +10 -0
  19. package/templates/arch-ui/app/api/graph/route.ts +16 -0
  20. package/templates/arch-ui/app/api/health/route.ts +44 -0
  21. package/templates/arch-ui/app/api/node-files/route.ts +173 -0
  22. package/templates/arch-ui/app/api/phases/route.ts +10 -0
  23. package/templates/arch-ui/app/api/route.ts +22 -0
  24. package/templates/arch-ui/app/api/search/route.ts +56 -0
  25. package/templates/arch-ui/app/api/task-doc/[taskId]/route.ts +60 -0
  26. package/templates/arch-ui/app/api/tasks/route.ts +36 -0
  27. package/templates/arch-ui/app/api/trace/file/route.ts +40 -0
  28. package/templates/arch-ui/app/api/trace/task/[taskId]/route.ts +12 -0
  29. package/templates/arch-ui/app/architecture/page.tsx +5 -0
  30. package/templates/arch-ui/app/globals.css +240 -0
  31. package/templates/arch-ui/app/health/page.tsx +48 -0
  32. package/templates/arch-ui/app/layout.tsx +19 -0
  33. package/templates/arch-ui/app/page.tsx +5 -0
  34. package/templates/arch-ui/app/work/page.tsx +265 -0
  35. package/templates/arch-ui/components/app-shell.tsx +171 -0
  36. package/templates/arch-ui/components/error-boundary.tsx +53 -0
  37. package/templates/arch-ui/components/graph/arch-node.tsx +77 -0
  38. package/templates/arch-ui/components/graph/build-graph-from-dataset.ts +196 -0
  39. package/templates/arch-ui/components/graph/build-initial-graph.ts +245 -0
  40. package/templates/arch-ui/components/graph/graph-context-menu.tsx +84 -0
  41. package/templates/arch-ui/components/graph/graph-doc-panel.tsx +46 -0
  42. package/templates/arch-ui/components/graph/graph-types.ts +82 -0
  43. package/templates/arch-ui/components/graph/use-auto-layout.ts +65 -0
  44. package/templates/arch-ui/components/graph/use-connection-validation.ts +62 -0
  45. package/templates/arch-ui/components/graph/use-flow-persistence.ts +48 -0
  46. package/templates/arch-ui/components/graph-canvas.tsx +670 -0
  47. package/templates/arch-ui/components/health-panel.tsx +49 -0
  48. package/templates/arch-ui/components/inspector-context.tsx +35 -0
  49. package/templates/arch-ui/components/inspector.tsx +895 -0
  50. package/templates/arch-ui/components/markdown-viewer.tsx +74 -0
  51. package/templates/arch-ui/components/sidebar.tsx +531 -0
  52. package/templates/arch-ui/components/topbar.tsx +187 -0
  53. package/templates/arch-ui/components/work-table.tsx +57 -0
  54. package/templates/arch-ui/components/workspace-context.tsx +274 -0
  55. package/templates/arch-ui/eslint.config.js +2 -0
  56. package/templates/arch-ui/global.d.ts +1 -0
  57. package/templates/arch-ui/lib/api.ts +93 -0
  58. package/templates/arch-ui/lib/arch-model.ts +113 -0
  59. package/templates/arch-ui/lib/graph-dataset.ts +756 -0
  60. package/templates/arch-ui/lib/graph-schema.ts +408 -0
  61. package/templates/arch-ui/lib/project-root.ts +52 -0
  62. package/templates/arch-ui/lib/types.ts +116 -0
  63. package/templates/arch-ui/next-env.d.ts +6 -0
  64. package/templates/arch-ui/next.config.js +17 -0
  65. package/templates/arch-ui/package.json +38 -0
  66. package/templates/arch-ui/postcss.config.mjs +6 -0
  67. package/templates/arch-ui/tailwind.config.ts +11 -0
  68. package/templates/arch-ui/tsconfig.json +21 -0
  69. package/templates/ui-package/eslint.config.mjs +4 -0
  70. package/templates/ui-package/package.json +26 -0
  71. package/templates/ui-package/src/accordion.tsx +10 -0
  72. package/templates/ui-package/src/badge.tsx +12 -0
  73. package/templates/ui-package/src/button.tsx +32 -0
  74. package/templates/ui-package/src/card.tsx +22 -0
  75. package/templates/ui-package/src/code.tsx +6 -0
  76. package/templates/ui-package/src/command.tsx +18 -0
  77. package/templates/ui-package/src/dialog.tsx +6 -0
  78. package/templates/ui-package/src/dropdown-menu.tsx +10 -0
  79. package/templates/ui-package/src/input.tsx +6 -0
  80. package/templates/ui-package/src/navigation-menu.tsx +6 -0
  81. package/templates/ui-package/src/scroll-area.tsx +6 -0
  82. package/templates/ui-package/src/select.tsx +6 -0
  83. package/templates/ui-package/src/separator.tsx +6 -0
  84. package/templates/ui-package/src/sheet.tsx +6 -0
  85. package/templates/ui-package/src/skeleton.tsx +6 -0
  86. package/templates/ui-package/src/table.tsx +26 -0
  87. package/templates/ui-package/src/tabs.tsx +14 -0
  88. package/templates/ui-package/src/toggle-group.tsx +10 -0
  89. package/templates/ui-package/src/utils.ts +3 -0
  90. package/templates/ui-package/tsconfig.json +10 -0
@@ -0,0 +1,756 @@
1
+ import path from "node:path";
2
+ import { readdir } from "node:fs/promises";
3
+ import { execFile as execFileCb } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { ArchitectureMapData } from "./types";
6
+ import {
7
+ GRAPH_SCHEMA_VERSION,
8
+ GraphDataset,
9
+ GraphEdge,
10
+ GraphNode,
11
+ validateGraphDataset,
12
+ } from "./graph-schema";
13
+
14
+ const execFile = promisify(execFileCb);
15
+
16
+ function slugify(value: string): string {
17
+ return value
18
+ .toLowerCase()
19
+ .trim()
20
+ .replace(/[^a-z0-9]+/g, "-")
21
+ .replace(/^-+|-+$/g, "");
22
+ }
23
+
24
+ async function collectMarkdownFiles(root: string, relativeDir: string): Promise<string[]> {
25
+ const directory = path.join(root, relativeDir);
26
+ const result: string[] = [];
27
+
28
+ async function walk(current: string) {
29
+ const entries = await readdir(current, { withFileTypes: true });
30
+ for (const entry of entries) {
31
+ const full = path.join(current, entry.name);
32
+ if (entry.isDirectory()) {
33
+ await walk(full);
34
+ continue;
35
+ }
36
+ if (!entry.isFile()) continue;
37
+ if (!entry.name.toLowerCase().endsWith(".md")) continue;
38
+ result.push(path.relative(root, full).split(path.sep).join("/"));
39
+ }
40
+ }
41
+
42
+ try {
43
+ await walk(directory);
44
+ } catch {
45
+ return [];
46
+ }
47
+
48
+ return result.sort((a, b) => a.localeCompare(b));
49
+ }
50
+
51
+ async function listImmediateDirectories(root: string, relativeDir: string): Promise<string[]> {
52
+ const full = path.join(root, relativeDir);
53
+ try {
54
+ const entries = await readdir(full, { withFileTypes: true });
55
+ return entries
56
+ .filter((entry) => entry.isDirectory())
57
+ .map((entry) => `${relativeDir}/${entry.name}`.split(path.sep).join("/"))
58
+ .sort((a, b) => a.localeCompare(b));
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+
64
+ async function walkDirectories(
65
+ root: string,
66
+ relativeDir: string,
67
+ onDirectory: (relativePath: string, parentRelativePath: string | null) => Promise<void> | void,
68
+ maxDepth = 6,
69
+ ): Promise<void> {
70
+ const start = path.join(root, relativeDir);
71
+
72
+ async function walk(currentRelative: string, parentRelative: string | null, depth: number) {
73
+ if (depth > maxDepth) return;
74
+ await onDirectory(currentRelative, parentRelative);
75
+
76
+ const full = path.join(root, currentRelative);
77
+ let entries: Awaited<ReturnType<typeof readdir>>;
78
+ try {
79
+ entries = await readdir(full, { withFileTypes: true });
80
+ } catch {
81
+ return;
82
+ }
83
+
84
+ for (const entry of entries) {
85
+ if (!entry.isDirectory()) continue;
86
+ const nextRelative = `${currentRelative}/${entry.name}`.split(path.sep).join("/");
87
+ await walk(nextRelative, currentRelative, depth + 1);
88
+ }
89
+ }
90
+
91
+ await walk(relativeDir, null, 0);
92
+ }
93
+
94
+ function ensureNode(nodes: GraphNode[], node: GraphNode) {
95
+ if (nodes.some((candidate) => candidate.id === node.id)) return;
96
+ nodes.push(node);
97
+ }
98
+
99
+ function ensureEdge(edges: GraphEdge[], edge: GraphEdge) {
100
+ if (edges.some((candidate) => candidate.id === edge.id)) return;
101
+ edges.push(edge);
102
+ }
103
+
104
+ async function filterGitIgnoredPaths(root: string, paths: string[]): Promise<Set<string>> {
105
+ const unique = [...new Set(paths.filter(Boolean))];
106
+ if (unique.length === 0) return new Set();
107
+
108
+ const ignored = new Set<string>();
109
+ const chunkSize = 200;
110
+
111
+ try {
112
+ for (let index = 0; index < unique.length; index += chunkSize) {
113
+ const chunk = unique.slice(index, index + chunkSize);
114
+ if (chunk.length === 0) continue;
115
+
116
+ try {
117
+ const { stdout } = (await execFile(
118
+ "git",
119
+ ["check-ignore", ...chunk],
120
+ {
121
+ cwd: root,
122
+ encoding: "utf8",
123
+ } as Parameters<typeof execFile>[2],
124
+ )) as { stdout: string };
125
+ stdout
126
+ .split("\n")
127
+ .map((line: string) => line.trim())
128
+ .filter(Boolean)
129
+ .forEach((value) => ignored.add(value));
130
+ } catch (error) {
131
+ const details = error as { code?: number; stdout?: string };
132
+ // `git check-ignore` exits 1 when no path from this chunk is ignored.
133
+ if (details.code !== 1) {
134
+ throw error;
135
+ }
136
+ const chunkStdout = typeof details.stdout === "string" ? details.stdout : "";
137
+ chunkStdout
138
+ .split("\n")
139
+ .map((line: string) => line.trim())
140
+ .filter(Boolean)
141
+ .forEach((value) => ignored.add(value));
142
+ }
143
+ }
144
+ return ignored;
145
+ } catch (error) {
146
+ // If git is unavailable or command fails unexpectedly, do not block graph generation.
147
+ return new Set();
148
+ }
149
+ }
150
+
151
+ function scopeForProjectPath(relativePath: string): "apps" | "packages" {
152
+ return relativePath.startsWith("packages/") ? "packages" : "apps";
153
+ }
154
+
155
+ export async function buildGraphDatasetFromArchitectureMap(
156
+ root: string,
157
+ data: ArchitectureMapData,
158
+ ): Promise<GraphDataset> {
159
+ const nodes: GraphNode[] = [];
160
+ const edges: GraphEdge[] = [];
161
+
162
+ const domainId = new Map<string, string>();
163
+ const modelId = new Map<string, string>();
164
+ const phaseId = new Map<string, string>();
165
+ const storyId = new Map<string, string>();
166
+ const taskId = new Map<string, string>();
167
+ const projectNodeId = new Map<string, string>();
168
+
169
+ const archRootId = "arch:folder:root";
170
+ const archDomainFolderId = "arch:folder:domain-docs";
171
+ const archDocsFolderId = "arch:folder:architecture-docs";
172
+ const archModelFolderId = "arch:folder:arch-model";
173
+
174
+ ensureNode(nodes, {
175
+ id: archRootId,
176
+ type: "arch_folder",
177
+ title: "Architecture",
178
+ views: ["architecture-map"],
179
+ source: { path: "architecture", scope: "architecture" },
180
+ metadata: { level: "root" },
181
+ });
182
+ ensureNode(nodes, {
183
+ id: archDomainFolderId,
184
+ type: "arch_folder",
185
+ title: "Domain Docs",
186
+ views: ["architecture-map"],
187
+ source: { path: "arch-domains", scope: "arch-domains" },
188
+ metadata: { level: "group" },
189
+ });
190
+ ensureNode(nodes, {
191
+ id: archDocsFolderId,
192
+ type: "arch_folder",
193
+ title: "Architecture Docs",
194
+ views: ["architecture-map"],
195
+ source: { path: "architecture", scope: "architecture" },
196
+ metadata: { level: "group" },
197
+ });
198
+ ensureNode(nodes, {
199
+ id: archModelFolderId,
200
+ type: "arch_folder",
201
+ title: "Arch Model",
202
+ views: ["architecture-map"],
203
+ source: { path: "arch-model", scope: "arch-model" },
204
+ metadata: { level: "group" },
205
+ });
206
+
207
+ ensureEdge(edges, {
208
+ id: `contains:${archRootId}:${archDomainFolderId}`,
209
+ type: "contains",
210
+ source: archRootId,
211
+ target: archDomainFolderId,
212
+ authority: "authoritative",
213
+ });
214
+ ensureEdge(edges, {
215
+ id: `contains:${archRootId}:${archDocsFolderId}`,
216
+ type: "contains",
217
+ source: archRootId,
218
+ target: archDocsFolderId,
219
+ authority: "authoritative",
220
+ });
221
+ ensureEdge(edges, {
222
+ id: `contains:${archRootId}:${archModelFolderId}`,
223
+ type: "contains",
224
+ source: archRootId,
225
+ target: archModelFolderId,
226
+ authority: "authoritative",
227
+ });
228
+
229
+ data.nodes.domains.forEach((domain) => {
230
+ const id = `arch:domain:${slugify(domain.name)}`;
231
+ domainId.set(domain.name, id);
232
+ ensureNode(nodes, {
233
+ id,
234
+ type: "domain_doc",
235
+ title: domain.name,
236
+ description: domain.description,
237
+ views: ["architecture-map"],
238
+ source: {
239
+ path: `arch-domains/domains.json#${domain.name}`,
240
+ scope: "arch-domains",
241
+ },
242
+ metadata: {
243
+ domain: domain.name,
244
+ },
245
+ });
246
+ ensureEdge(edges, {
247
+ id: `contains:${archDomainFolderId}:${id}`,
248
+ type: "contains",
249
+ source: archDomainFolderId,
250
+ target: id,
251
+ authority: "authoritative",
252
+ });
253
+ });
254
+
255
+ const architectureDocs = await collectMarkdownFiles(root, "architecture");
256
+ const archPathFolder = new Map<string, string>();
257
+ archPathFolder.set("architecture", archDocsFolderId);
258
+
259
+ architectureDocs.forEach((docPath) => {
260
+ const parts = docPath.split("/");
261
+ let prefix = "architecture";
262
+ let parentFolderId = archDocsFolderId;
263
+
264
+ for (let i = 1; i < parts.length - 1; i++) {
265
+ prefix = `${prefix}/${parts[i]}`;
266
+ let folderId = archPathFolder.get(prefix);
267
+ if (!folderId) {
268
+ folderId = `arch:folder:${slugify(prefix)}`;
269
+ archPathFolder.set(prefix, folderId);
270
+ ensureNode(nodes, {
271
+ id: folderId,
272
+ type: "arch_folder",
273
+ title: parts[i] ?? prefix,
274
+ views: ["architecture-map"],
275
+ source: { path: prefix, scope: "architecture" },
276
+ metadata: { level: "path" },
277
+ });
278
+ ensureEdge(edges, {
279
+ id: `contains:${parentFolderId}:${folderId}`,
280
+ type: "contains",
281
+ source: parentFolderId,
282
+ target: folderId,
283
+ authority: "authoritative",
284
+ });
285
+ }
286
+ parentFolderId = folderId;
287
+ }
288
+
289
+ const id = `arch:doc:${slugify(docPath.replace(/\.md$/i, ""))}`;
290
+ ensureNode(nodes, {
291
+ id,
292
+ type: "architecture_doc",
293
+ title: docPath.split("/").pop()?.replace(/\.md$/i, "") ?? docPath,
294
+ views: ["architecture-map"],
295
+ source: {
296
+ path: docPath,
297
+ scope: "architecture",
298
+ },
299
+ metadata: {
300
+ file: docPath,
301
+ },
302
+ });
303
+ ensureEdge(edges, {
304
+ id: `contains:${parentFolderId}:${id}`,
305
+ type: "contains",
306
+ source: parentFolderId,
307
+ target: id,
308
+ authority: "authoritative",
309
+ });
310
+ });
311
+
312
+ data.nodes.decisions.forEach((decision) => {
313
+ const id = `arch:model:${slugify(decision.id)}`;
314
+ modelId.set(decision.id, id);
315
+ ensureNode(nodes, {
316
+ id,
317
+ type: "architecture_model",
318
+ title: decision.title ?? decision.id,
319
+ description: decision.status,
320
+ views: ["architecture-map"],
321
+ source: {
322
+ path: `roadmap/decisions/index.json#${decision.id}`,
323
+ scope: "roadmap",
324
+ },
325
+ metadata: {
326
+ decisionId: decision.id,
327
+ status: decision.status ?? "open",
328
+ },
329
+ });
330
+ ensureEdge(edges, {
331
+ id: `contains:${archModelFolderId}:${id}`,
332
+ type: "contains",
333
+ source: archModelFolderId,
334
+ target: id,
335
+ authority: "authoritative",
336
+ });
337
+ });
338
+
339
+ const roadmapRootId = "roadmap:folder:root";
340
+ ensureNode(nodes, {
341
+ id: roadmapRootId,
342
+ type: "roadmap_folder",
343
+ title: "Roadmap",
344
+ views: ["tasks"],
345
+ source: { path: "roadmap", scope: "roadmap" },
346
+ metadata: { level: "root" },
347
+ });
348
+
349
+ const uniquePhases = [...new Set(data.nodes.milestones.map((milestone) => milestone.phaseId))];
350
+ uniquePhases.forEach((phase) => {
351
+ const folderId = `roadmap:folder:${slugify(phase)}`;
352
+ ensureNode(nodes, {
353
+ id: folderId,
354
+ type: "roadmap_folder",
355
+ title: phase,
356
+ views: ["tasks"],
357
+ source: { path: `roadmap/phases/${phase}`, scope: "roadmap" },
358
+ metadata: { level: "group" },
359
+ });
360
+ ensureEdge(edges, {
361
+ id: `contains:${roadmapRootId}:${folderId}`,
362
+ type: "contains",
363
+ source: roadmapRootId,
364
+ target: folderId,
365
+ authority: "authoritative",
366
+ });
367
+
368
+ const id = `roadmap:epic:${slugify(phase)}`;
369
+ phaseId.set(phase, id);
370
+ ensureNode(nodes, {
371
+ id,
372
+ type: "roadmap_epic",
373
+ title: phase,
374
+ views: ["tasks"],
375
+ source: {
376
+ path: `roadmap/phases/${phase}`,
377
+ scope: "roadmap",
378
+ },
379
+ metadata: {
380
+ phase,
381
+ },
382
+ });
383
+
384
+ ensureEdge(edges, {
385
+ id: `contains:${folderId}:${id}`,
386
+ type: "contains",
387
+ source: folderId,
388
+ target: id,
389
+ authority: "authoritative",
390
+ });
391
+ });
392
+
393
+ data.nodes.milestones.forEach((milestone) => {
394
+ const phaseFolderId = `roadmap:folder:${slugify(milestone.phaseId)}`;
395
+ const milestoneFolderId = `roadmap:folder:${slugify(milestone.id)}`;
396
+
397
+ ensureNode(nodes, {
398
+ id: milestoneFolderId,
399
+ type: "roadmap_folder",
400
+ title: milestone.id,
401
+ views: ["tasks"],
402
+ source: {
403
+ path: `roadmap/phases/${milestone.phaseId}/milestones/${milestone.milestoneId}`,
404
+ scope: "roadmap",
405
+ },
406
+ metadata: { level: "path" },
407
+ });
408
+ ensureEdge(edges, {
409
+ id: `contains:${phaseFolderId}:${milestoneFolderId}`,
410
+ type: "contains",
411
+ source: phaseFolderId,
412
+ target: milestoneFolderId,
413
+ authority: "authoritative",
414
+ });
415
+
416
+ const id = `roadmap:story:${slugify(milestone.id)}`;
417
+ storyId.set(milestone.id, id);
418
+ ensureNode(nodes, {
419
+ id,
420
+ type: "roadmap_story",
421
+ title: milestone.id,
422
+ views: ["tasks"],
423
+ source: {
424
+ path: `roadmap/phases/${milestone.phaseId}/milestones/${milestone.milestoneId}`,
425
+ scope: "roadmap",
426
+ },
427
+ metadata: {
428
+ phaseId: milestone.phaseId,
429
+ milestoneId: milestone.milestoneId,
430
+ },
431
+ });
432
+ ensureEdge(edges, {
433
+ id: `contains:${milestoneFolderId}:${id}`,
434
+ type: "contains",
435
+ source: milestoneFolderId,
436
+ target: id,
437
+ authority: "authoritative",
438
+ });
439
+
440
+ const parentPhase = phaseId.get(milestone.phaseId);
441
+ if (parentPhase) {
442
+ ensureEdge(edges, {
443
+ id: `contains:${parentPhase}:${id}`,
444
+ type: "contains",
445
+ source: parentPhase,
446
+ target: id,
447
+ authority: "authoritative",
448
+ });
449
+ }
450
+ });
451
+
452
+ data.nodes.tasks.forEach((task) => {
453
+ const id = `roadmap:task:${slugify(task.id)}`;
454
+ taskId.set(task.id, id);
455
+ ensureNode(nodes, {
456
+ id,
457
+ type: "roadmap_task",
458
+ title: task.title,
459
+ views: ["tasks"],
460
+ source: {
461
+ path: `roadmap/phases/${task.id}`,
462
+ scope: "roadmap",
463
+ },
464
+ metadata: {
465
+ taskId: task.id,
466
+ milestone: task.milestone,
467
+ status: task.status,
468
+ lane: task.lane,
469
+ domain: task.domain ?? "unassigned",
470
+ },
471
+ });
472
+
473
+ const milestoneFolderId = `roadmap:folder:${slugify(task.milestone)}`;
474
+ ensureEdge(edges, {
475
+ id: `contains:${milestoneFolderId}:${id}`,
476
+ type: "contains",
477
+ source: milestoneFolderId,
478
+ target: id,
479
+ authority: "authoritative",
480
+ });
481
+ });
482
+
483
+ const projectRootId = "project:folder:root";
484
+ const appsRootId = "project:folder:apps";
485
+ const packagesRootId = "project:folder:packages";
486
+
487
+ ensureNode(nodes, {
488
+ id: projectRootId,
489
+ type: "project_folder",
490
+ title: "Project",
491
+ views: ["project"],
492
+ source: { path: ".", scope: "apps" },
493
+ metadata: { level: "root" },
494
+ });
495
+ ensureNode(nodes, {
496
+ id: appsRootId,
497
+ type: "project_folder",
498
+ title: "apps",
499
+ views: ["project"],
500
+ source: { path: "apps", scope: "apps" },
501
+ metadata: { level: "group" },
502
+ });
503
+ ensureNode(nodes, {
504
+ id: packagesRootId,
505
+ type: "project_folder",
506
+ title: "packages",
507
+ views: ["project"],
508
+ source: { path: "packages", scope: "packages" },
509
+ metadata: { level: "group" },
510
+ });
511
+ ensureEdge(edges, {
512
+ id: `contains:${projectRootId}:${appsRootId}`,
513
+ type: "contains",
514
+ source: projectRootId,
515
+ target: appsRootId,
516
+ authority: "authoritative",
517
+ });
518
+ ensureEdge(edges, {
519
+ id: `contains:${projectRootId}:${packagesRootId}`,
520
+ type: "contains",
521
+ source: projectRootId,
522
+ target: packagesRootId,
523
+ authority: "authoritative",
524
+ });
525
+
526
+ projectNodeId.set("apps", appsRootId);
527
+ projectNodeId.set("packages", packagesRootId);
528
+
529
+ const appDirs = await listImmediateDirectories(root, "apps");
530
+ for (const appPath of appDirs) {
531
+ const appSlug = appPath.split("/")[1] ?? appPath;
532
+ const appNodeId = `project:app:${slugify(appSlug)}`;
533
+ projectNodeId.set(appPath, appNodeId);
534
+ ensureNode(nodes, {
535
+ id: appNodeId,
536
+ type: "app",
537
+ title: appSlug,
538
+ views: ["project"],
539
+ source: { path: appPath, scope: "apps" },
540
+ metadata: { app: appSlug },
541
+ });
542
+ ensureEdge(edges, {
543
+ id: `contains:${appsRootId}:${appNodeId}`,
544
+ type: "contains",
545
+ source: appsRootId,
546
+ target: appNodeId,
547
+ authority: "authoritative",
548
+ });
549
+
550
+ await walkDirectories(root, appPath, (relativePath, parentRelativePath) => {
551
+ if (relativePath === appPath) return;
552
+ const folderId = `project:folder:${slugify(relativePath)}`;
553
+ projectNodeId.set(relativePath, folderId);
554
+ ensureNode(nodes, {
555
+ id: folderId,
556
+ type: "project_folder",
557
+ title: relativePath.split("/").pop() ?? relativePath,
558
+ views: ["project"],
559
+ source: { path: relativePath, scope: scopeForProjectPath(relativePath) },
560
+ metadata: { level: "path" },
561
+ });
562
+
563
+ const parentId = parentRelativePath ? projectNodeId.get(parentRelativePath) : appNodeId;
564
+ ensureEdge(edges, {
565
+ id: `contains:${parentId ?? appNodeId}:${folderId}`,
566
+ type: "contains",
567
+ source: parentId ?? appNodeId,
568
+ target: folderId,
569
+ authority: "authoritative",
570
+ });
571
+ });
572
+ }
573
+
574
+ const packageDirs = await listImmediateDirectories(root, "packages");
575
+ for (const packagePath of packageDirs) {
576
+ const packageSlug = packagePath.split("/")[1] ?? packagePath;
577
+ const packageNodeId = `project:package:${slugify(packageSlug)}`;
578
+ projectNodeId.set(packagePath, packageNodeId);
579
+ ensureNode(nodes, {
580
+ id: packageNodeId,
581
+ type: "package",
582
+ title: packageSlug,
583
+ views: ["project"],
584
+ source: { path: packagePath, scope: "packages" },
585
+ metadata: { package: packageSlug },
586
+ });
587
+ ensureEdge(edges, {
588
+ id: `contains:${packagesRootId}:${packageNodeId}`,
589
+ type: "contains",
590
+ source: packagesRootId,
591
+ target: packageNodeId,
592
+ authority: "authoritative",
593
+ });
594
+
595
+ await walkDirectories(root, packagePath, (relativePath, parentRelativePath) => {
596
+ if (relativePath === packagePath) return;
597
+ const folderId = `project:folder:${slugify(relativePath)}`;
598
+ projectNodeId.set(relativePath, folderId);
599
+ ensureNode(nodes, {
600
+ id: folderId,
601
+ type: "project_folder",
602
+ title: relativePath.split("/").pop() ?? relativePath,
603
+ views: ["project"],
604
+ source: { path: relativePath, scope: scopeForProjectPath(relativePath) },
605
+ metadata: { level: "path" },
606
+ });
607
+
608
+ const parentId = parentRelativePath ? projectNodeId.get(parentRelativePath) : packageNodeId;
609
+ ensureEdge(edges, {
610
+ id: `contains:${parentId ?? packageNodeId}:${folderId}`,
611
+ type: "contains",
612
+ source: parentId ?? packageNodeId,
613
+ target: folderId,
614
+ authority: "authoritative",
615
+ });
616
+ });
617
+ }
618
+
619
+ data.nodes.modules.forEach((moduleRef) => {
620
+ const normalized = moduleRef.name.split(path.sep).join("/");
621
+
622
+ // Avoid duplicate semantic nodes like app "docs" + module "apps/docs".
623
+ if (projectNodeId.has(normalized)) {
624
+ return;
625
+ }
626
+
627
+ const parts = normalized.split("/");
628
+ const rootScope = parts[0] === "packages" ? "packages" : "apps";
629
+ const isComponent = /\.(tsx|jsx)$/i.test(normalized) || normalized.includes("/components/");
630
+ const nodeType = isComponent ? "component" : "module";
631
+ const childId = `project:${nodeType}:${slugify(normalized)}`;
632
+ projectNodeId.set(moduleRef.name, childId);
633
+
634
+ ensureNode(nodes, {
635
+ id: childId,
636
+ type: nodeType,
637
+ title: moduleRef.name,
638
+ description: moduleRef.description,
639
+ views: ["project"],
640
+ source: {
641
+ path: moduleRef.name,
642
+ scope: rootScope,
643
+ },
644
+ metadata: {
645
+ moduleType: moduleRef.type ?? "module",
646
+ },
647
+ });
648
+
649
+ const parentPath = normalized.includes("/")
650
+ ? normalized.slice(0, normalized.lastIndexOf("/"))
651
+ : rootScope;
652
+
653
+ let parentId = projectNodeId.get(parentPath);
654
+ if (!parentId) {
655
+ const rootParent = rootScope === "packages" ? packagesRootId : appsRootId;
656
+ parentId = rootParent;
657
+ }
658
+
659
+ ensureEdge(edges, {
660
+ id: `contains:${parentId}:${childId}`,
661
+ type: "contains",
662
+ source: parentId,
663
+ target: childId,
664
+ authority: "authoritative",
665
+ });
666
+ });
667
+
668
+ data.edges.milestoneToTask.forEach((edge) => {
669
+ const source = storyId.get(edge.milestone);
670
+ const target = taskId.get(edge.task);
671
+ if (!source || !target) return;
672
+ ensureEdge(edges, {
673
+ id: `contains:${source}:${target}`,
674
+ type: "contains",
675
+ source,
676
+ target,
677
+ authority: "authoritative",
678
+ });
679
+ });
680
+
681
+ data.edges.taskToDecision.forEach((edge) => {
682
+ const source = taskId.get(edge.task);
683
+ const target = modelId.get(edge.decision);
684
+ if (!source || !target) return;
685
+ ensureEdge(edges, {
686
+ id: `references:${source}:${target}`,
687
+ type: "references",
688
+ source,
689
+ target,
690
+ authority: "authoritative",
691
+ });
692
+ });
693
+
694
+ data.edges.taskToModule.forEach((edge) => {
695
+ const source = projectNodeId.get(edge.module);
696
+ const target = taskId.get(edge.task);
697
+ if (!source || !target) return;
698
+ ensureEdge(edges, {
699
+ id: `implements:${source}:${target}`,
700
+ type: "implements",
701
+ source,
702
+ target,
703
+ authority: "authoritative",
704
+ });
705
+ });
706
+
707
+ data.edges.decisionToDomain.forEach((edge) => {
708
+ const source = modelId.get(edge.decision);
709
+ const target = domainId.get(edge.domain);
710
+ if (!source || !target) return;
711
+ ensureEdge(edges, {
712
+ id: `references:${source}:${target}`,
713
+ type: "references",
714
+ source,
715
+ target,
716
+ authority: "authoritative",
717
+ });
718
+ });
719
+
720
+ const sourcePathByNodeId = new Map<string, string>();
721
+ nodes.forEach((node) => {
722
+ const sourcePath = node.source.path.split("#")[0]?.trim() ?? "";
723
+ sourcePathByNodeId.set(node.id, sourcePath);
724
+ });
725
+
726
+ const ignoredPaths = await filterGitIgnoredPaths(
727
+ root,
728
+ [...sourcePathByNodeId.values()].filter((value) => value !== "." && value.length > 0),
729
+ );
730
+
731
+ const filteredNodes = nodes.filter((node) => {
732
+ const sourcePath = sourcePathByNodeId.get(node.id) ?? "";
733
+ if (!sourcePath || sourcePath === ".") return true;
734
+ return !ignoredPaths.has(sourcePath);
735
+ });
736
+ const allowedNodeIds = new Set(filteredNodes.map((node) => node.id));
737
+ const filteredEdges = edges.filter(
738
+ (edge) => allowedNodeIds.has(edge.source) && allowedNodeIds.has(edge.target),
739
+ );
740
+
741
+ return {
742
+ schemaVersion: GRAPH_SCHEMA_VERSION,
743
+ generatedAt: new Date().toISOString(),
744
+ nodes: filteredNodes,
745
+ edges: filteredEdges,
746
+ };
747
+ }
748
+
749
+ export async function buildValidatedGraphDataset(root: string, data: ArchitectureMapData) {
750
+ const dataset = await buildGraphDatasetFromArchitectureMap(root, data);
751
+ const validation = validateGraphDataset(dataset);
752
+ return {
753
+ dataset,
754
+ validation,
755
+ };
756
+ }