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
@@ -0,0 +1,404 @@
1
+ import { Section, LegacyOperation, DocId, Step, Checklist, NewOperation } from '../types/schema.js';
2
+ import { getAllSections, findSection, getSectionExtends } from './sections.js';
3
+ import { debug } from '../utils/logger.js';
4
+
5
+ const OPERATIONS_SECTION_ALIASES = [
6
+ 'operations',
7
+ 'operations-section',
8
+ ];
9
+
10
+ // =============================================================================
11
+ // NEW PARSER FUNCTIONS - busy-python compatible
12
+ // =============================================================================
13
+
14
+ /**
15
+ * Parse numbered steps from markdown content
16
+ * Returns Step objects with stepNumber, instruction, and operationReferences
17
+ *
18
+ * @param content - Markdown content to parse
19
+ * @returns Array of Step objects
20
+ */
21
+ export function parseSteps(content: string): Step[] {
22
+ const steps: Step[] = [];
23
+
24
+ // Find Steps section if present
25
+ const stepsMatch = content.match(/###\s*\[?Steps\]?\s*\n([\s\S]*?)(?=\n###|\n##|$)/i);
26
+ const textToParse = stepsMatch ? stepsMatch[1] : content;
27
+
28
+ // Split content by lines and process
29
+ const lines = textToParse.split('\n');
30
+ let currentStepNumber = 0;
31
+ let currentInstruction = '';
32
+
33
+ for (const line of lines) {
34
+ const trimmed = line.trim();
35
+
36
+ // Check for numbered step start: "1. instruction"
37
+ const stepMatch = trimmed.match(/^(\d+)\.\s+(.+)$/);
38
+
39
+ if (stepMatch) {
40
+ // Save previous step if exists
41
+ if (currentStepNumber > 0 && currentInstruction) {
42
+ steps.push(createStepObject(currentStepNumber, currentInstruction));
43
+ }
44
+
45
+ currentStepNumber = parseInt(stepMatch[1], 10);
46
+ currentInstruction = stepMatch[2];
47
+ } else if (currentStepNumber > 0 && trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('-') && !trimmed.startsWith('*')) {
48
+ // Continuation of current step (indented or regular text)
49
+ currentInstruction += ' ' + trimmed;
50
+ }
51
+ }
52
+
53
+ // Add final step
54
+ if (currentStepNumber > 0 && currentInstruction) {
55
+ steps.push(createStepObject(currentStepNumber, currentInstruction));
56
+ }
57
+
58
+ return steps;
59
+ }
60
+
61
+ /**
62
+ * Helper to create a Step object with operation references extracted
63
+ */
64
+ function createStepObject(stepNumber: number, instruction: string): Step {
65
+ instruction = instruction.trim();
66
+
67
+ // Extract operation references: [OperationName]
68
+ const operationReferences: string[] = [];
69
+ const refPattern = /\[([^\]]+)\]/g;
70
+ let refMatch;
71
+ while ((refMatch = refPattern.exec(instruction)) !== null) {
72
+ // Skip if it looks like a markdown link [text](url)
73
+ const afterBracket = instruction.slice(refMatch.index + refMatch[0].length);
74
+ if (!afterBracket.startsWith('(')) {
75
+ operationReferences.push(refMatch[1]);
76
+ }
77
+ }
78
+
79
+ return {
80
+ stepNumber,
81
+ instruction,
82
+ operationReferences: operationReferences.length > 0 ? operationReferences : undefined,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Parse checklist items from markdown content
88
+ * Returns Checklist object with items array, or null if no checklist found
89
+ *
90
+ * @param content - Markdown content to parse
91
+ * @returns Checklist object or null
92
+ */
93
+ export function parseChecklist(content: string): Checklist | null {
94
+ // Find Checklist section
95
+ const checklistMatch = content.match(/###\s*\[?Checklist\]?\s*\n([\s\S]*?)(?=\n###|\n##|$)/i);
96
+
97
+ if (!checklistMatch) {
98
+ return null;
99
+ }
100
+
101
+ const checklistContent = checklistMatch[1];
102
+ const items: string[] = [];
103
+
104
+ // Split by lines and process bullet items
105
+ const lines = checklistContent.split('\n');
106
+ let currentItem = '';
107
+
108
+ for (const line of lines) {
109
+ const trimmed = line.trim();
110
+
111
+ // Check for bullet item start (- or *)
112
+ const bulletMatch = trimmed.match(/^[-*]\s+(.+)$/);
113
+
114
+ if (bulletMatch) {
115
+ // Save previous item if exists
116
+ if (currentItem) {
117
+ items.push(currentItem.trim());
118
+ }
119
+ currentItem = bulletMatch[1];
120
+ } else if (currentItem && trimmed && !trimmed.startsWith('#')) {
121
+ // Continuation of current item
122
+ currentItem += ' ' + trimmed;
123
+ }
124
+ }
125
+
126
+ // Add final item
127
+ if (currentItem) {
128
+ items.push(currentItem.trim());
129
+ }
130
+
131
+ if (items.length === 0) {
132
+ return null;
133
+ }
134
+
135
+ return { items };
136
+ }
137
+
138
+ /**
139
+ * Parse inputs/outputs from an operation section
140
+ */
141
+ function parseInputsOutputs(content: string, sectionName: string): string[] {
142
+ const pattern = new RegExp(
143
+ `###\\s*\\[?${sectionName}\\]?\\s*\\n([\\s\\S]*?)(?=\\n###|\\n##|$)`,
144
+ 'i'
145
+ );
146
+ const match = content.match(pattern);
147
+
148
+ if (!match) {
149
+ return [];
150
+ }
151
+
152
+ const items: string[] = [];
153
+ const lines = match[1].split('\n');
154
+
155
+ for (const line of lines) {
156
+ const trimmed = line.trim();
157
+ const bulletMatch = trimmed.match(/^[-*]\s+(.+)$/);
158
+ if (bulletMatch) {
159
+ items.push(bulletMatch[1].trim());
160
+ }
161
+ }
162
+
163
+ return items;
164
+ }
165
+
166
+ /**
167
+ * Parse operations from markdown content
168
+ * Returns array of Operation objects matching busy-python format
169
+ *
170
+ * @param content - Full markdown document content
171
+ * @returns Array of NewOperation objects
172
+ */
173
+ export function parseOperations(content: string): NewOperation[] {
174
+ const operations: NewOperation[] = [];
175
+
176
+ // Find Operations section - handle both with and without brackets
177
+ // Match # Operations or # [Operations]
178
+ const operationsMatch = content.match(
179
+ /^#\s*\[?Operations\]?\s*$/im
180
+ );
181
+
182
+ if (!operationsMatch) {
183
+ return [];
184
+ }
185
+
186
+ // Get content after Operations heading until next top-level section or end
187
+ const startIndex = operationsMatch.index! + operationsMatch[0].length;
188
+ const restContent = content.slice(startIndex);
189
+
190
+ // Find next top-level heading (# not ##)
191
+ const nextH1Match = restContent.match(/\n#\s+[^\#]/);
192
+ const operationsContent = nextH1Match
193
+ ? restContent.slice(0, nextH1Match.index)
194
+ : restContent;
195
+
196
+ // Split by ## headings to find individual operations
197
+ // Use a simpler approach: split by ## and process each part
198
+ const parts = operationsContent.split(/\n(?=##\s+)/);
199
+
200
+ for (const part of parts) {
201
+ if (!part.trim()) continue;
202
+
203
+ // Match operation heading: ## OperationName or ## [OperationName][Type]
204
+ const headingMatch = part.match(/^##\s+(?:\[([^\]]+)\](?:\[[^\]]*\])?|([^\n]+))\s*\n?([\s\S]*)$/);
205
+
206
+ if (headingMatch) {
207
+ const name = (headingMatch[1] || headingMatch[2]).trim();
208
+ const opContent = headingMatch[3] || '';
209
+
210
+ const steps = parseSteps(opContent);
211
+ const checklist = parseChecklist(opContent);
212
+ const inputs = parseInputsOutputs(opContent, 'Inputs');
213
+ const outputs = parseInputsOutputs(opContent, 'Outputs');
214
+
215
+ operations.push({
216
+ name,
217
+ inputs,
218
+ outputs,
219
+ steps,
220
+ checklist: checklist || undefined,
221
+ });
222
+ }
223
+ }
224
+
225
+ return operations;
226
+ }
227
+
228
+ // =============================================================================
229
+ // LEGACY FUNCTIONS - kept for backward compatibility
230
+ // =============================================================================
231
+
232
+ // Type alias for backward compatibility
233
+ type Operation = LegacyOperation;
234
+
235
+ /**
236
+ * Extract Operations from sections
237
+ */
238
+ export function extractOperations(
239
+ sections: Section[],
240
+ docId: DocId,
241
+ filePath: string
242
+ ): Operation[] {
243
+ debug.localdefs('Extracting operations for %s', docId);
244
+
245
+ // Find the Operations section
246
+ const operationsSection = findOperationsSection(sections);
247
+
248
+ if (!operationsSection) {
249
+ debug.localdefs('No Operations section found');
250
+ return [];
251
+ }
252
+
253
+ debug.localdefs('Found Operations section: %s', operationsSection.title);
254
+
255
+ // Extract all direct children as Operations
256
+ const operations: Operation[] = [];
257
+
258
+ for (const child of operationsSection.children) {
259
+ const operation = createOperation(child, docId, filePath);
260
+
261
+ // Skip empty operations (just reference headers with no content)
262
+ // These are placeholders that should be inherited from parent documents
263
+ if (operation.content.trim().length === 0 &&
264
+ operation.steps.length === 0 &&
265
+ operation.checklist.length === 0 &&
266
+ child.children.length === 0) {
267
+ debug.localdefs('Skipping empty operation: %s (likely a reference header)', operation.name);
268
+ continue;
269
+ }
270
+
271
+ operations.push(operation);
272
+ }
273
+
274
+ debug.localdefs('Extracted %d operations', operations.length);
275
+
276
+ return operations;
277
+ }
278
+
279
+ /**
280
+ * Find the Operations section (case-insensitive)
281
+ */
282
+ function findOperationsSection(sections: Section[]): Section | undefined {
283
+ for (const alias of OPERATIONS_SECTION_ALIASES) {
284
+ const section = findSection(sections, alias);
285
+ if (section) {
286
+ return section;
287
+ }
288
+ }
289
+ return undefined;
290
+ }
291
+
292
+ /**
293
+ * Create an Operation from a section
294
+ */
295
+ function createOperation(
296
+ section: Section,
297
+ docId: DocId,
298
+ filePath: string
299
+ ): Operation {
300
+ const slug = section.slug;
301
+ const id = `${docId}::${slug}`; // Use :: for concept IDs
302
+
303
+ // Parse steps and checklist from content
304
+ const { steps, checklist } = parseOperationContent(section);
305
+
306
+ // Get extends from section heading (e.g., ## [ValidateInput][Operation])
307
+ const extends_ = getSectionExtends(section.id);
308
+
309
+ return {
310
+ kind: 'operation',
311
+ id,
312
+ docId,
313
+ slug,
314
+ name: section.title,
315
+ content: section.content,
316
+ types: [],
317
+ extends: extends_,
318
+ sectionRef: section.id, // sectionRef uses # for section references
319
+ steps,
320
+ checklist,
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Parse operation content for steps and checklist
326
+ */
327
+ function parseOperationContent(section: Section): {
328
+ steps: string[];
329
+ checklist: string[];
330
+ } {
331
+ const steps: string[] = [];
332
+ const checklist: string[] = [];
333
+
334
+ // Look for Steps subsection
335
+ const stepsSection = section.children.find(
336
+ (child) => child.title.toLowerCase() === 'steps'
337
+ );
338
+
339
+ if (stepsSection) {
340
+ steps.push(...extractListItems(stepsSection.content));
341
+ } else {
342
+ // Try to find numbered lists in main content
343
+ steps.push(...extractListItems(section.content));
344
+ }
345
+
346
+ // Look for Checklist subsection
347
+ const checklistSection = section.children.find(
348
+ (child) => child.title.toLowerCase() === 'checklist'
349
+ );
350
+
351
+ if (checklistSection) {
352
+ checklist.push(...extractListItems(checklistSection.content));
353
+ }
354
+
355
+ return { steps, checklist };
356
+ }
357
+
358
+ /**
359
+ * Extract list items from markdown content
360
+ * Handles both ordered (1. 2. 3.) and unordered (- *) lists
361
+ */
362
+ function extractListItems(content: string): string[] {
363
+ const items: string[] = [];
364
+ const lines = content.split('\n');
365
+
366
+ let inList = false;
367
+ let currentItem = '';
368
+
369
+ for (const line of lines) {
370
+ const trimmed = line.trim();
371
+
372
+ // Check if this is a list item
373
+ const orderedMatch = trimmed.match(/^\d+\.\s+(.+)$/);
374
+ const unorderedMatch = trimmed.match(/^[-*]\s+(.+)$/);
375
+
376
+ if (orderedMatch || unorderedMatch) {
377
+ // Save previous item if any
378
+ if (currentItem) {
379
+ items.push(currentItem.trim());
380
+ }
381
+
382
+ // Start new item
383
+ currentItem = (orderedMatch?.[1] || unorderedMatch?.[1] || '').trim();
384
+ inList = true;
385
+ } else if (inList && trimmed && !trimmed.startsWith('#')) {
386
+ // Continuation of current item
387
+ currentItem += ' ' + trimmed;
388
+ } else if (inList && !trimmed) {
389
+ // Empty line might end the list
390
+ if (currentItem) {
391
+ items.push(currentItem.trim());
392
+ currentItem = '';
393
+ }
394
+ inList = false;
395
+ }
396
+ }
397
+
398
+ // Add final item
399
+ if (currentItem) {
400
+ items.push(currentItem.trim());
401
+ }
402
+
403
+ return items;
404
+ }
@@ -0,0 +1,230 @@
1
+ import { unified } from 'unified';
2
+ import remarkParse from 'remark-parse';
3
+ import remarkFrontmatter from 'remark-frontmatter';
4
+ import { visit } from 'unist-util-visit';
5
+ import type { Root, Heading, Node } from 'mdast';
6
+ import { Section, DocId } from '../types/schema.js';
7
+ import { createSlug } from '../utils/slugify.js';
8
+ import { debug } from '../utils/logger.js';
9
+
10
+ interface HeadingInfo {
11
+ depth: number;
12
+ title: string;
13
+ slug: string;
14
+ lineStart: number;
15
+ lineEnd: number;
16
+ extends: string[];
17
+ }
18
+
19
+ /**
20
+ * Parse markdown content into a section tree
21
+ */
22
+ export function parseSections(
23
+ content: string,
24
+ docId: DocId,
25
+ filePath: string
26
+ ): Section[] {
27
+ debug.sections('Parsing sections for %s', docId);
28
+
29
+ const processor = unified().use(remarkParse).use(remarkFrontmatter, ['yaml']);
30
+
31
+ const tree = processor.parse(content) as Root;
32
+
33
+ // Extract headings with their positions
34
+ const headings: HeadingInfo[] = [];
35
+
36
+ visit(tree, 'heading', (node: Heading) => {
37
+ const { title, extends: extendsArr } = parseHeadingNode(node);
38
+ const slug = createSlug(title);
39
+ const lineStart = node.position?.start.line ?? 0;
40
+ const lineEnd = node.position?.end.line ?? 0;
41
+
42
+ if (extendsArr.length > 0) {
43
+ debug.sections('Parsed heading: "%s" extends %o', title, extendsArr);
44
+ }
45
+
46
+ headings.push({
47
+ depth: node.depth,
48
+ title,
49
+ slug,
50
+ lineStart,
51
+ lineEnd,
52
+ extends: extendsArr,
53
+ });
54
+ });
55
+
56
+ // Build section tree and populate extends map
57
+ const sections = buildSectionTree(headings, content, docId, filePath);
58
+
59
+ debug.sections('Found %d top-level sections', sections.length);
60
+
61
+ return sections;
62
+ }
63
+
64
+ /**
65
+ * Get extends information for a section by ID
66
+ * This is stored separately since Section schema doesn't include extends
67
+ */
68
+ const sectionExtendsMap = new Map<string, string[]>();
69
+
70
+ export function getSectionExtends(sectionId: string): string[] {
71
+ return sectionExtendsMap.get(sectionId) ?? [];
72
+ }
73
+
74
+ /**
75
+ * Extract plain text from a node
76
+ */
77
+ function extractTextFromNode(node: Node): string {
78
+ if ('value' in node && typeof node.value === 'string') {
79
+ return node.value;
80
+ }
81
+ if ('children' in node && Array.isArray(node.children)) {
82
+ return node.children.map(extractTextFromNode).join('');
83
+ }
84
+ return '';
85
+ }
86
+
87
+ /**
88
+ * Parse heading node to extract title and extends information
89
+ * Patterns:
90
+ * - [Title][Type] -> title="Title", extends=["Type"]
91
+ * - [Title][Type1][Type2] -> title="Title", extends=["Type1", "Type2"]
92
+ * - Regular Title -> title="Regular Title", extends=[]
93
+ *
94
+ * When [Type] is a defined reference link (like [Operation]:./operation.md),
95
+ * markdown parses [Title][Type] as a linkReference node with:
96
+ * - children: [text node with "Title"]
97
+ * - label: "Type"
98
+ * - referenceType: "full"
99
+ */
100
+ function parseHeadingNode(node: Heading): { title: string; extends: string[] } {
101
+ const children = node.children || [];
102
+
103
+ // Check for linkReference pattern: [Title][Type]
104
+ if (children.length === 1 && children[0].type === 'linkReference') {
105
+ const linkRef = children[0] as any;
106
+ if (linkRef.referenceType === 'full' && linkRef.label) {
107
+ // This is [Title][Type] where Type is a defined reference
108
+ const title = extractTextFromNode(linkRef);
109
+ const extends_ = [linkRef.label];
110
+ return { title, extends: extends_ };
111
+ }
112
+ }
113
+
114
+ // Fallback: extract plain text
115
+ const rawTitle = extractTextFromNode(node);
116
+ return { title: rawTitle, extends: [] };
117
+ }
118
+
119
+ /**
120
+ * Build a hierarchical section tree from flat headings
121
+ */
122
+ function buildSectionTree(
123
+ headings: HeadingInfo[],
124
+ content: string,
125
+ docId: DocId,
126
+ filePath: string
127
+ ): Section[] {
128
+ if (headings.length === 0) {
129
+ return [];
130
+ }
131
+
132
+ const lines = content.split('\n');
133
+ const sections: Section[] = [];
134
+ const stack: Section[] = [];
135
+
136
+ for (let i = 0; i < headings.length; i++) {
137
+ const heading = headings[i];
138
+ const nextHeading = headings[i + 1];
139
+
140
+ // Determine content boundaries
141
+ const contentStart = heading.lineEnd;
142
+ const contentEnd = nextHeading
143
+ ? nextHeading.lineStart - 1
144
+ : lines.length;
145
+
146
+ // Extract content for this section
147
+ const sectionContent = lines
148
+ .slice(contentStart, contentEnd)
149
+ .join('\n')
150
+ .trim();
151
+
152
+ // Create section
153
+ const section: Section = {
154
+ kind: 'section',
155
+ id: `${docId}#${heading.slug}`,
156
+ docId,
157
+ slug: heading.slug,
158
+ title: heading.title,
159
+ depth: heading.depth,
160
+ path: filePath,
161
+ lineStart: heading.lineStart,
162
+ lineEnd: contentEnd,
163
+ content: sectionContent,
164
+ children: [],
165
+ };
166
+
167
+ // Store extends metadata separately (not in Section schema)
168
+ if (heading.extends.length > 0) {
169
+ sectionExtendsMap.set(section.id, heading.extends);
170
+ }
171
+
172
+ // Find parent and add to tree
173
+ while (stack.length > 0 && stack[stack.length - 1].depth >= heading.depth) {
174
+ stack.pop();
175
+ }
176
+
177
+ if (stack.length === 0) {
178
+ // Top-level section
179
+ sections.push(section);
180
+ } else {
181
+ // Child section
182
+ stack[stack.length - 1].children.push(section);
183
+ }
184
+
185
+ stack.push(section);
186
+ }
187
+
188
+ return sections;
189
+ }
190
+
191
+ /**
192
+ * Find a section by name/slug (case-insensitive)
193
+ */
194
+ export function findSection(
195
+ sections: Section[],
196
+ nameOrSlug: string
197
+ ): Section | undefined {
198
+ const target = nameOrSlug.toLowerCase();
199
+
200
+ for (const section of sections) {
201
+ if (
202
+ section.title.toLowerCase() === target ||
203
+ section.slug.toLowerCase() === target
204
+ ) {
205
+ return section;
206
+ }
207
+
208
+ // Search recursively
209
+ const found = findSection(section.children, nameOrSlug);
210
+ if (found) {
211
+ return found;
212
+ }
213
+ }
214
+
215
+ return undefined;
216
+ }
217
+
218
+ /**
219
+ * Get all sections recursively (flatten tree)
220
+ */
221
+ export function getAllSections(sections: Section[]): Section[] {
222
+ const result: Section[] = [];
223
+
224
+ for (const section of sections) {
225
+ result.push(section);
226
+ result.push(...getAllSections(section.children));
227
+ }
228
+
229
+ return result;
230
+ }