@brad-frost-web/eddie-brain 0.32.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 (239) hide show
  1. package/README.md +109 -0
  2. package/dist/analyze/drift-detector.d.ts +30 -0
  3. package/dist/analyze/drift-detector.d.ts.map +1 -0
  4. package/dist/analyze/drift-detector.js +310 -0
  5. package/dist/analyze/drift-detector.js.map +1 -0
  6. package/dist/analyze/health-scorer.d.ts +71 -0
  7. package/dist/analyze/health-scorer.d.ts.map +1 -0
  8. package/dist/analyze/health-scorer.js +420 -0
  9. package/dist/analyze/health-scorer.js.map +1 -0
  10. package/dist/analyze/index.d.ts +11 -0
  11. package/dist/analyze/index.d.ts.map +1 -0
  12. package/dist/analyze/index.js +11 -0
  13. package/dist/analyze/index.js.map +1 -0
  14. package/dist/analyze/naming-validator.d.ts +99 -0
  15. package/dist/analyze/naming-validator.d.ts.map +1 -0
  16. package/dist/analyze/naming-validator.js +430 -0
  17. package/dist/analyze/naming-validator.js.map +1 -0
  18. package/dist/analyze/slot-contract-validator.d.ts +68 -0
  19. package/dist/analyze/slot-contract-validator.d.ts.map +1 -0
  20. package/dist/analyze/slot-contract-validator.js +232 -0
  21. package/dist/analyze/slot-contract-validator.js.map +1 -0
  22. package/dist/analyze/token-validator.d.ts +62 -0
  23. package/dist/analyze/token-validator.d.ts.map +1 -0
  24. package/dist/analyze/token-validator.js +348 -0
  25. package/dist/analyze/token-validator.js.map +1 -0
  26. package/dist/cli/brain.d.ts +12 -0
  27. package/dist/cli/brain.d.ts.map +1 -0
  28. package/dist/cli/brain.js +641 -0
  29. package/dist/cli/brain.js.map +1 -0
  30. package/dist/cli/formatters/json.d.ts +15 -0
  31. package/dist/cli/formatters/json.d.ts.map +1 -0
  32. package/dist/cli/formatters/json.js +18 -0
  33. package/dist/cli/formatters/json.js.map +1 -0
  34. package/dist/cli/formatters/terminal.d.ts +19 -0
  35. package/dist/cli/formatters/terminal.d.ts.map +1 -0
  36. package/dist/cli/formatters/terminal.js +125 -0
  37. package/dist/cli/formatters/terminal.js.map +1 -0
  38. package/dist/cli/index.d.ts +7 -0
  39. package/dist/cli/index.d.ts.map +1 -0
  40. package/dist/cli/index.js +7 -0
  41. package/dist/cli/index.js.map +1 -0
  42. package/dist/data/governance-rules.json +94 -0
  43. package/dist/governance/audit-log.d.ts +17 -0
  44. package/dist/governance/audit-log.d.ts.map +1 -0
  45. package/dist/governance/audit-log.js +44 -0
  46. package/dist/governance/audit-log.js.map +1 -0
  47. package/dist/governance/index.d.ts +8 -0
  48. package/dist/governance/index.d.ts.map +1 -0
  49. package/dist/governance/index.js +8 -0
  50. package/dist/governance/index.js.map +1 -0
  51. package/dist/governance/permissions.d.ts +26 -0
  52. package/dist/governance/permissions.d.ts.map +1 -0
  53. package/dist/governance/permissions.js +75 -0
  54. package/dist/governance/permissions.js.map +1 -0
  55. package/dist/governance/rules-engine.d.ts +24 -0
  56. package/dist/governance/rules-engine.d.ts.map +1 -0
  57. package/dist/governance/rules-engine.js +111 -0
  58. package/dist/governance/rules-engine.js.map +1 -0
  59. package/dist/governance/trust-manager.d.ts +34 -0
  60. package/dist/governance/trust-manager.d.ts.map +1 -0
  61. package/dist/governance/trust-manager.js +148 -0
  62. package/dist/governance/trust-manager.js.map +1 -0
  63. package/dist/index.d.ts +23 -0
  64. package/dist/index.d.ts.map +1 -0
  65. package/dist/index.js +28 -0
  66. package/dist/index.js.map +1 -0
  67. package/dist/knowledge-graph/component-index.d.ts +320 -0
  68. package/dist/knowledge-graph/component-index.d.ts.map +1 -0
  69. package/dist/knowledge-graph/component-index.js +1033 -0
  70. package/dist/knowledge-graph/component-index.js.map +1 -0
  71. package/dist/knowledge-graph/index.d.ts +134 -0
  72. package/dist/knowledge-graph/index.d.ts.map +1 -0
  73. package/dist/knowledge-graph/index.js +249 -0
  74. package/dist/knowledge-graph/index.js.map +1 -0
  75. package/dist/knowledge-graph/learning-history.d.ts +77 -0
  76. package/dist/knowledge-graph/learning-history.d.ts.map +1 -0
  77. package/dist/knowledge-graph/learning-history.js +187 -0
  78. package/dist/knowledge-graph/learning-history.js.map +1 -0
  79. package/dist/knowledge-graph/relationship-map.d.ts +55 -0
  80. package/dist/knowledge-graph/relationship-map.d.ts.map +1 -0
  81. package/dist/knowledge-graph/relationship-map.js +238 -0
  82. package/dist/knowledge-graph/relationship-map.js.map +1 -0
  83. package/dist/knowledge-graph/token-taxonomy.d.ts +127 -0
  84. package/dist/knowledge-graph/token-taxonomy.d.ts.map +1 -0
  85. package/dist/knowledge-graph/token-taxonomy.js +357 -0
  86. package/dist/knowledge-graph/token-taxonomy.js.map +1 -0
  87. package/dist/loop/fix-agent.d.ts +55 -0
  88. package/dist/loop/fix-agent.d.ts.map +1 -0
  89. package/dist/loop/fix-agent.js +344 -0
  90. package/dist/loop/fix-agent.js.map +1 -0
  91. package/dist/loop/index.d.ts +8 -0
  92. package/dist/loop/index.d.ts.map +1 -0
  93. package/dist/loop/index.js +8 -0
  94. package/dist/loop/index.js.map +1 -0
  95. package/dist/loop/issue-fetcher.d.ts +51 -0
  96. package/dist/loop/issue-fetcher.d.ts.map +1 -0
  97. package/dist/loop/issue-fetcher.js +188 -0
  98. package/dist/loop/issue-fetcher.js.map +1 -0
  99. package/dist/loop/observer.d.ts +42 -0
  100. package/dist/loop/observer.d.ts.map +1 -0
  101. package/dist/loop/observer.js +220 -0
  102. package/dist/loop/observer.js.map +1 -0
  103. package/dist/loop/pacer.d.ts +44 -0
  104. package/dist/loop/pacer.d.ts.map +1 -0
  105. package/dist/loop/pacer.js +90 -0
  106. package/dist/loop/pacer.js.map +1 -0
  107. package/dist/loop/reporter.d.ts +9 -0
  108. package/dist/loop/reporter.d.ts.map +1 -0
  109. package/dist/loop/reporter.js +119 -0
  110. package/dist/loop/reporter.js.map +1 -0
  111. package/dist/loop/runner.d.ts +57 -0
  112. package/dist/loop/runner.d.ts.map +1 -0
  113. package/dist/loop/runner.js +390 -0
  114. package/dist/loop/runner.js.map +1 -0
  115. package/dist/loop/types.d.ts +151 -0
  116. package/dist/loop/types.d.ts.map +1 -0
  117. package/dist/loop/types.js +22 -0
  118. package/dist/loop/types.js.map +1 -0
  119. package/dist/mcp/index.d.ts +7 -0
  120. package/dist/mcp/index.d.ts.map +1 -0
  121. package/dist/mcp/index.js +7 -0
  122. package/dist/mcp/index.js.map +1 -0
  123. package/dist/mcp/server.d.ts +12 -0
  124. package/dist/mcp/server.d.ts.map +1 -0
  125. package/dist/mcp/server.js +618 -0
  126. package/dist/mcp/server.js.map +1 -0
  127. package/dist/pipeline/agent-runner.d.ts +34 -0
  128. package/dist/pipeline/agent-runner.d.ts.map +1 -0
  129. package/dist/pipeline/agent-runner.js +323 -0
  130. package/dist/pipeline/agent-runner.js.map +1 -0
  131. package/dist/pipeline/agents/accessibility-auditor.d.ts +10 -0
  132. package/dist/pipeline/agents/accessibility-auditor.d.ts.map +1 -0
  133. package/dist/pipeline/agents/accessibility-auditor.js +69 -0
  134. package/dist/pipeline/agents/accessibility-auditor.js.map +1 -0
  135. package/dist/pipeline/agents/code-reviewer.d.ts +10 -0
  136. package/dist/pipeline/agents/code-reviewer.d.ts.map +1 -0
  137. package/dist/pipeline/agents/code-reviewer.js +75 -0
  138. package/dist/pipeline/agents/code-reviewer.js.map +1 -0
  139. package/dist/pipeline/agents/code-writer.d.ts +10 -0
  140. package/dist/pipeline/agents/code-writer.d.ts.map +1 -0
  141. package/dist/pipeline/agents/code-writer.js +103 -0
  142. package/dist/pipeline/agents/code-writer.js.map +1 -0
  143. package/dist/pipeline/agents/component-architect.d.ts +13 -0
  144. package/dist/pipeline/agents/component-architect.d.ts.map +1 -0
  145. package/dist/pipeline/agents/component-architect.js +81 -0
  146. package/dist/pipeline/agents/component-architect.js.map +1 -0
  147. package/dist/pipeline/agents/index.d.ts +16 -0
  148. package/dist/pipeline/agents/index.d.ts.map +1 -0
  149. package/dist/pipeline/agents/index.js +24 -0
  150. package/dist/pipeline/agents/index.js.map +1 -0
  151. package/dist/pipeline/agents/library-researcher.d.ts +12 -0
  152. package/dist/pipeline/agents/library-researcher.d.ts.map +1 -0
  153. package/dist/pipeline/agents/library-researcher.js +85 -0
  154. package/dist/pipeline/agents/library-researcher.js.map +1 -0
  155. package/dist/pipeline/agents/quality-gate.d.ts +9 -0
  156. package/dist/pipeline/agents/quality-gate.d.ts.map +1 -0
  157. package/dist/pipeline/agents/quality-gate.js +71 -0
  158. package/dist/pipeline/agents/quality-gate.js.map +1 -0
  159. package/dist/pipeline/agents/spec-analyst.d.ts +10 -0
  160. package/dist/pipeline/agents/spec-analyst.d.ts.map +1 -0
  161. package/dist/pipeline/agents/spec-analyst.js +72 -0
  162. package/dist/pipeline/agents/spec-analyst.js.map +1 -0
  163. package/dist/pipeline/agents/story-author.d.ts +9 -0
  164. package/dist/pipeline/agents/story-author.d.ts.map +1 -0
  165. package/dist/pipeline/agents/story-author.js +65 -0
  166. package/dist/pipeline/agents/story-author.js.map +1 -0
  167. package/dist/pipeline/artifact-store.d.ts +27 -0
  168. package/dist/pipeline/artifact-store.d.ts.map +1 -0
  169. package/dist/pipeline/artifact-store.js +77 -0
  170. package/dist/pipeline/artifact-store.js.map +1 -0
  171. package/dist/pipeline/conversational-gate.d.ts +26 -0
  172. package/dist/pipeline/conversational-gate.d.ts.map +1 -0
  173. package/dist/pipeline/conversational-gate.js +122 -0
  174. package/dist/pipeline/conversational-gate.js.map +1 -0
  175. package/dist/pipeline/index.d.ts +14 -0
  176. package/dist/pipeline/index.d.ts.map +1 -0
  177. package/dist/pipeline/index.js +17 -0
  178. package/dist/pipeline/index.js.map +1 -0
  179. package/dist/pipeline/iteration-tracker.d.ts +29 -0
  180. package/dist/pipeline/iteration-tracker.d.ts.map +1 -0
  181. package/dist/pipeline/iteration-tracker.js +102 -0
  182. package/dist/pipeline/iteration-tracker.js.map +1 -0
  183. package/dist/pipeline/learning-bridge.d.ts +37 -0
  184. package/dist/pipeline/learning-bridge.d.ts.map +1 -0
  185. package/dist/pipeline/learning-bridge.js +118 -0
  186. package/dist/pipeline/learning-bridge.js.map +1 -0
  187. package/dist/pipeline/orchestrator.d.ts +45 -0
  188. package/dist/pipeline/orchestrator.d.ts.map +1 -0
  189. package/dist/pipeline/orchestrator.js +473 -0
  190. package/dist/pipeline/orchestrator.js.map +1 -0
  191. package/dist/pipeline/templates/architecture.d.ts +27 -0
  192. package/dist/pipeline/templates/architecture.d.ts.map +1 -0
  193. package/dist/pipeline/templates/architecture.js +111 -0
  194. package/dist/pipeline/templates/architecture.js.map +1 -0
  195. package/dist/pipeline/templates/brief.d.ts +22 -0
  196. package/dist/pipeline/templates/brief.d.ts.map +1 -0
  197. package/dist/pipeline/templates/brief.js +121 -0
  198. package/dist/pipeline/templates/brief.js.map +1 -0
  199. package/dist/pipeline/templates/component-rules.d.ts +25 -0
  200. package/dist/pipeline/templates/component-rules.d.ts.map +1 -0
  201. package/dist/pipeline/templates/component-rules.js +93 -0
  202. package/dist/pipeline/templates/component-rules.js.map +1 -0
  203. package/dist/pipeline/templates/index.d.ts +9 -0
  204. package/dist/pipeline/templates/index.d.ts.map +1 -0
  205. package/dist/pipeline/templates/index.js +7 -0
  206. package/dist/pipeline/templates/index.js.map +1 -0
  207. package/dist/pipeline/tool-handler.d.ts +25 -0
  208. package/dist/pipeline/tool-handler.d.ts.map +1 -0
  209. package/dist/pipeline/tool-handler.js +392 -0
  210. package/dist/pipeline/tool-handler.js.map +1 -0
  211. package/dist/pipeline/types.d.ts +146 -0
  212. package/dist/pipeline/types.d.ts.map +1 -0
  213. package/dist/pipeline/types.js +27 -0
  214. package/dist/pipeline/types.js.map +1 -0
  215. package/dist/plan/action-types.d.ts +31 -0
  216. package/dist/plan/action-types.d.ts.map +1 -0
  217. package/dist/plan/action-types.js +83 -0
  218. package/dist/plan/action-types.js.map +1 -0
  219. package/dist/plan/decision-engine.d.ts +57 -0
  220. package/dist/plan/decision-engine.d.ts.map +1 -0
  221. package/dist/plan/decision-engine.js +162 -0
  222. package/dist/plan/decision-engine.js.map +1 -0
  223. package/dist/plan/index.d.ts +6 -0
  224. package/dist/plan/index.d.ts.map +1 -0
  225. package/dist/plan/index.js +6 -0
  226. package/dist/plan/index.js.map +1 -0
  227. package/dist/types.d.ts +351 -0
  228. package/dist/types.d.ts.map +1 -0
  229. package/dist/types.js +26 -0
  230. package/dist/types.js.map +1 -0
  231. package/dist/utils/anthropic.d.ts +15 -0
  232. package/dist/utils/anthropic.d.ts.map +1 -0
  233. package/dist/utils/anthropic.js +40 -0
  234. package/dist/utils/anthropic.js.map +1 -0
  235. package/dist/utils/id.d.ts +8 -0
  236. package/dist/utils/id.d.ts.map +1 -0
  237. package/dist/utils/id.js +14 -0
  238. package/dist/utils/id.js.map +1 -0
  239. package/package.json +80 -0
@@ -0,0 +1,1033 @@
1
+ /**
2
+ * ComponentIndex — Auto-discover and catalog Eddie components
3
+ *
4
+ * Scans the Eddie monorepo to build a registry of all components,
5
+ * extracting metadata from TypeScript decorators and Storybook stories.
6
+ */
7
+ import fg from 'fast-glob';
8
+ const { globSync } = fg;
9
+ import { existsSync, readFileSync } from 'fs';
10
+ import { join } from 'path';
11
+ export class ComponentIndex {
12
+ components = new Map();
13
+ /**
14
+ * Build the component registry by scanning the Eddie codebase.
15
+ *
16
+ * Scans four packages:
17
+ *
18
+ * eddie-web-components — core Lit components (depth 1: <name>/<name>.ts)
19
+ * eddie-recipes — recipe components (depth 2: <project>/<name>/<name>.ts)
20
+ * eddie-pages — full-page templates (depth 2: <project>/<name>/<name>.ts)
21
+ * eddie-charts — chart recipe components (depth 2: <project>/<name>/<name>.ts)
22
+ *
23
+ * All four are walked by scanPackage, which handles depth-agnostic traversal
24
+ * and enforces the basename-matches-containing-dir convention. eddie-charts
25
+ * is a category recipe library: its components share the `ed-r-*` tag prefix
26
+ * and `ed-r-*` CSS class prefix with eddie-recipes, but live in a separate
27
+ * package so that the Chart.js runtime is opt-in for consumers who don't
28
+ * need chart surfaces.
29
+ */
30
+ async build(rootDir) {
31
+ this.components.clear();
32
+ const webComponentsDir = join(rootDir, 'packages', 'eddie-web-components', 'components');
33
+ const recipesDir = join(rootDir, 'packages', 'eddie-recipes', 'recipes');
34
+ const pagesDir = join(rootDir, 'packages', 'eddie-pages', 'pages');
35
+ const chartsDir = join(rootDir, 'packages', 'eddie-charts', 'charts');
36
+ await this.scanPackage(webComponentsDir, 'eddie-web-components', rootDir);
37
+ await this.scanPackage(recipesDir, 'eddie-recipes', rootDir);
38
+ await this.scanPackage(pagesDir, 'eddie-pages', rootDir);
39
+ await this.scanPackage(chartsDir, 'eddie-charts', rootDir);
40
+ }
41
+ /**
42
+ * Scan a package directory for components.
43
+ *
44
+ * Walks any depth under the package root and picks up files that follow
45
+ * the Eddie component convention: every component file lives at
46
+ * `<component-dir>/<component-name>.ts` where the file's basename matches
47
+ * its containing directory's basename. That gives us:
48
+ *
49
+ * eddie-web-components: components/button/button.ts
50
+ * components/card/card.ts
51
+ * → depth 1 under pkgDir
52
+ *
53
+ * eddie-recipes: recipes/common/project-card/project-card.ts
54
+ * recipes/we-are-here/wah-logo/wah-logo.ts
55
+ * → depth 2 under pkgDir (extra project-name level)
56
+ *
57
+ * Historic bug: the previous glob was a two-level pattern (star slash star
58
+ * dot ts) which only matched depth 1, so the entire eddie-recipes catalog
59
+ * was invisible to the indexer. `ed-r-project-card` and 11 other real,
60
+ * shipping recipe components never made it into components.json. Fix: walk
61
+ * any depth via a double-star glob, then enforce the basename-matches-
62
+ * containing-directory convention to filter helpers, types, and barrel
63
+ * files that happen to sit next to real components.
64
+ */
65
+ async scanPackage(pkgDir, pkgName, rootDir) {
66
+ // Walk any depth. Ignore base classes, test files, stories, and
67
+ // declaration files. Everything else is a candidate for being a component.
68
+ const tsFiles = globSync('**/*.ts', {
69
+ cwd: pkgDir,
70
+ ignore: [
71
+ 'EdElement.ts',
72
+ 'EdFormElement.ts',
73
+ '**/EdElement.ts',
74
+ '**/EdFormElement.ts',
75
+ '**/test/**',
76
+ '**/*.stories.ts',
77
+ '**/*.d.ts',
78
+ ],
79
+ });
80
+ for (const relativeFile of tsFiles) {
81
+ // relativeFile is pkgDir-relative, e.g. "button/button.ts" or
82
+ // "common/project-card/project-card.ts"
83
+ const parts = relativeFile.split('/');
84
+ const fileName = parts[parts.length - 1];
85
+ const componentName = fileName.replace(/\.ts$/, '');
86
+ // Enforce the Eddie <name>/<name>.ts convention. If the containing
87
+ // directory's name doesn't match the file's name, this isn't a
88
+ // component — it's a helper, a shared type, or a barrel file.
89
+ // Skip silently.
90
+ if (parts.length < 2)
91
+ continue;
92
+ const dirName = parts[parts.length - 2];
93
+ if (dirName !== componentName)
94
+ continue;
95
+ // relativeDir is the path from pkgDir to the component's own directory,
96
+ // without the filename. E.g. "button" or "common/project-card".
97
+ const relativeDir = parts.slice(0, -1).join('/');
98
+ const absoluteComponentDir = join(pkgDir, relativeDir);
99
+ const absoluteFilePath = join(pkgDir, relativeFile);
100
+ try {
101
+ const entry = await this.parseComponent(componentName, absoluteFilePath, absoluteComponentDir, relativeDir, pkgName, rootDir);
102
+ if (entry) {
103
+ this.components.set(entry.tagName, entry);
104
+ }
105
+ }
106
+ catch (err) {
107
+ // Silently skip unparseable files
108
+ }
109
+ }
110
+ }
111
+ /**
112
+ * Parse a single component file and extract metadata.
113
+ *
114
+ * @param componentName The component's base name (e.g. "button", "project-card")
115
+ * @param filePath Absolute path to the component's .ts file
116
+ * @param componentDir Absolute path to the component's own directory
117
+ * (contains the .ts, .scss, .stories.ts, and test/)
118
+ * @param relativeDir Path from the package root to the component dir,
119
+ * e.g. "button" (eddie-web-components) or
120
+ * "common/project-card" (eddie-recipes)
121
+ * @param pkgName Which package this component lives in
122
+ * @param rootDir Repo root (unused today but passed for future use)
123
+ */
124
+ async parseComponent(componentName, filePath, componentDir, relativeDir, pkgName, rootDir) {
125
+ const content = readFileSync(filePath, 'utf-8');
126
+ // Extract tag name from @customElement decorator or customElements.define().
127
+ //
128
+ // eddie-pages are structurally different: they're reusable Lit templates
129
+ // that extend LitElement directly, render into light DOM, and are NOT
130
+ // registered as custom elements. They have no `customElements.define()`
131
+ // call and no `@customElement` decorator. For pages, we synthesize an
132
+ // identifier of the form `ed-p-<name>` from the file name so they can
133
+ // live in the component index alongside real custom elements. Consumers
134
+ // can tell them apart by checking `atomicLevel === 'page'`.
135
+ const tagNameMatch = content.match(/@customElement\(['"]([^'"]+)['"]\)/) ||
136
+ content.match(/customElements\.define\(['"]([^'"]+)['"]/);
137
+ let tagName;
138
+ if (tagNameMatch) {
139
+ tagName = tagNameMatch[1];
140
+ }
141
+ else if (pkgName === 'eddie-pages') {
142
+ // Synthesize a stable identifier from the component name.
143
+ // e.g. "app-dashboard" → "ed-p-app-dashboard"
144
+ tagName = `ed-p-${componentName}`;
145
+ }
146
+ else {
147
+ return null; // Not a custom element and not a page
148
+ }
149
+ // Extract class name
150
+ const classMatch = content.match(/export class\s+(\w+)/);
151
+ if (!classMatch) {
152
+ return null;
153
+ }
154
+ const className = classMatch[1];
155
+ const displayName = this.classNameToDisplayName(className);
156
+ // Determine CSS class name (follows BEM convention).
157
+ // The category letter distinguishes core components, recipes, and pages:
158
+ // core components: `ed-c-<name>` (c = component)
159
+ // recipes: `ed-r-<name>` (r = recipe)
160
+ // pages: `ed-p-<name>` (p = page)
161
+ //
162
+ // Historic bug: the previous derivation emitted `ed-r-c-<name>` and
163
+ // `ed-p-c-<name>` (from PRs #497 and #499), double-encoding the
164
+ // category with an extra `-c-`. The recipe source files also use
165
+ // `ed-r-c-*` today (tracked as #505 for a source-side rename). Once
166
+ // that rename lands, the source and the indexer will agree. In the
167
+ // meantime, the indexer reflects the correct target convention, not
168
+ // the temporary source state.
169
+ const nameAsClass = componentName
170
+ .replace(/([A-Z])/g, '-$1')
171
+ .toLowerCase()
172
+ .replace(/^-/, '');
173
+ let cssClassName;
174
+ if (pkgName === 'eddie-recipes' || pkgName === 'eddie-charts') {
175
+ // eddie-charts components are recipes in every sense except their
176
+ // runtime dep on Chart.js — they use the same `ed-r-*` tag and class
177
+ // prefix as eddie-recipes.
178
+ cssClassName = `ed-r-${nameAsClass}`;
179
+ }
180
+ else if (pkgName === 'eddie-pages') {
181
+ cssClassName = `ed-p-${nameAsClass}`;
182
+ }
183
+ else {
184
+ cssClassName = `ed-c-${nameAsClass}`;
185
+ }
186
+ // Extract base class
187
+ const baseClassMatch = content.match(/extends\s+(EdElement|EdFormElement)/);
188
+ const baseClass = baseClassMatch ? baseClassMatch[1] : 'EdElement';
189
+ // Extract properties
190
+ const properties = this.extractProperties(content);
191
+ // Extract slots
192
+ const slots = this.extractSlots(content);
193
+ // Extract events
194
+ const events = this.extractEvents(content);
195
+ // Extract CSS custom properties used
196
+ const cssCustomProperties = this.extractCssCustomProperties(content);
197
+ // Determine atomic level from stories file (uses componentDir directly —
198
+ // previously this constructed the stories path from a hardcoded
199
+ // "components/<name>" template which broke for eddie-recipes).
200
+ const atomicLevel = await this.getAtomicLevel(componentName, componentDir);
201
+ // Extract component intent from JSDoc
202
+ const intent = this.extractIntent(content);
203
+ // Extract guidelines (use / dontUse / accessibility) from JSDoc tags
204
+ const guidelines = this.extractGuidelines(content);
205
+ // Determine parent/child relationships
206
+ const { parentComponent, childComponents } = this.determineCompoundRelationship(tagName, componentName);
207
+ // Relative paths for cross-referencing. Previously hardcoded to
208
+ // `components/<name>/...` which only worked for eddie-web-components.
209
+ // Now derived from the component's actual relative directory within
210
+ // its package, with a package-specific subdir:
211
+ // eddie-web-components → components/<relativeDir>/...
212
+ // eddie-recipes → recipes/<relativeDir>/...
213
+ // eddie-pages → pages/<relativeDir>/...
214
+ //
215
+ // Each sibling-file path (styles, stories, test) is filesystem-verified
216
+ // before being emitted — the previous implementation emitted all four
217
+ // unconditionally, so consumers querying eddie-brain saw paths like
218
+ // `pages/common/homepage/homepage.scss` for components that have no
219
+ // .scss file at all. The `component` path is always emitted because we
220
+ // just read it.
221
+ let pkgSubdir;
222
+ if (pkgName === 'eddie-recipes') {
223
+ pkgSubdir = 'recipes';
224
+ }
225
+ else if (pkgName === 'eddie-pages') {
226
+ pkgSubdir = 'pages';
227
+ }
228
+ else if (pkgName === 'eddie-charts') {
229
+ pkgSubdir = 'charts';
230
+ }
231
+ else {
232
+ pkgSubdir = 'components';
233
+ }
234
+ const paths = {
235
+ component: `${pkgSubdir}/${relativeDir}/${componentName}.ts`,
236
+ };
237
+ const stylesAbs = join(componentDir, `${componentName}.scss`);
238
+ if (existsSync(stylesAbs)) {
239
+ paths.styles = `${pkgSubdir}/${relativeDir}/${componentName}.scss`;
240
+ }
241
+ const storiesAbs = join(componentDir, `${componentName}.stories.ts`);
242
+ if (existsSync(storiesAbs)) {
243
+ paths.stories = `${pkgSubdir}/${relativeDir}/${componentName}.stories.ts`;
244
+ }
245
+ const testAbs = join(componentDir, 'test', `${componentName}.test.ts`);
246
+ if (existsSync(testAbs)) {
247
+ paths.test = `${pkgSubdir}/${relativeDir}/test/${componentName}.test.ts`;
248
+ }
249
+ // Extract composesWith from import statements in the source. This turns
250
+ // the previously-empty array into a real list of `<ed-*>` / `<ed-r-*>` /
251
+ // `<ed-p-*>` tag names that the component or template imports — the
252
+ // catalog answer to "what does this thing combine?". Imports that don't
253
+ // resolve to an Eddie component path are ignored.
254
+ const composesWith = this.extractComposesWith(content);
255
+ // Extract the component's customization surface (slots/props marked
256
+ // `@overridableSlot` / `@overridableProp`) and its canonical invocation
257
+ // (prose from `@canonicalUsage` + markup from the Default story). Both
258
+ // are optional — components that don't declare these tags simply get
259
+ // `undefined` for the corresponding field. See #642.
260
+ const overridableSurface = this.extractOverridableSurface(content);
261
+ const canonicalUsage = this.extractCanonicalUsage(content, componentDir, componentName);
262
+ const contentApi = this.extractContentApi(content);
263
+ return {
264
+ tagName,
265
+ displayName,
266
+ cssClassName,
267
+ atomicLevel,
268
+ package: pkgName,
269
+ intent,
270
+ baseClass,
271
+ properties,
272
+ slots,
273
+ cssCustomProperties,
274
+ events,
275
+ composesWith,
276
+ parentComponent,
277
+ childComponents,
278
+ guidelines,
279
+ overridableSurface,
280
+ canonicalUsage,
281
+ contentApi,
282
+ paths,
283
+ };
284
+ }
285
+ /**
286
+ * Extract the list of Eddie component tag names referenced via `import`
287
+ * statements in the source.
288
+ *
289
+ * Recognizes the canonical Eddie import paths:
290
+ *
291
+ * import '@brad-frost-web/eddie-web-components/components/button/button';
292
+ * → ed-button
293
+ * import '@brad-frost-web/eddie-recipes/recipes/common/site-header/site-header';
294
+ * → ed-r-site-header
295
+ * import '@brad-frost-web/eddie-pages/pages/common/homepage/homepage';
296
+ * → ed-p-homepage
297
+ *
298
+ * The trailing `/<name>/<name>` segment is the convention enforced by
299
+ * scanPackage (component basename matches its containing directory). We
300
+ * map the basename to a tag name using the same package → prefix table
301
+ * the indexer uses for cssClassName.
302
+ *
303
+ * Imports that don't match this shape (e.g. `import { html } from 'lit'`,
304
+ * `import styles from './foo.scss?inline'`, EdElement base-class imports)
305
+ * are ignored. Side-effect imports without a `from` (the most common form
306
+ * for Eddie component registration) and named/default imports are both
307
+ * matched.
308
+ *
309
+ * The resulting list is deduplicated and sorted for stable output across
310
+ * builds (eliminates a class of dirty-diff in the catalog snapshot).
311
+ */
312
+ extractComposesWith(content) {
313
+ const tagNames = new Set();
314
+ // Match the bare-specifier import form used everywhere in the codebase:
315
+ // import '@brad-frost-web/<pkg>/<path>';
316
+ // import { x } from '@brad-frost-web/<pkg>/<path>';
317
+ // import x from '@brad-frost-web/<pkg>/<path>';
318
+ const importRegex = /import\s+(?:[^'"`;]+\s+from\s+)?['"]@brad-frost-web\/([^'"`]+)['"]/g;
319
+ let match;
320
+ while ((match = importRegex.exec(content)) !== null) {
321
+ const importPath = match[1];
322
+ const tagName = this.importPathToTagName(importPath);
323
+ if (tagName)
324
+ tagNames.add(tagName);
325
+ }
326
+ return Array.from(tagNames).sort();
327
+ }
328
+ /**
329
+ * Map a `@brad-frost-web/<pkg>/<path>` import specifier to its Eddie tag
330
+ * name, or return null if the specifier doesn't resolve to a component.
331
+ *
332
+ * Handles the three component-bearing packages:
333
+ *
334
+ * eddie-web-components/components/<X>/<X> → ed-<X>
335
+ * eddie-recipes/recipes/<scope>/<X>/<X> → ed-r-<X>
336
+ * eddie-pages/pages/<scope>/<X>/<X> → ed-p-<X>
337
+ *
338
+ * Returns null for non-component imports such as the `EdElement` base
339
+ * class, theme/token CSS, asset paths, or any other shape we don't
340
+ * recognize as a registered Eddie tag.
341
+ */
342
+ importPathToTagName(importPath) {
343
+ const segments = importPath.split('/');
344
+ if (segments.length < 3)
345
+ return null;
346
+ const [pkg, ...rest] = segments;
347
+ // Convention: the last two path segments are `<name>/<name>` for any
348
+ // valid Eddie component registration import. If they don't match, it's
349
+ // not a component import.
350
+ const last = rest[rest.length - 1];
351
+ const beforeLast = rest[rest.length - 2];
352
+ if (!last || !beforeLast || last !== beforeLast)
353
+ return null;
354
+ // Skip the EdElement base class explicitly; its path is
355
+ // `eddie-web-components/components/EdElement` which doesn't fit the
356
+ // <name>/<name> convention anyway, so the check above usually catches it,
357
+ // but be defensive.
358
+ if (last === 'EdElement' || last === 'EdFormElement')
359
+ return null;
360
+ if (pkg === 'eddie-web-components' && rest[0] === 'components') {
361
+ return `ed-${last}`;
362
+ }
363
+ if (pkg === 'eddie-recipes' && rest[0] === 'recipes') {
364
+ return `ed-r-${last}`;
365
+ }
366
+ if (pkg === 'eddie-pages' && rest[0] === 'pages') {
367
+ return `ed-p-${last}`;
368
+ }
369
+ if (pkg === 'eddie-charts' && rest[0] === 'charts') {
370
+ // eddie-charts shares the `ed-r-*` tag prefix with eddie-recipes.
371
+ return `ed-r-${last}`;
372
+ }
373
+ return null;
374
+ }
375
+ /**
376
+ * Extract `use` / `dontUse` / `accessibility` guidelines from the class-level
377
+ * JSDoc. Recognizes three custom tag forms:
378
+ *
379
+ * @use <text> → adds one entry to guidelines.use
380
+ * @dontuse <text> → adds one entry to guidelines.dontUse
381
+ * @a11y <text> → adds one entry to guidelines.accessibility
382
+ *
383
+ * Tags can appear multiple times in the same JSDoc block, with each
384
+ * occurrence becoming a separate bullet in the corresponding array. The
385
+ * tag text is everything from after the tag name to the end of the line.
386
+ * Trailing-line continuations are not currently supported — each guideline
387
+ * must fit on one line.
388
+ *
389
+ * This is the counterpart to authoring-side guidelines in the component
390
+ * source files. Today the Eddie catalog is sparse on guidelines; this
391
+ * parser is the mechanism that lets new guidelines actually land in
392
+ * eddie-brain's output.
393
+ */
394
+ extractGuidelines(content) {
395
+ const jsDocMatch = content.match(/\/\*\*[\s\S]*?\*\/\s*export class/);
396
+ if (!jsDocMatch) {
397
+ return { use: [], dontUse: [], accessibility: [] };
398
+ }
399
+ const jsDocText = jsDocMatch[0];
400
+ const extractTag = (tag) => {
401
+ // Case-insensitive to tolerate @DontUse / @dontUse variations
402
+ const regex = new RegExp(`@${tag}\\s+([^\\n]+)`, 'gi');
403
+ const items = [];
404
+ let m;
405
+ while ((m = regex.exec(jsDocText)) !== null) {
406
+ const text = m[1].trim();
407
+ if (text.length > 0)
408
+ items.push(text);
409
+ }
410
+ return items;
411
+ };
412
+ return {
413
+ use: extractTag('use'),
414
+ dontUse: extractTag('dontuse'),
415
+ accessibility: extractTag('a11y'),
416
+ };
417
+ }
418
+ /**
419
+ * Extract `@overridableSlot` and `@overridableProp` tags from class-level
420
+ * JSDoc. These mark the specific slots/props a consumer is expected to use
421
+ * to customize the component — distinct from `slots` (which lists every
422
+ * slot that exists) and `properties` (which lists everything the class
423
+ * declares). See #642 for why we need both.
424
+ *
425
+ * Tag shapes:
426
+ * `@overridableSlot name — purpose`
427
+ * `@overridableSlot `name` purpose`
428
+ * `@overridableProp name — purpose`
429
+ * (any of em-dash, en-dash, or hyphen between name and purpose; optional)
430
+ */
431
+ extractOverridableSurface(content) {
432
+ const jsDocMatch = content.match(/\/\*\*[\s\S]*?\*\/\s*export class/);
433
+ if (!jsDocMatch)
434
+ return undefined;
435
+ const jsDocText = jsDocMatch[0];
436
+ const slots = [];
437
+ const props = [];
438
+ const slotRegex = /@overridableSlot\s+`?([\w-]+)`?\s*[-—–]?\s*([^\n]*)/gi;
439
+ let m;
440
+ while ((m = slotRegex.exec(jsDocText)) !== null) {
441
+ const name = m[1];
442
+ const purpose = m[2].trim();
443
+ if (name)
444
+ slots.push({ name, purpose });
445
+ }
446
+ const propRegex = /@overridableProp\s+`?([\w-]+)`?\s*[-—–]?\s*([^\n]*)/gi;
447
+ while ((m = propRegex.exec(jsDocText)) !== null) {
448
+ const name = m[1];
449
+ const purpose = m[2].trim();
450
+ if (name)
451
+ props.push({ name, purpose });
452
+ }
453
+ if (slots.length === 0 && props.length === 0)
454
+ return undefined;
455
+ return { slots, props };
456
+ }
457
+ /**
458
+ * Extract the canonical invocation of the component.
459
+ *
460
+ * Sourced from two places:
461
+ * - Optional `@canonicalUsage` JSDoc tag on the class → prose `note`
462
+ * - The `Default` export in the component's stories file → `default`
463
+ * markup (only when the story uses `html\`...\`` — imperative
464
+ * `document.createElement` stories are skipped because they have no
465
+ * static markup to extract)
466
+ *
467
+ * The goal (per #642): when a consumer asks "use the default site header",
468
+ * an agent can call `eddie_get_component` and get back the exact markup to
469
+ * paste. No interpretation, no guessing.
470
+ */
471
+ extractCanonicalUsage(content, componentDir, componentName) {
472
+ let note;
473
+ const jsDocMatch = content.match(/\/\*\*[\s\S]*?\*\/\s*export class/);
474
+ if (jsDocMatch) {
475
+ const tagMatch = jsDocMatch[0].match(/@canonicalUsage\s+([^\n]+)/i);
476
+ if (tagMatch)
477
+ note = tagMatch[1].trim();
478
+ }
479
+ const storiesPath = join(componentDir, `${componentName}.stories.ts`);
480
+ const markup = this.extractDefaultStoryMarkup(storiesPath);
481
+ if (!note && !markup)
482
+ return undefined;
483
+ return { default: markup, note };
484
+ }
485
+ /**
486
+ * Extract a `@contentApi { ... }` JSDoc tag from the class-level comment.
487
+ *
488
+ * The tag value is a JSON-ish object literal (single quotes or double quotes
489
+ * accepted, trailing commas tolerated). Three keys are recognized:
490
+ *
491
+ * contentVia — string description of the primary content path
492
+ * labelVia — "prop" | "slot" | "none"
493
+ * labelVisibility — "visible" | "hidden" | "accessible-only"
494
+ *
495
+ * Components that don't declare the tag get `undefined`. See #627: this is
496
+ * the structural answer to "where does this component's content come from?",
497
+ * letting consumers and validators detect prop-vs-slot mismatches before
498
+ * they render as silent drops.
499
+ */
500
+ extractContentApi(content) {
501
+ const jsDocMatch = content.match(/\/\*\*[\s\S]*?\*\/\s*export class/);
502
+ if (!jsDocMatch)
503
+ return undefined;
504
+ const tagMatch = jsDocMatch[0].match(/@contentApi\s*(\{[\s\S]*?\})/i);
505
+ if (!tagMatch)
506
+ return undefined;
507
+ // The tag body is JSON-ish but lenient: bare keys, single or double
508
+ // quotes, and trailing commas are all acceptable. Pull out each known
509
+ // field by name with a tolerant per-key regex rather than relying on
510
+ // JSON.parse — the JSDoc continuation asterisks make the body messy
511
+ // enough that strict parsing isn't worth it.
512
+ const body = tagMatch[1].replace(/^\s*\*\s?/gm, '');
513
+ const result = {};
514
+ const contentVia = body.match(/\bcontentVia\s*:\s*['"]([^'"]+)['"]/);
515
+ if (contentVia)
516
+ result.contentVia = contentVia[1];
517
+ const labelVia = body.match(/\blabelVia\s*:\s*['"](prop|slot|none)['"]/);
518
+ if (labelVia)
519
+ result.labelVia = labelVia[1];
520
+ const labelVisibility = body.match(/\blabelVisibility\s*:\s*['"](visible|hidden|accessible-only)['"]/);
521
+ if (labelVisibility) {
522
+ result.labelVisibility = labelVisibility[1];
523
+ }
524
+ if (Object.keys(result).length === 0)
525
+ return undefined;
526
+ return result;
527
+ }
528
+ /**
529
+ * Parse the `Default` story template literal out of a stories file.
530
+ *
531
+ * Recognizes the common Eddie story shape:
532
+ *
533
+ * export const Default = () => html`<ed-foo></ed-foo>`;
534
+ * export const Default = (args: Args) => html`<ed-foo>...</ed-foo>`;
535
+ * export const Default = () => html`
536
+ * <ed-foo>
537
+ * ...
538
+ * </ed-foo>
539
+ * `;
540
+ *
541
+ * Returns undefined for imperative stories (`document.createElement(...)`)
542
+ * which have no extractable markup — consumers of those recipes interact
543
+ * via JS properties rather than markup and don't benefit from a canonical
544
+ * markup string.
545
+ */
546
+ extractDefaultStoryMarkup(storiesPath) {
547
+ if (!existsSync(storiesPath))
548
+ return undefined;
549
+ let content;
550
+ try {
551
+ content = readFileSync(storiesPath, 'utf-8');
552
+ }
553
+ catch {
554
+ return undefined;
555
+ }
556
+ const match = content.match(/export\s+const\s+Default[^=]*=\s*(?:\([^)]*\)\s*=>\s*)?(?:\{\s*return\s+)?html`([^`]*)`/);
557
+ if (!match)
558
+ return undefined;
559
+ return match[1].trim();
560
+ }
561
+ /**
562
+ * Extract @property decorated properties
563
+ */
564
+ extractProperties(content) {
565
+ const properties = [];
566
+ // Match @property decorators and following property declarations.
567
+ //
568
+ // IMPORTANT: `[^=;\n]*` (not `[^=]*`) constrains the between-name-and-equals
569
+ // match so it cannot cross statement terminators. Previously the regex used
570
+ // `[^=]*` which is greedy and newline-permissive, so after matching a
571
+ // property declaration with no default (e.g. `variant?: 'bare';`) the
572
+ // engine would continue across newlines looking for the next `=` sign
573
+ // anywhere in the file — typically landing inside the render method on
574
+ // something like `const componentClassNames = this.componentClassNames(...)`
575
+ // and capturing truncated method-call text as the "default value". The
576
+ // tighter character class prevents that cross-statement leakage.
577
+ const propRegex = /@property\s*\(\s*({[^}]*})?\s*\)\s*(?:declare\s+)?(\w+)[^=;\n]*(?:=\s*([^;\n]+))?/g;
578
+ let match;
579
+ while ((match = propRegex.exec(content)) !== null) {
580
+ const decoratorConfig = match[1] || '';
581
+ const propName = match[2];
582
+ const rawDefault = match[3]?.trim();
583
+ // Sanity-check the captured default: if it has unbalanced parens/braces
584
+ // it's a truncated expression (multi-line method call, template literal,
585
+ // object initializer that extends past the line) and should not be
586
+ // emitted as a "default value". Emit nothing in that case.
587
+ const defaultValue = rawDefault && this.hasBalancedDelimiters(rawDefault)
588
+ ? rawDefault
589
+ : undefined;
590
+ // Extract type from property context
591
+ const contextStart = Math.max(0, match.index - 500);
592
+ const contextEnd = match.index + 300;
593
+ const context = content.substring(contextStart, contextEnd);
594
+ const typeMatch = context.match(new RegExp(`${propName}\\s*:\\s*([^;=\n]+)`));
595
+ const propType = typeMatch ? typeMatch[1].trim().split(/[{|}]/)[0].trim() : 'string';
596
+ // Check if required
597
+ const required = !decoratorConfig.includes('type: Boolean') && !defaultValue;
598
+ // Extract the JSDoc comment IMMEDIATELY preceding this @property decorator.
599
+ //
600
+ // Historic bug (#642): the previous implementation used a forward regex
601
+ // `/\/\*\*[\s\S]*?\*\/\s*@property/` on a 500-char-back context window.
602
+ // Non-greedy matching starts at the earliest `/**` in the window, so
603
+ // when property A's JSDoc + declaration fits into the window preceding
604
+ // property B, the regex finds A's `/** ... */ @property a: ...` and
605
+ // returns A's description for B. Reproducible on `ed-logo`: the
606
+ // `accent` property was getting `href`'s description.
607
+ //
608
+ // Fix: walk backward from the @property match to find the closest `*/`,
609
+ // verify there's only whitespace between that `*/` and the @property
610
+ // (so we know this JSDoc actually belongs to THIS property), then find
611
+ // the matching `/**`.
612
+ const before = content.substring(0, match.index);
613
+ const jsDocEnd = before.lastIndexOf('*/');
614
+ let description = '';
615
+ if (jsDocEnd !== -1) {
616
+ const between = before.substring(jsDocEnd + 2);
617
+ if (/^\s*$/.test(between)) {
618
+ const jsDocStart = before.lastIndexOf('/**', jsDocEnd);
619
+ if (jsDocStart !== -1) {
620
+ description = before
621
+ .substring(jsDocStart, jsDocEnd + 2)
622
+ .replace(/\/\*\*/, '')
623
+ .replace(/\*\//, '')
624
+ .split('\n')
625
+ .map((line) => line.replace(/^\s*\*\s?/, '').trim())
626
+ .filter((line) => line.length > 0)
627
+ .join(' ');
628
+ }
629
+ }
630
+ }
631
+ // Extract enum options if present
632
+ const options = this.extractEnumOptions(propType);
633
+ properties.push({
634
+ name: propName,
635
+ type: propType.split('|')[0].trim(),
636
+ default: defaultValue,
637
+ description,
638
+ options,
639
+ required,
640
+ });
641
+ }
642
+ return properties;
643
+ }
644
+ /**
645
+ * Extract enum options from union types
646
+ */
647
+ extractEnumOptions(type) {
648
+ const unionMatch = type.match(/['"]([^'"]+)['"]\s*\|\s*['"]([^'"]+)['"]/g);
649
+ if (unionMatch) {
650
+ return unionMatch.map((opt) => opt.replace(/['"]/g, ''));
651
+ }
652
+ return undefined;
653
+ }
654
+ /**
655
+ * Extract slot definitions from JSDoc comments.
656
+ *
657
+ * Parses the Eddie JSDoc slot convention, which comes in these forms:
658
+ *
659
+ * @slot - The default slot description. (default slot, leading dash)
660
+ * @slot header - Optional header content (named slot, name before dash)
661
+ * @slot `header` Optional header content (named slot, backticked name)
662
+ * @slot Just a default description (default slot, no dash, bare prose)
663
+ *
664
+ * Also looks for `<slot name="x">` in the render template as a secondary
665
+ * source for slots that weren't explicitly documented in JSDoc.
666
+ *
667
+ * Historic bug: the previous regex `/@slot\s*(?:-\s*)?([^\n]*)/` consumed
668
+ * the optional leading `- ` and then an inner regex `/^`?(\w+)`?/` grabbed
669
+ * the first word of the remaining text as the slot name. For a default slot
670
+ * like `@slot - The grid items`, this produced slot name "The" (the first
671
+ * word of "The grid items") instead of "default". Bug fix: distinguish the
672
+ * four cases above explicitly.
673
+ */
674
+ extractSlots(content) {
675
+ const slots = [];
676
+ // Grab each @slot JSDoc line individually
677
+ const slotLineRegex = /@slot\s+([^\n]*)/g;
678
+ let match;
679
+ while ((match = slotLineRegex.exec(content)) !== null) {
680
+ const rest = match[1].trim();
681
+ let name;
682
+ let description;
683
+ if (rest.startsWith('-')) {
684
+ // Form: "@slot - description" → default slot
685
+ name = 'default';
686
+ description = rest.slice(1).trim();
687
+ }
688
+ else {
689
+ const backtickMatch = rest.match(/^`(\w+)`\s*-?\s*(.*)$/);
690
+ const dashMatch = rest.match(/^(\w+)\s+-\s+(.*)$/);
691
+ if (backtickMatch) {
692
+ // Form: "@slot `name` description" → named slot, backticked
693
+ name = backtickMatch[1];
694
+ description = backtickMatch[2].trim();
695
+ }
696
+ else if (dashMatch) {
697
+ // Form: "@slot name - description" → named slot, dash-separated
698
+ name = dashMatch[1];
699
+ description = dashMatch[2].trim();
700
+ }
701
+ else {
702
+ // Form: "@slot Just a description" → default slot, bare prose.
703
+ // We do NOT pluck the first word as a name — that was the historic
704
+ // bug. If the author wanted a named slot they would have used one
705
+ // of the explicit forms above.
706
+ name = 'default';
707
+ description = rest;
708
+ }
709
+ }
710
+ slots.push({ name, description });
711
+ }
712
+ // Supplement with any slots declared in the render template that weren't
713
+ // caught by JSDoc (e.g., when the author named a slot but didn't document
714
+ // it with @slot).
715
+ const renderSlotRegex = /<slot\s+name=["']([^"']+)["']/g;
716
+ let renderMatch;
717
+ while ((renderMatch = renderSlotRegex.exec(content)) !== null) {
718
+ const slotName = renderMatch[1];
719
+ if (!slots.find((s) => s.name === slotName)) {
720
+ slots.push({ name: slotName, description: '' });
721
+ }
722
+ }
723
+ return slots;
724
+ }
725
+ /**
726
+ * Returns true if the given string has balanced parens, braces, and
727
+ * brackets. Used to reject truncated expressions (like multi-line method
728
+ * calls that span past the regex capture boundary) from being emitted as
729
+ * property default values.
730
+ */
731
+ hasBalancedDelimiters(s) {
732
+ let paren = 0;
733
+ let brace = 0;
734
+ let bracket = 0;
735
+ for (const c of s) {
736
+ if (c === '(')
737
+ paren++;
738
+ else if (c === ')')
739
+ paren--;
740
+ else if (c === '{')
741
+ brace++;
742
+ else if (c === '}')
743
+ brace--;
744
+ else if (c === '[')
745
+ bracket++;
746
+ else if (c === ']')
747
+ bracket--;
748
+ if (paren < 0 || brace < 0 || bracket < 0)
749
+ return false;
750
+ }
751
+ return paren === 0 && brace === 0 && bracket === 0;
752
+ }
753
+ /**
754
+ * Extract custom events from dispatch calls
755
+ */
756
+ extractEvents(content) {
757
+ const events = [];
758
+ // Look for dispatchEvent or dispatch calls
759
+ const dispatchRegex = /dispatch\(['"]([^'"]+)['"]/g;
760
+ let match;
761
+ while ((match = dispatchRegex.exec(content)) !== null) {
762
+ const eventName = match[1];
763
+ if (!events.find((e) => e.name === eventName)) {
764
+ events.push({
765
+ name: eventName,
766
+ description: '',
767
+ });
768
+ }
769
+ }
770
+ return events;
771
+ }
772
+ /**
773
+ * Extract CSS custom properties referenced
774
+ */
775
+ extractCssCustomProperties(content) {
776
+ const props = new Set();
777
+ // Look for var(--ed-*) in SCSS content
778
+ const varRegex = /var\((--ed[^)]+)\)/g;
779
+ let match;
780
+ while ((match = varRegex.exec(content)) !== null) {
781
+ props.add(match[1]);
782
+ }
783
+ return Array.from(props).sort();
784
+ }
785
+ /**
786
+ * Extract intent/purpose from JSDoc comment.
787
+ *
788
+ * Parses the class-level JSDoc preceding `export class` and returns the
789
+ * first real description paragraph. A "paragraph" is a sequence of
790
+ * consecutive non-blank, non-@tag lines joined with spaces.
791
+ *
792
+ * Heading detection: Eddie components sometimes begin their JSDoc with a
793
+ * one-word title line (e.g. "Grid", "Button") on its own, separated from
794
+ * the actual description by a blank line. In that case the first paragraph
795
+ * is just the single heading word, which is not a useful intent. If the
796
+ * first paragraph is a short heading-like line AND a second paragraph
797
+ * exists, return the second paragraph instead.
798
+ *
799
+ * Historic bug: the previous implementation used `.slice(0, 1)` on the
800
+ * filtered lines, which returned only the first non-blank non-@ line
801
+ * regardless of context — so `ed-grid`'s intent came back as literally the
802
+ * single word "Grid" while its real description lived on the next
803
+ * paragraph. Bug fix: paragraph-aware extraction.
804
+ */
805
+ extractIntent(content) {
806
+ const jsDocMatch = content.match(/\/\*\*[\s\S]*?\*\/\s*export class/);
807
+ if (!jsDocMatch) {
808
+ return '';
809
+ }
810
+ const lines = jsDocMatch[0]
811
+ .replace(/\/\*\*/, '')
812
+ .replace(/\*\//, '')
813
+ .split('\n')
814
+ .map((line) => line.replace(/^\s*\*\s?/, '').trim());
815
+ // Group lines into paragraphs. Blank lines and @tag lines act as
816
+ // paragraph separators.
817
+ const paragraphs = [];
818
+ let current = [];
819
+ for (const line of lines) {
820
+ if (line.length === 0 || line.startsWith('@')) {
821
+ if (current.length > 0) {
822
+ paragraphs.push(current);
823
+ current = [];
824
+ }
825
+ }
826
+ else {
827
+ current.push(line);
828
+ }
829
+ }
830
+ if (current.length > 0)
831
+ paragraphs.push(current);
832
+ if (paragraphs.length === 0)
833
+ return '';
834
+ // Heading-detection heuristic: if the first paragraph is a single line
835
+ // of at most 3 words, treat it as a heading and prefer the next
836
+ // paragraph (if one exists). This handles components whose JSDoc starts
837
+ // with a title like "Grid" before the real description.
838
+ const firstParagraph = paragraphs[0].join(' ');
839
+ const looksLikeHeading = paragraphs[0].length === 1 && firstParagraph.split(/\s+/).filter(Boolean).length <= 3;
840
+ if (looksLikeHeading && paragraphs.length > 1) {
841
+ return paragraphs[1].join(' ');
842
+ }
843
+ return firstParagraph;
844
+ }
845
+ /**
846
+ * Get atomic level from the component's own stories file.
847
+ *
848
+ * `componentDir` is the component's actual directory (e.g.
849
+ * `packages/eddie-web-components/components/button` or
850
+ * `packages/eddie-recipes/recipes/common/project-card`). The stories file
851
+ * lives directly inside it as `<componentName>.stories.ts`.
852
+ */
853
+ async getAtomicLevel(componentName, componentDir) {
854
+ try {
855
+ const storiesPath = join(componentDir, `${componentName}.stories.ts`);
856
+ const stories = readFileSync(storiesPath, 'utf-8');
857
+ // Extract title from default export
858
+ const titleMatch = stories.match(/title:\s*['"]([^'"]+)['"]/);
859
+ if (!titleMatch) {
860
+ return 'atom';
861
+ }
862
+ const title = titleMatch[1].toLowerCase();
863
+ if (title.includes('atoms'))
864
+ return 'atom';
865
+ if (title.includes('molecules'))
866
+ return 'molecule';
867
+ if (title.includes('organisms'))
868
+ return 'organism';
869
+ if (title.includes('recipes'))
870
+ return 'recipe';
871
+ if (title.includes('charts'))
872
+ return 'recipe';
873
+ if (title.includes('pages'))
874
+ return 'page';
875
+ return 'atom';
876
+ }
877
+ catch {
878
+ return 'atom';
879
+ }
880
+ }
881
+ /**
882
+ * Determine parent/child relationships for compound components
883
+ */
884
+ determineCompoundRelationship(tagName, componentName) {
885
+ // Check if this is a child component (starts with a parent name)
886
+ const parentMatch = Array.from(this.components.values()).find((comp) => tagName !== comp.tagName && tagName.startsWith(comp.tagName + '-'));
887
+ if (parentMatch) {
888
+ return {
889
+ parentComponent: parentMatch.tagName,
890
+ };
891
+ }
892
+ // Check if this is a parent with children
893
+ const children = Array.from(this.components.values())
894
+ .filter((comp) => comp.tagName.startsWith(tagName + '-'))
895
+ .map((comp) => comp.tagName);
896
+ if (children.length > 0) {
897
+ return {
898
+ childComponents: children,
899
+ };
900
+ }
901
+ return {};
902
+ }
903
+ /**
904
+ * Convert class name to display name
905
+ */
906
+ classNameToDisplayName(className) {
907
+ return className
908
+ .replace(/^Ed/, '')
909
+ .replace(/([A-Z])/g, ' $1')
910
+ .trim();
911
+ }
912
+ /**
913
+ * Get a component by tag name
914
+ */
915
+ getComponent(tagName) {
916
+ return this.components.get(tagName);
917
+ }
918
+ /**
919
+ * Get all components
920
+ */
921
+ getAll() {
922
+ return Array.from(this.components.values());
923
+ }
924
+ /**
925
+ * Search components by query string
926
+ */
927
+ /**
928
+ * Search components by natural-language query.
929
+ *
930
+ * Historic bug (#610): used naive `.includes()` substring matching against
931
+ * only tagName / displayName / intent. A multi-word query like "marketing
932
+ * homepage" never matched `ed-p-marketing-homepage` because the literal
933
+ * substring "marketing homepage" (with a space) is not anywhere in the
934
+ * tagName, displayName, or single-sentence intent. Pages were effectively
935
+ * invisible to any query an agent would actually type.
936
+ *
937
+ * Fix: tokenize the query, drop stopwords and very short terms, and score
938
+ * each candidate against the full set of fields that carry meaning —
939
+ * tagName, displayName, intent, guidelines.use, property names and
940
+ * descriptions, slot names and descriptions. Pages get a ranking boost
941
+ * when the query contains page-intent keywords ("page", "template",
942
+ * "dashboard", "homepage", "login", "article", etc.) because in that
943
+ * case the agent is looking for a full composition starting point, not
944
+ * a primitive.
945
+ */
946
+ search(query) {
947
+ const STOPWORDS = new Set([
948
+ 'a', 'an', 'the', 'of', 'in', 'on', 'at', 'to', 'for', 'by',
949
+ 'is', 'it', 'as', 'or', 'and', 'with', 'that', 'this', 'from',
950
+ 'how', 'what', 'which', 'use', 'using', 'build', 'make',
951
+ ]);
952
+ const PAGE_KEYWORDS = new Set([
953
+ 'page', 'pages', 'template', 'starter', 'shell', 'layout',
954
+ 'homepage', 'dashboard', 'article', 'login', 'authentication',
955
+ 'auth', 'checkout', 'form', 'grid',
956
+ ]);
957
+ const terms = query
958
+ .toLowerCase()
959
+ .split(/\s+/)
960
+ .map((t) => t.replace(/[^a-z0-9-]/g, ''))
961
+ .filter((t) => t.length > 2 && !STOPWORDS.has(t));
962
+ if (terms.length === 0)
963
+ return [];
964
+ const queryWantsPage = terms.some((t) => PAGE_KEYWORDS.has(t));
965
+ const scoreEntry = (comp) => {
966
+ const haystack = {
967
+ tagName: comp.tagName.toLowerCase(),
968
+ displayName: comp.displayName.toLowerCase(),
969
+ intent: comp.intent.toLowerCase(),
970
+ useGuidelines: comp.guidelines.use.join(' ').toLowerCase(),
971
+ propNames: comp.properties.map((p) => p.name.toLowerCase()).join(' '),
972
+ propDescriptions: comp.properties.map((p) => p.description.toLowerCase()).join(' '),
973
+ slotNames: comp.slots.map((s) => s.name.toLowerCase()).join(' '),
974
+ slotDescriptions: comp.slots.map((s) => s.description.toLowerCase()).join(' '),
975
+ };
976
+ let score = 0;
977
+ for (const t of terms) {
978
+ if (haystack.tagName.includes(t))
979
+ score += 5;
980
+ if (haystack.displayName.includes(t))
981
+ score += 3;
982
+ if (haystack.intent.includes(t))
983
+ score += 3;
984
+ if (haystack.useGuidelines.includes(t))
985
+ score += 2;
986
+ if (haystack.slotNames.includes(t))
987
+ score += 2;
988
+ if (haystack.propNames.includes(t))
989
+ score += 1;
990
+ if (haystack.slotDescriptions.includes(t))
991
+ score += 1;
992
+ if (haystack.propDescriptions.includes(t))
993
+ score += 1;
994
+ }
995
+ // Boost pages when the query reads like it wants a page template.
996
+ if (queryWantsPage && comp.atomicLevel === 'page')
997
+ score += 6;
998
+ return score;
999
+ };
1000
+ return this.getAll()
1001
+ .map((comp) => ({ comp, score: scoreEntry(comp) }))
1002
+ .filter((r) => r.score > 0)
1003
+ .sort((a, b) => b.score - a.score)
1004
+ .map((r) => r.comp);
1005
+ }
1006
+ /**
1007
+ * Get components by atomic level
1008
+ */
1009
+ getByAtomicLevel(level) {
1010
+ return this.getAll().filter((comp) => comp.atomicLevel === level);
1011
+ }
1012
+ /**
1013
+ * Serialize to JSON
1014
+ */
1015
+ toJSON() {
1016
+ const obj = {};
1017
+ for (const [tagName, entry] of this.components) {
1018
+ obj[tagName] = entry;
1019
+ }
1020
+ return obj;
1021
+ }
1022
+ /**
1023
+ * Deserialize from JSON
1024
+ */
1025
+ static fromJSON(obj) {
1026
+ const index = new ComponentIndex();
1027
+ for (const [tagName, entry] of Object.entries(obj)) {
1028
+ index.components.set(tagName, entry);
1029
+ }
1030
+ return index;
1031
+ }
1032
+ }
1033
+ //# sourceMappingURL=component-index.js.map