busy-cli 0.1.2

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 (128) hide show
  1. package/README.md +129 -0
  2. package/dist/builders/context.d.ts +50 -0
  3. package/dist/builders/context.d.ts.map +1 -0
  4. package/dist/builders/context.js +190 -0
  5. package/dist/cache/index.d.ts +100 -0
  6. package/dist/cache/index.d.ts.map +1 -0
  7. package/dist/cache/index.js +270 -0
  8. package/dist/cli/index.d.ts +3 -0
  9. package/dist/cli/index.d.ts.map +1 -0
  10. package/dist/cli/index.js +463 -0
  11. package/dist/commands/package.d.ts +96 -0
  12. package/dist/commands/package.d.ts.map +1 -0
  13. package/dist/commands/package.js +285 -0
  14. package/dist/index.d.ts +7 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +7 -0
  17. package/dist/loader.d.ts +6 -0
  18. package/dist/loader.d.ts.map +1 -0
  19. package/dist/loader.js +361 -0
  20. package/dist/merge.d.ts +16 -0
  21. package/dist/merge.d.ts.map +1 -0
  22. package/dist/merge.js +102 -0
  23. package/dist/package/manifest.d.ts +59 -0
  24. package/dist/package/manifest.d.ts.map +1 -0
  25. package/dist/package/manifest.js +265 -0
  26. package/dist/parser.d.ts +28 -0
  27. package/dist/parser.d.ts.map +1 -0
  28. package/dist/parser.js +220 -0
  29. package/dist/parsers/frontmatter.d.ts +14 -0
  30. package/dist/parsers/frontmatter.d.ts.map +1 -0
  31. package/dist/parsers/frontmatter.js +110 -0
  32. package/dist/parsers/imports.d.ts +48 -0
  33. package/dist/parsers/imports.d.ts.map +1 -0
  34. package/dist/parsers/imports.js +147 -0
  35. package/dist/parsers/links.d.ts +12 -0
  36. package/dist/parsers/links.d.ts.map +1 -0
  37. package/dist/parsers/links.js +79 -0
  38. package/dist/parsers/localdefs.d.ts +6 -0
  39. package/dist/parsers/localdefs.d.ts.map +1 -0
  40. package/dist/parsers/localdefs.js +132 -0
  41. package/dist/parsers/operations.d.ts +32 -0
  42. package/dist/parsers/operations.d.ts.map +1 -0
  43. package/dist/parsers/operations.js +313 -0
  44. package/dist/parsers/sections.d.ts +15 -0
  45. package/dist/parsers/sections.d.ts.map +1 -0
  46. package/dist/parsers/sections.js +173 -0
  47. package/dist/parsers/tools.d.ts +30 -0
  48. package/dist/parsers/tools.d.ts.map +1 -0
  49. package/dist/parsers/tools.js +178 -0
  50. package/dist/parsers/triggers.d.ts +35 -0
  51. package/dist/parsers/triggers.d.ts.map +1 -0
  52. package/dist/parsers/triggers.js +219 -0
  53. package/dist/providers/base.d.ts +60 -0
  54. package/dist/providers/base.d.ts.map +1 -0
  55. package/dist/providers/base.js +34 -0
  56. package/dist/providers/github.d.ts +18 -0
  57. package/dist/providers/github.d.ts.map +1 -0
  58. package/dist/providers/github.js +109 -0
  59. package/dist/providers/gitlab.d.ts +18 -0
  60. package/dist/providers/gitlab.d.ts.map +1 -0
  61. package/dist/providers/gitlab.js +101 -0
  62. package/dist/providers/index.d.ts +13 -0
  63. package/dist/providers/index.d.ts.map +1 -0
  64. package/dist/providers/index.js +17 -0
  65. package/dist/providers/local.d.ts +31 -0
  66. package/dist/providers/local.d.ts.map +1 -0
  67. package/dist/providers/local.js +116 -0
  68. package/dist/providers/url.d.ts +16 -0
  69. package/dist/providers/url.d.ts.map +1 -0
  70. package/dist/providers/url.js +45 -0
  71. package/dist/registry/index.d.ts +99 -0
  72. package/dist/registry/index.d.ts.map +1 -0
  73. package/dist/registry/index.js +320 -0
  74. package/dist/types/schema.d.ts +3259 -0
  75. package/dist/types/schema.d.ts.map +1 -0
  76. package/dist/types/schema.js +258 -0
  77. package/dist/utils/logger.d.ts +19 -0
  78. package/dist/utils/logger.d.ts.map +1 -0
  79. package/dist/utils/logger.js +23 -0
  80. package/dist/utils/slugify.d.ts +14 -0
  81. package/dist/utils/slugify.d.ts.map +1 -0
  82. package/dist/utils/slugify.js +28 -0
  83. package/package.json +61 -0
  84. package/src/__tests__/cache.test.ts +393 -0
  85. package/src/__tests__/cli-package.test.ts +667 -0
  86. package/src/__tests__/fixtures/automated-workflow.busy.md +84 -0
  87. package/src/__tests__/fixtures/concept.busy.md +30 -0
  88. package/src/__tests__/fixtures/document.busy.md +44 -0
  89. package/src/__tests__/fixtures/simple-operation.busy.md +45 -0
  90. package/src/__tests__/fixtures/tool-document.busy.md +71 -0
  91. package/src/__tests__/fixtures/tool.busy.md +54 -0
  92. package/src/__tests__/imports.test.ts +244 -0
  93. package/src/__tests__/integration.test.ts +432 -0
  94. package/src/__tests__/operations.test.ts +408 -0
  95. package/src/__tests__/package-manifest.test.ts +455 -0
  96. package/src/__tests__/providers.test.ts +672 -0
  97. package/src/__tests__/registry.test.ts +402 -0
  98. package/src/__tests__/schema.test.ts +467 -0
  99. package/src/__tests__/tools.test.ts +376 -0
  100. package/src/__tests__/triggers.test.ts +312 -0
  101. package/src/builders/context.ts +294 -0
  102. package/src/cache/index.ts +312 -0
  103. package/src/cli/index.ts +514 -0
  104. package/src/commands/package.ts +392 -0
  105. package/src/index.ts +46 -0
  106. package/src/loader.ts +474 -0
  107. package/src/merge.ts +126 -0
  108. package/src/package/manifest.ts +349 -0
  109. package/src/parser.ts +278 -0
  110. package/src/parsers/frontmatter.ts +135 -0
  111. package/src/parsers/imports.ts +196 -0
  112. package/src/parsers/links.ts +108 -0
  113. package/src/parsers/localdefs.ts +166 -0
  114. package/src/parsers/operations.ts +404 -0
  115. package/src/parsers/sections.ts +230 -0
  116. package/src/parsers/tools.ts +215 -0
  117. package/src/parsers/triggers.ts +252 -0
  118. package/src/providers/base.ts +77 -0
  119. package/src/providers/github.ts +129 -0
  120. package/src/providers/gitlab.ts +121 -0
  121. package/src/providers/index.ts +25 -0
  122. package/src/providers/local.ts +129 -0
  123. package/src/providers/url.ts +56 -0
  124. package/src/registry/index.ts +408 -0
  125. package/src/types/schema.ts +369 -0
  126. package/src/utils/logger.ts +25 -0
  127. package/src/utils/slugify.ts +31 -0
  128. package/tsconfig.json +21 -0
package/src/loader.ts ADDED
@@ -0,0 +1,474 @@
1
+ import { readFile } from 'fs/promises';
2
+ import fg from 'fast-glob';
3
+ import path from 'path';
4
+ import {
5
+ Repo,
6
+ BusyDocument,
7
+ Playbook,
8
+ ConceptBase,
9
+ LocalDef,
10
+ Operation,
11
+ ImportDef,
12
+ Edge,
13
+ Section,
14
+ File,
15
+ } from './types/schema.js';
16
+ import { parseFrontMatter } from './parsers/frontmatter.js';
17
+ import { parseSections, getAllSections, findSection } from './parsers/sections.js';
18
+ import { extractLocalDefs } from './parsers/localdefs.js';
19
+ import { extractOperations } from './parsers/operations.js';
20
+ import { extractImports, legacyResolveImportTarget } from './parsers/imports.js';
21
+ import { extractLinksFromSection } from './parsers/links.js';
22
+ import { debug, warn } from './utils/logger.js';
23
+
24
+ /**
25
+ * Load and index a workspace from glob patterns
26
+ */
27
+ export async function loadRepo(globs: string[]): Promise<Repo> {
28
+ debug.parser('Loading repo from globs: %o', globs);
29
+
30
+ // Find all markdown files
31
+ const filePaths = await fg(globs, {
32
+ absolute: true,
33
+ onlyFiles: true,
34
+ });
35
+
36
+ debug.parser('Found %d files', filePaths.length);
37
+
38
+ // Sort files for determinism
39
+ filePaths.sort();
40
+
41
+ // Build file map for resolution
42
+ const fileMap = new Map<string, { docId: string; path: string }>();
43
+
44
+ // First pass: parse frontmatter to build file map
45
+ const fileContents = new Map<string, string>();
46
+
47
+ for (const filePath of filePaths) {
48
+ const content = await readFile(filePath, 'utf-8');
49
+ fileContents.set(filePath, content);
50
+
51
+ const { docId } = parseFrontMatter(content, filePath);
52
+
53
+ // Store multiple variants for resolution:
54
+ // - Full basename: "document.busy.md"
55
+ // - Without .busy: "document.md" (for imports that reference old extension)
56
+ // - Just name: "document"
57
+ const basename = path.basename(filePath);
58
+ const withoutBusy = basename.replace('.busy.md', '.md');
59
+ const nameOnly = basename.replace(/\.busy\.md$/, '').replace(/\.md$/, '');
60
+
61
+ fileMap.set(basename, { docId, path: filePath });
62
+ if (withoutBusy !== basename) {
63
+ fileMap.set(withoutBusy, { docId, path: filePath });
64
+ }
65
+ fileMap.set(nameOnly, { docId, path: filePath });
66
+ }
67
+
68
+ // Second pass: parse documents
69
+ const files: File[] = []; // Lightweight file representations
70
+ const docs: (BusyDocument | Playbook)[] = []; // Full concept definitions
71
+ const allLocaldefs = new Map<string, LocalDef>();
72
+ const allOperations = new Map<string, Operation>();
73
+ const allImports: ImportDef[] = [];
74
+ const allEdges: Edge[] = [];
75
+ const allSections = new Map<string, Section>();
76
+
77
+ // Store document parts temporarily before building final docs
78
+ const docParts = new Map<string, {
79
+ filePath: string;
80
+ content: string;
81
+ frontmatter: any;
82
+ docId: string;
83
+ types: string[];
84
+ extends: string[];
85
+ sections: Section[];
86
+ localdefs: LocalDef[];
87
+ operations: Operation[];
88
+ setup: any;
89
+ imports: ImportDef[];
90
+ symbols: Record<string, { docId?: string; slug?: string }>;
91
+ }>();
92
+
93
+ for (const filePath of filePaths) {
94
+ const content = fileContents.get(filePath)!;
95
+
96
+ // Parse frontmatter
97
+ const { frontmatter, content: mdContent, docId, kind, types, extends: extends_ } =
98
+ parseFrontMatter(content, filePath);
99
+
100
+ // Parse sections
101
+ const sections = parseSections(mdContent, docId, filePath);
102
+
103
+ // Create file representation (lightweight - just sections)
104
+ files.push({
105
+ docId,
106
+ path: filePath,
107
+ name: frontmatter.Name,
108
+ sections,
109
+ });
110
+
111
+ // Index all sections
112
+ for (const section of getAllSections(sections)) {
113
+ allSections.set(section.id, section);
114
+ }
115
+
116
+ // Extract local definitions
117
+ const localdefs = extractLocalDefs(sections, docId, filePath);
118
+ for (const localdef of localdefs) {
119
+ allLocaldefs.set(localdef.id, localdef);
120
+ }
121
+
122
+ // Extract operations
123
+ const operations = extractOperations(sections, docId, filePath);
124
+ for (const operation of operations) {
125
+ allOperations.set(operation.id, operation);
126
+ }
127
+
128
+ // Extract setup (if present)
129
+ const setupSection = findSection(sections, 'setup');
130
+ const setup = setupSection ? {
131
+ kind: 'setup' as const,
132
+ id: `${docId}::setup`, // Use :: for concept IDs
133
+ docId,
134
+ slug: 'setup',
135
+ name: 'Setup',
136
+ content: setupSection.content,
137
+ types: [],
138
+ extends: [],
139
+ sectionRef: setupSection.id, // sectionRef uses # for section references
140
+ } : undefined;
141
+
142
+ // Extract imports
143
+ const { imports, symbols } = extractImports(content, docId);
144
+
145
+ // Store document parts
146
+ docParts.set(docId, {
147
+ filePath,
148
+ content,
149
+ frontmatter,
150
+ docId,
151
+ types,
152
+ extends: extends_,
153
+ sections,
154
+ localdefs,
155
+ operations,
156
+ setup,
157
+ imports,
158
+ symbols,
159
+ });
160
+
161
+ // Resolve imports
162
+ for (const importDef of imports) {
163
+ const resolved = legacyResolveImportTarget(importDef.target, docId, fileMap);
164
+
165
+ // Store resolved as ConceptId (string) per schema
166
+ if (resolved.docId) {
167
+ const resolvedId = resolved.slug
168
+ ? `${resolved.docId}#${resolved.slug}`
169
+ : resolved.docId;
170
+ importDef.resolved = resolvedId;
171
+
172
+ // Create import edge
173
+ allEdges.push({
174
+ from: docId,
175
+ to: resolvedId,
176
+ role: 'imports',
177
+ });
178
+ }
179
+
180
+ // Update symbol table (keeps object format for easy lookup)
181
+ if (symbols[importDef.label]) {
182
+ symbols[importDef.label] = resolved;
183
+ }
184
+
185
+ allImports.push(importDef);
186
+ }
187
+
188
+ // Extract links and create edges
189
+ for (const section of getAllSections(sections)) {
190
+ const linkEdges = extractLinksFromSection(
191
+ section,
192
+ section.content,
193
+ symbols,
194
+ fileMap
195
+ );
196
+ allEdges.push(...linkEdges);
197
+ }
198
+
199
+ // Create extends edges for local definitions
200
+ for (const localdef of localdefs) {
201
+ for (const parent of localdef.extends) {
202
+ const resolvedParent = resolveSymbol(parent, docId, allLocaldefs, docs, symbols);
203
+ if (resolvedParent) {
204
+ allEdges.push({
205
+ from: localdef.id,
206
+ to: resolvedParent,
207
+ role: 'extends',
208
+ });
209
+ } else {
210
+ warn(`Unresolved extends: ${parent} in ${localdef.id}`);
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ // Build final document structures with inline arrays
217
+ for (const [docId, parts] of docParts) {
218
+ const isPlaybook = parts.types.some((t) => t.toLowerCase() === 'playbook');
219
+
220
+ if (isPlaybook) {
221
+ // Extract sequence from ExecutePlaybook operation
222
+ const sequence = extractPlaybookSequence(parts.sections);
223
+
224
+ const doc: Playbook = {
225
+ kind: 'playbook',
226
+ id: parts.docId,
227
+ docId: parts.docId,
228
+ slug: parts.docId.toLowerCase(),
229
+ name: parts.frontmatter.Name,
230
+ content: parts.content,
231
+ types: parts.types,
232
+ extends: parts.extends,
233
+ sectionRef: `${parts.docId}#`, // Root reference
234
+ imports: parts.imports,
235
+ localdefs: parts.localdefs,
236
+ setup: parts.setup!,
237
+ operations: parts.operations,
238
+ sequence,
239
+ };
240
+ docs.push(doc);
241
+ } else {
242
+ const doc: BusyDocument = {
243
+ kind: 'document',
244
+ id: parts.docId,
245
+ docId: parts.docId,
246
+ slug: parts.docId.toLowerCase(),
247
+ name: parts.frontmatter.Name,
248
+ content: parts.content,
249
+ types: parts.types,
250
+ extends: parts.extends,
251
+ sectionRef: `${parts.docId}#`, // Root reference
252
+ imports: parts.imports,
253
+ localdefs: parts.localdefs,
254
+ setup: parts.setup!,
255
+ operations: parts.operations,
256
+ };
257
+ docs.push(doc);
258
+ }
259
+ }
260
+
261
+ // Inherit operations from parent documents
262
+ inheritOperations(docs, allOperations);
263
+
264
+ // Build concepts array (includes all documents as ConceptBase)
265
+ const concepts: ConceptBase[] = docs.map((doc) => ({
266
+ kind: doc.kind,
267
+ id: doc.id,
268
+ docId: doc.docId,
269
+ slug: doc.slug,
270
+ name: doc.name,
271
+ content: doc.content,
272
+ types: doc.types,
273
+ extends: doc.extends,
274
+ sectionRef: doc.sectionRef,
275
+ children: [], // ConceptBase has children for hierarchy
276
+ }));
277
+
278
+ // Build byId index
279
+ const byId: Record<string, Section | LocalDef | Operation | ConceptBase> = {};
280
+
281
+ for (const concept of concepts) {
282
+ byId[concept.id] = concept;
283
+ }
284
+
285
+ for (const [id, section] of allSections) {
286
+ byId[id] = section;
287
+ }
288
+
289
+ for (const [id, localdef] of allLocaldefs) {
290
+ byId[id] = localdef;
291
+ }
292
+
293
+ for (const [id, operation] of allOperations) {
294
+ byId[id] = operation;
295
+ }
296
+
297
+ // Reclassify edges based on target type
298
+ // Links to operations should be 'calls', links to defs/concepts should be 'ref'
299
+ for (const edge of allEdges) {
300
+ if (edge.role === 'ref') {
301
+ const target = byId[edge.to];
302
+ if (target && target.kind === 'operation') {
303
+ edge.role = 'calls';
304
+ }
305
+ }
306
+ }
307
+
308
+ // Build byFile index
309
+ const byFile: Record<string, { concept: BusyDocument | Playbook; bySlug: Record<string, Section> }> = {};
310
+
311
+ for (const doc of docs) {
312
+ const bySlug: Record<string, Section> = {};
313
+ const parts = docParts.get(doc.docId);
314
+
315
+ if (parts) {
316
+ for (const section of getAllSections(parts.sections)) {
317
+ bySlug[section.slug] = section;
318
+ }
319
+ }
320
+
321
+ byFile[doc.docId] = { concept: doc, bySlug };
322
+ }
323
+
324
+ const repo: Repo = {
325
+ files,
326
+ concepts,
327
+ localdefs: Object.fromEntries(allLocaldefs),
328
+ operations: Object.fromEntries(allOperations),
329
+ imports: allImports,
330
+ byId,
331
+ byFile,
332
+ edges: allEdges,
333
+ };
334
+
335
+ debug.parser(
336
+ 'Loaded repo: %d docs, %d concepts, %d localdefs, %d operations, %d imports, %d edges',
337
+ docs.length,
338
+ concepts.length,
339
+ allLocaldefs.size,
340
+ allOperations.size,
341
+ allImports.length,
342
+ allEdges.length
343
+ );
344
+
345
+ return repo;
346
+ }
347
+
348
+ /**
349
+ * Inherit operations from parent documents
350
+ * Inherits from:
351
+ * 1. Documents in the 'extends' array (explicit extension)
352
+ * 2. Documents in the 'types' array (implicit type-based inheritance)
353
+ */
354
+ function inheritOperations(
355
+ docs: (BusyDocument | Playbook)[],
356
+ allOperations: Map<string, Operation>
357
+ ): void {
358
+ // Build doc lookup by name
359
+ const docByName = new Map<string, BusyDocument | Playbook>();
360
+ for (const doc of docs) {
361
+ docByName.set(doc.name, doc);
362
+ }
363
+
364
+ // Process each document
365
+ for (const doc of docs) {
366
+ // Collect parent names from both extends and types
367
+ const parentNames = [...doc.extends, ...doc.types];
368
+
369
+ if (parentNames.length === 0) continue;
370
+
371
+ // Get operations currently in this document
372
+ const existingOps = new Set<string>();
373
+ for (const [id, op] of allOperations) {
374
+ if (op.docId === doc.docId) {
375
+ existingOps.add(op.slug);
376
+ }
377
+ }
378
+
379
+ // Inherit from parent documents
380
+ for (const parentName of parentNames) {
381
+ const parentDoc = docByName.get(parentName);
382
+ if (!parentDoc) {
383
+ debug.parser('Parent document not found: %s', parentName);
384
+ continue;
385
+ }
386
+
387
+ // Find all operations in parent document
388
+ for (const [id, op] of allOperations) {
389
+ if (op.docId === parentDoc.docId) {
390
+ // If operation not overridden in child, inherit it
391
+ if (!existingOps.has(op.slug)) {
392
+ const inheritedOp: Operation = {
393
+ ...op,
394
+ id: `${doc.docId}::${op.slug}`, // Use :: for concept IDs
395
+ docId: doc.docId,
396
+ };
397
+ allOperations.set(inheritedOp.id, inheritedOp);
398
+ existingOps.add(op.slug);
399
+ debug.parser(
400
+ 'Inherited operation %s from %s to %s',
401
+ op.name,
402
+ parentDoc.name,
403
+ doc.name
404
+ );
405
+ }
406
+ }
407
+ }
408
+ }
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Resolve a symbol (name or label) to a node ID
414
+ */
415
+ function resolveSymbol(
416
+ nameOrLabel: string,
417
+ currentDocId: string,
418
+ localdefs: Map<string, LocalDef>,
419
+ docs: (BusyDocument | Playbook)[],
420
+ symbols: Record<string, { docId?: string; slug?: string }>
421
+ ): string | undefined {
422
+ // 1. Check for LocalDef in same doc
423
+ const localdefId = `${currentDocId}::${nameOrLabel.toLowerCase()}`;
424
+ if (localdefs.has(localdefId)) {
425
+ return localdefId;
426
+ }
427
+
428
+ // 2. Check for Concept/Doc by Name
429
+ const doc = docs.find((d) => d.name === nameOrLabel);
430
+ if (doc) {
431
+ return doc.docId;
432
+ }
433
+
434
+ // 3. Check import symbol table
435
+ const symbol = symbols[nameOrLabel];
436
+ if (symbol?.docId) {
437
+ return symbol.slug ? `${symbol.docId}#${symbol.slug}` : symbol.docId;
438
+ }
439
+
440
+ return undefined;
441
+ }
442
+
443
+ /**
444
+ * Extract sequence of operations from a playbook's ExecutePlaybook operation
445
+ * Looks for sections with "Step" in the title and extracts Target metadata
446
+ */
447
+ function extractPlaybookSequence(sections: Section[]): string[] {
448
+ const sequence: string[] = [];
449
+
450
+ // Find ExecutePlaybook operation in the Operations section
451
+ const allSecs = getAllSections(sections);
452
+ const executePlaybook = allSecs.find(
453
+ (sec) => sec.title.toLowerCase() === 'executeplaybook'
454
+ );
455
+
456
+ if (!executePlaybook) {
457
+ return sequence;
458
+ }
459
+
460
+ // Look for child sections that are steps (contain "step" in title, case-insensitive)
461
+ for (const child of executePlaybook.children) {
462
+ if (child.title.toLowerCase().includes('step')) {
463
+ // Extract Target field from content
464
+ // Pattern: - **Target:** `OperationName`
465
+ const targetMatch = child.content.match(/^\s*-\s*\*\*Target:\*\*\s*`([^`]+)`/m);
466
+ if (targetMatch) {
467
+ sequence.push(targetMatch[1]);
468
+ debug.parser('Found playbook sequence step: %s -> %s', child.title, targetMatch[1]);
469
+ }
470
+ }
471
+ }
472
+
473
+ return sequence;
474
+ }
package/src/merge.ts ADDED
@@ -0,0 +1,126 @@
1
+ import { Repo, File, ConceptBase, LocalDef, Operation, ImportDef, Edge, Section } from './types/schema.js';
2
+ import { debug } from './utils/logger.js';
3
+
4
+ /**
5
+ * Merge multiple repos into a single repo
6
+ * Later repos override earlier ones when there are conflicts
7
+ */
8
+ export function mergeRepos(...repos: Repo[]): Repo {
9
+ if (repos.length === 0) {
10
+ throw new Error('At least one repo is required');
11
+ }
12
+
13
+ if (repos.length === 1) {
14
+ return repos[0];
15
+ }
16
+
17
+ debug.parser('Merging %d repos', repos.length);
18
+
19
+ const merged: Repo = {
20
+ files: [],
21
+ concepts: [],
22
+ localdefs: {},
23
+ operations: {},
24
+ imports: [],
25
+ byId: {},
26
+ byFile: {},
27
+ edges: [],
28
+ };
29
+
30
+ // Merge files (later repos override)
31
+ const filesByDocId = new Map<string, File>();
32
+ for (const repo of repos) {
33
+ for (const file of repo.files) {
34
+ filesByDocId.set(file.docId, file);
35
+ }
36
+ }
37
+ merged.files = Array.from(filesByDocId.values());
38
+
39
+ // Merge concepts (later repos override)
40
+ const conceptsById = new Map<string, ConceptBase>();
41
+ for (const repo of repos) {
42
+ for (const concept of repo.concepts) {
43
+ conceptsById.set(concept.id, concept);
44
+ }
45
+ }
46
+ merged.concepts = Array.from(conceptsById.values());
47
+
48
+ // Merge localdefs (later repos override)
49
+ for (const repo of repos) {
50
+ Object.assign(merged.localdefs, repo.localdefs);
51
+ }
52
+
53
+ // Merge operations (later repos override)
54
+ for (const repo of repos) {
55
+ Object.assign(merged.operations, repo.operations);
56
+ }
57
+
58
+ // Merge imports (append all, handle duplicates by ID)
59
+ const importsByDocId = new Map<string, Map<string, ImportDef>>();
60
+ for (const repo of repos) {
61
+ for (const imp of repo.imports) {
62
+ if (!importsByDocId.has(imp.docId)) {
63
+ importsByDocId.set(imp.docId, new Map());
64
+ }
65
+ importsByDocId.get(imp.docId)!.set(imp.id, imp);
66
+ }
67
+ }
68
+ for (const docImports of importsByDocId.values()) {
69
+ merged.imports.push(...docImports.values());
70
+ }
71
+
72
+ // Merge byId (later repos override)
73
+ for (const repo of repos) {
74
+ Object.assign(merged.byId, repo.byId);
75
+ }
76
+
77
+ // Merge byFile (later repos override)
78
+ for (const repo of repos) {
79
+ Object.assign(merged.byFile, repo.byFile);
80
+ }
81
+
82
+ // Merge edges (append all, deduplicate)
83
+ const edgeSet = new Set<string>();
84
+ for (const repo of repos) {
85
+ for (const edge of repo.edges) {
86
+ const key = `${edge.from}→${edge.to}:${edge.role}`;
87
+ if (!edgeSet.has(key)) {
88
+ edgeSet.add(key);
89
+ merged.edges.push(edge);
90
+ }
91
+ }
92
+ }
93
+
94
+ debug.parser(
95
+ 'Merged repo: %d files, %d concepts, %d localdefs, %d operations, %d imports, %d edges',
96
+ merged.files.length,
97
+ merged.concepts.length,
98
+ Object.keys(merged.localdefs).length,
99
+ Object.keys(merged.operations).length,
100
+ merged.imports.length,
101
+ merged.edges.length
102
+ );
103
+
104
+ return merged;
105
+ }
106
+
107
+ /**
108
+ * Extend a base repo with additional files
109
+ * This is a convenience wrapper around mergeRepos
110
+ */
111
+ export function extendRepo(baseRepo: Repo, extensionRepo: Repo): Repo {
112
+ debug.parser('Extending base repo with extension');
113
+ return mergeRepos(baseRepo, extensionRepo);
114
+ }
115
+
116
+ /**
117
+ * Load a repo from JSON and validate it against the schema
118
+ */
119
+ export function loadRepoFromJSON(json: string): Repo {
120
+ const data = JSON.parse(json);
121
+ // Basic validation - could use Zod here but keeping it simple for now
122
+ if (!data.files || !data.concepts) {
123
+ throw new Error('Invalid repo JSON: missing required fields');
124
+ }
125
+ return data as Repo;
126
+ }