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,135 @@
1
+ import matter from 'gray-matter';
2
+ import { FrontMatter, FrontMatterSchema, ConceptBase } from '../types/schema.js';
3
+ import { normalizeDocId, getBasename } from '../utils/slugify.js';
4
+ import { debug, warn } from '../utils/logger.js';
5
+
6
+ export interface ParsedFrontMatter {
7
+ frontmatter: FrontMatter;
8
+ content: string;
9
+ docId: string;
10
+ kind: ConceptBase['kind'];
11
+ types: string[];
12
+ extends: string[];
13
+ }
14
+
15
+ /**
16
+ * Parse front-matter from markdown content
17
+ */
18
+ export function parseFrontMatter(
19
+ fileContent: string,
20
+ filePath: string
21
+ ): ParsedFrontMatter {
22
+ let data: any;
23
+ let content: string;
24
+
25
+ // Extract only the first frontmatter block to avoid "multiple documents" error
26
+ // when the body contains --- horizontal rules
27
+ const frontmatterMatch = fileContent.match(/^---\n([\s\S]*?)\n---/);
28
+ if (frontmatterMatch) {
29
+ try {
30
+ const parsed = matter(frontmatterMatch[0]);
31
+ data = parsed.data;
32
+ content = fileContent.slice(frontmatterMatch[0].length);
33
+ } catch (err) {
34
+ warn(`Failed to parse YAML frontmatter in ${filePath}: ${err}`, { file: filePath });
35
+ data = {};
36
+ content = fileContent;
37
+ }
38
+ } else {
39
+ // No frontmatter found
40
+ data = {};
41
+ content = fileContent;
42
+ }
43
+
44
+ debug.frontmatter('Parsing frontmatter for %s', filePath);
45
+
46
+ // Validate frontmatter
47
+ let frontmatter: FrontMatter;
48
+ try {
49
+ frontmatter = FrontMatterSchema.parse(data);
50
+ } catch (err) {
51
+ warn(`Invalid frontmatter schema in ${filePath}`, { file: filePath });
52
+ // Provide defaults if validation fails
53
+ frontmatter = {
54
+ Name: getBasename(filePath),
55
+ Type: [],
56
+ Description: undefined,
57
+ Tags: [],
58
+ Extends: [],
59
+ };
60
+ }
61
+
62
+ // Normalize docId from Name or filename
63
+ const docId = frontmatter.Name
64
+ ? normalizeDocId(frontmatter.Name)
65
+ : normalizeDocId(getBasename(filePath));
66
+
67
+ // Normalize types - strip markdown link brackets like [Document] -> Document
68
+ const types = (frontmatter.Type ?? []).map(stripMarkdownBrackets);
69
+
70
+ // Infer kind from types
71
+ const kind = inferKind(types);
72
+
73
+ // Normalize extends - also strip brackets
74
+ const extendsFromFm = (frontmatter.Extends ?? []).map(stripMarkdownBrackets);
75
+ const extendsFromTypes = inferExtendsFromTypes(types);
76
+ const extends_ = Array.from(new Set([...extendsFromFm, ...extendsFromTypes]));
77
+
78
+ // Normalize tags
79
+ const tags = (frontmatter.Tags ?? []).map(stripMarkdownBrackets);
80
+
81
+ debug.frontmatter(
82
+ 'Parsed: docId=%s, kind=%s, types=%o, extends=%o',
83
+ docId,
84
+ kind,
85
+ types,
86
+ extends_
87
+ );
88
+
89
+ return {
90
+ frontmatter: {
91
+ ...frontmatter,
92
+ Type: types,
93
+ Extends: extends_,
94
+ Tags: tags,
95
+ },
96
+ content,
97
+ docId,
98
+ kind,
99
+ types,
100
+ extends: extends_,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Strip markdown link brackets from a string
106
+ * [Document] -> Document
107
+ * [[Document]] -> Document
108
+ */
109
+ function stripMarkdownBrackets(str: string): string {
110
+ return str.replace(/^\[+|\]+$/g, '');
111
+ }
112
+
113
+ /**
114
+ * Infer kind from types array
115
+ */
116
+ function inferKind(types: string[]): ConceptBase['kind'] {
117
+ const typesLower = types.map((t) => t.toLowerCase());
118
+
119
+ if (typesLower.includes('document')) return 'document';
120
+ if (typesLower.includes('operation')) return 'operation';
121
+ if (typesLower.includes('checklist')) return 'checklist';
122
+ if (typesLower.includes('tool')) return 'tool';
123
+ if (typesLower.includes('playbook')) return 'playbook';
124
+
125
+ return 'concept';
126
+ }
127
+
128
+ /**
129
+ * Infer extends from types
130
+ * All Type references should be included in extends
131
+ */
132
+ function inferExtendsFromTypes(types: string[]): string[] {
133
+ // All types are also extends - they define what this concept is based on
134
+ return types;
135
+ }
@@ -0,0 +1,196 @@
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, Definition } from 'mdast';
6
+ import { ImportDef, DocId, Import } from '../types/schema.js';
7
+ import { debug } from '../utils/logger.js';
8
+
9
+ // =============================================================================
10
+ // NEW PARSER FUNCTIONS - busy-python compatible
11
+ // =============================================================================
12
+
13
+ /**
14
+ * Parse reference-style imports from markdown content
15
+ * Matches busy-python format: [ConceptName]: path/to/file.md[#anchor]
16
+ *
17
+ * @returns Object with imports array and symbols table
18
+ */
19
+ export function parseImports(
20
+ content: string
21
+ ): { imports: Import[]; symbols: Record<string, { docId?: string; slug?: string }> } {
22
+ const imports: Import[] = [];
23
+ const symbols: Record<string, { docId?: string; slug?: string }> = {};
24
+
25
+ // Regex pattern matching busy-python: r"\[([^\]]+)\]:\s*([^\s#]+)(?:#([^\s]+))?"
26
+ const importPattern = /^\[([^\]]+)\]:\s*([^\s#]+)(?:#([^\s]+))?$/gm;
27
+
28
+ let match;
29
+ while ((match = importPattern.exec(content)) !== null) {
30
+ const [, conceptName, path, anchor] = match;
31
+
32
+ const importDef: Import = {
33
+ conceptName,
34
+ path,
35
+ anchor: anchor || undefined,
36
+ };
37
+
38
+ imports.push(importDef);
39
+
40
+ // Add to symbol table for later resolution
41
+ symbols[conceptName] = {
42
+ docId: undefined,
43
+ slug: anchor || undefined,
44
+ };
45
+ }
46
+
47
+ return { imports, symbols };
48
+ }
49
+
50
+ /**
51
+ * Resolve an import path to docId and optional slug
52
+ * Used for import resolution against a file map
53
+ */
54
+ export function resolveImportTarget(
55
+ target: string,
56
+ fileMap: Map<string, { docId: string; path: string }>
57
+ ): { docId?: string; slug?: string } {
58
+ // Parse target: "file.md", "./file.md", "../core/file.md", or "file.md#slug"
59
+ const hashIndex = target.indexOf('#');
60
+ let filePath = target;
61
+ let slug: string | undefined;
62
+
63
+ if (hashIndex !== -1) {
64
+ filePath = target.slice(0, hashIndex);
65
+ slug = target.slice(hashIndex + 1);
66
+ }
67
+
68
+ // Remove leading ./ if present
69
+ filePath = filePath.replace(/^\.\//, '');
70
+
71
+ // Try multiple resolution strategies:
72
+ // 1. Full path as provided
73
+ let fileInfo = fileMap.get(filePath);
74
+
75
+ // 2. Try just the basename
76
+ if (!fileInfo) {
77
+ const basename = filePath.split('/').pop() || filePath;
78
+ fileInfo = fileMap.get(basename);
79
+ }
80
+
81
+ // 3. Try without extension
82
+ if (!fileInfo) {
83
+ const basename = filePath.split('/').pop() || filePath;
84
+ const withoutExt = basename.replace(/\.busy\.md$/, '').replace(/\.md$/, '');
85
+ fileInfo = fileMap.get(withoutExt);
86
+ }
87
+
88
+ if (!fileInfo) {
89
+ return slug ? { slug } : {};
90
+ }
91
+
92
+ return {
93
+ docId: fileInfo.docId,
94
+ slug,
95
+ };
96
+ }
97
+
98
+ // =============================================================================
99
+ // LEGACY FUNCTIONS - kept for backward compatibility
100
+ // =============================================================================
101
+
102
+ /**
103
+ * Extract reference-style imports from markdown content (LEGACY)
104
+ * Returns ImportDef objects for graph-based representation
105
+ */
106
+ export function extractImports(
107
+ content: string,
108
+ docId: DocId
109
+ ): { imports: ImportDef[]; symbols: Record<string, { docId?: string; slug?: string }> } {
110
+ debug.imports('Extracting imports for %s', docId);
111
+
112
+ const processor = unified().use(remarkParse).use(remarkFrontmatter, ['yaml']);
113
+
114
+ const tree = processor.parse(content) as Root;
115
+
116
+ const imports: ImportDef[] = [];
117
+ const symbols: Record<string, { docId?: string; slug?: string }> = {};
118
+
119
+ // Visit definition nodes (reference-style links)
120
+ visit(tree, 'definition', (node: Definition) => {
121
+ const label = node.label ?? node.identifier;
122
+ const target = node.url;
123
+
124
+ debug.imports('Found import: [%s]: %s', label, target);
125
+
126
+ const importDef: ImportDef = {
127
+ kind: 'importdef',
128
+ id: `${docId}::import::${label}`,
129
+ docId,
130
+ slug: label.toLowerCase(),
131
+ name: label,
132
+ content: '', // Imports don't have content
133
+ types: [],
134
+ extends: [],
135
+ sectionRef: '', // Imports don't belong to a section
136
+ label,
137
+ target,
138
+ resolved: undefined, // Will be resolved later
139
+ };
140
+
141
+ imports.push(importDef);
142
+
143
+ // Add to symbol table (will be fully resolved later)
144
+ symbols[label] = {
145
+ docId: undefined,
146
+ slug: undefined,
147
+ };
148
+ });
149
+
150
+ debug.imports('Found %d imports', imports.length);
151
+
152
+ return { imports, symbols };
153
+ }
154
+
155
+ /**
156
+ * Resolve an import target to {docId, slug} (LEGACY)
157
+ * This version requires currentDocId parameter for backward compatibility
158
+ */
159
+ export function legacyResolveImportTarget(
160
+ target: string,
161
+ currentDocId: DocId,
162
+ fileMap: Map<string, { docId: string; path: string }>
163
+ ): { docId?: string; slug?: string } {
164
+ // Parse target: "./file.md", "../core/file.md", or "./file.md#slug"
165
+ const match = target.match(/^\.{1,2}\/(.+?)(#(.+))?$/);
166
+
167
+ if (!match) {
168
+ debug.imports('Invalid import target: %s', target);
169
+ return {};
170
+ }
171
+
172
+ const [, filePath, , slug] = match;
173
+
174
+ // Try multiple resolution strategies:
175
+ // 1. Full relative path
176
+ // 2. Just the basename
177
+ // 3. Basename with different extensions
178
+
179
+ let fileInfo = fileMap.get(filePath);
180
+
181
+ if (!fileInfo) {
182
+ // Try just the basename
183
+ const basename = filePath.split('/').pop() || filePath;
184
+ fileInfo = fileMap.get(basename);
185
+ }
186
+
187
+ if (!fileInfo) {
188
+ debug.imports('File not found in map: %s', filePath);
189
+ return { slug };
190
+ }
191
+
192
+ return {
193
+ docId: fileInfo.docId,
194
+ slug,
195
+ };
196
+ }
@@ -0,0 +1,108 @@
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, Link, LinkReference } from 'mdast';
6
+ import { Section, Edge, EdgeRole } from '../types/schema.js';
7
+ import { debug } from '../utils/logger.js';
8
+
9
+
10
+ /**
11
+ * Extract links from a section and create edges
12
+ */
13
+ export function extractLinksFromSection(
14
+ section: Section,
15
+ content: string,
16
+ symbols: Record<string, { docId?: string; slug?: string }>,
17
+ fileMap: Map<string, { docId: string; path: string }>
18
+ ): Edge[] {
19
+ debug.links('Extracting links from section %s', section.id);
20
+
21
+ const processor = unified().use(remarkParse).use(remarkFrontmatter, ['yaml']);
22
+
23
+ const tree = processor.parse(content) as Root;
24
+
25
+ const edges: Edge[] = [];
26
+
27
+ // Visit link nodes
28
+ visit(tree, 'link', (node: Link) => {
29
+ const href = node.url;
30
+
31
+ const resolved = resolveLink(href, section.docId, fileMap);
32
+
33
+ if (resolved) {
34
+ // Temporarily mark as 'ref', will be reclassified in loader
35
+ edges.push({
36
+ from: section.id,
37
+ to: resolved,
38
+ role: 'ref',
39
+ });
40
+ }
41
+ });
42
+
43
+ // Visit link reference nodes
44
+ visit(tree, 'linkReference', (node: LinkReference) => {
45
+ const label = node.label ?? node.identifier;
46
+
47
+ // Resolve via symbol table
48
+ const symbol = symbols[label];
49
+ if (symbol) {
50
+ let resolved: string | undefined;
51
+
52
+ if (symbol.docId && symbol.slug) {
53
+ resolved = `${symbol.docId}#${symbol.slug}`;
54
+ } else if (symbol.docId) {
55
+ resolved = symbol.docId;
56
+ }
57
+
58
+ if (resolved) {
59
+ // Temporarily mark as 'ref', will be reclassified in loader
60
+ edges.push({
61
+ from: section.id,
62
+ to: resolved,
63
+ role: 'ref',
64
+ });
65
+ }
66
+ }
67
+ });
68
+
69
+ debug.links('Found %d edges from section', edges.length);
70
+
71
+ return edges;
72
+ }
73
+
74
+ /**
75
+ * Resolve a link href to a node ID
76
+ */
77
+ function resolveLink(
78
+ href: string,
79
+ currentDocId: string,
80
+ fileMap: Map<string, { docId: string; path: string }>
81
+ ): string | undefined {
82
+ // Handle internal anchors: #slug
83
+ if (href.startsWith('#')) {
84
+ const slug = href.slice(1);
85
+ return `${currentDocId}#${slug}`;
86
+ }
87
+
88
+ // Handle relative file links: ./file.md or ./file.md#slug
89
+ const match = href.match(/^\.\/(.+?)(#(.+))?$/);
90
+ if (match) {
91
+ const [, filePath, , slug] = match;
92
+
93
+ const fileInfo = fileMap.get(filePath);
94
+ if (!fileInfo) {
95
+ debug.links('File not found: %s', filePath);
96
+ return undefined;
97
+ }
98
+
99
+ if (slug) {
100
+ return `${fileInfo.docId}#${slug}`;
101
+ }
102
+
103
+ return fileInfo.docId;
104
+ }
105
+
106
+ // External link - return as-is or skip
107
+ return href.startsWith('http') ? href : undefined;
108
+ }
@@ -0,0 +1,166 @@
1
+ import { Section, LocalDef, DocId } from '../types/schema.js';
2
+ import { createSlug } from '../utils/slugify.js';
3
+ import { findSection, getAllSections, getSectionExtends } from './sections.js';
4
+ import { debug } from '../utils/logger.js';
5
+
6
+ const LOCAL_DEFS_ALIASES = [
7
+ 'local definitions',
8
+ 'definitions',
9
+ 'glossary',
10
+ 'local-definitions-section',
11
+ ];
12
+
13
+ /**
14
+ * Extract Local Definitions from sections
15
+ */
16
+ export function extractLocalDefs(
17
+ sections: Section[],
18
+ docId: DocId,
19
+ filePath: string
20
+ ): LocalDef[] {
21
+ debug.localdefs('Extracting local definitions for %s', docId);
22
+
23
+ // Find the Local Definitions section
24
+ const localDefsSection = findLocalDefinitionsSection(sections);
25
+
26
+ if (!localDefsSection) {
27
+ debug.localdefs('No Local Definitions section found');
28
+ return [];
29
+ }
30
+
31
+ debug.localdefs(
32
+ 'Found Local Definitions section: %s',
33
+ localDefsSection.title
34
+ );
35
+
36
+ // Extract all subheadings as LocalDefs
37
+ const localdefs: LocalDef[] = [];
38
+
39
+ for (const child of getAllSections(localDefsSection.children)) {
40
+ const localdef = createLocalDef(child, docId, filePath);
41
+ localdefs.push(localdef);
42
+ }
43
+
44
+ debug.localdefs('Extracted %d local definitions', localdefs.length);
45
+
46
+ return localdefs;
47
+ }
48
+
49
+ /**
50
+ * Find the Local Definitions section (case-insensitive)
51
+ */
52
+ function findLocalDefinitionsSection(sections: Section[]): Section | undefined {
53
+ for (const alias of LOCAL_DEFS_ALIASES) {
54
+ const section = findSection(sections, alias);
55
+ if (section) {
56
+ return section;
57
+ }
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ /**
63
+ * Create a LocalDef from a section
64
+ */
65
+ function createLocalDef(
66
+ section: Section,
67
+ docId: DocId,
68
+ filePath: string
69
+ ): LocalDef {
70
+ const slug = section.slug;
71
+ const id = `${docId}::${slug}`;
72
+
73
+ // Parse extends from content
74
+ const { extends: extendsFromContent } = parseLocalDefAttrs(section.content);
75
+
76
+ // Get extends from section heading (e.g., ## [MyDef][Type])
77
+ const extendsFromHeading = getSectionExtends(section.id);
78
+
79
+ // Combine both sources of extends
80
+ const extends_ = Array.from(new Set([...extendsFromContent, ...extendsFromHeading]));
81
+
82
+ return {
83
+ kind: 'localdef',
84
+ id,
85
+ docId,
86
+ slug,
87
+ name: section.title,
88
+ content: section.content,
89
+ types: [],
90
+ extends: extends_,
91
+ sectionRef: section.id,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Parse attributes from local def content
97
+ * Looks for:
98
+ * 1. Fenced blocks with "yaml busy" or "json busy"
99
+ * 2. Inline patterns like "Extends: [...]" or "_Extends:_"
100
+ */
101
+ function parseLocalDefAttrs(content: string): {
102
+ attrs: Record<string, unknown>;
103
+ extends: string[];
104
+ } {
105
+ const attrs: Record<string, unknown> = {};
106
+ let extends_: string[] = [];
107
+
108
+ // Check for fenced blocks first
109
+ const yamlBusyMatch = content.match(/```(?:yaml|json)\s+busy\s*\n([\s\S]*?)```/);
110
+ if (yamlBusyMatch) {
111
+ try {
112
+ // Simple parsing - just look for key: value pairs
113
+ const block = yamlBusyMatch[1];
114
+ const lines = block.split('\n');
115
+
116
+ for (const line of lines) {
117
+ const match = line.match(/^\s*(\w+):\s*(.+)$/);
118
+ if (match) {
119
+ const [, key, value] = match;
120
+ // Try to parse as JSON
121
+ try {
122
+ attrs[key] = JSON.parse(value);
123
+ } catch {
124
+ attrs[key] = value.trim();
125
+ }
126
+ }
127
+ }
128
+
129
+ if (attrs.Extends) {
130
+ extends_ = Array.isArray(attrs.Extends)
131
+ ? attrs.Extends
132
+ : [attrs.Extends];
133
+ }
134
+ } catch (err) {
135
+ debug.localdefs('Failed to parse fenced block: %s', err);
136
+ }
137
+ }
138
+
139
+ // Look for inline Extends patterns
140
+ const extendsPatterns = [
141
+ /^Extends:\s*\[(.*?)\]/im,
142
+ /^_Extends:_\s*\[(.*?)\]/im,
143
+ /^Extends:\s*(.+)$/im,
144
+ ];
145
+
146
+ for (const pattern of extendsPatterns) {
147
+ const match = content.match(pattern);
148
+ if (match) {
149
+ const value = match[1].trim();
150
+ // Parse as comma-separated list or JSON array
151
+ try {
152
+ const parsed = JSON.parse(`[${value}]`);
153
+ extends_ = [...extends_, ...parsed];
154
+ } catch {
155
+ // Try comma-separated
156
+ extends_ = [
157
+ ...extends_,
158
+ ...value.split(',').map((s) => s.trim().replace(/['"]/g, '')),
159
+ ];
160
+ }
161
+ break;
162
+ }
163
+ }
164
+
165
+ return { attrs, extends: Array.from(new Set(extends_)) };
166
+ }