@m4ike1/ion-cue 0.1.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.
- package/README.md +92 -0
- package/dist/dispatcher.d.ts +31 -0
- package/dist/dispatcher.d.ts.map +1 -0
- package/dist/dispatcher.js +230 -0
- package/dist/dispatcher.js.map +1 -0
- package/dist/harness-bridge.d.ts +13 -0
- package/dist/harness-bridge.d.ts.map +1 -0
- package/dist/harness-bridge.js +29 -0
- package/dist/harness-bridge.js.map +1 -0
- package/dist/harness-router.d.ts +12 -0
- package/dist/harness-router.d.ts.map +1 -0
- package/dist/harness-router.js +189 -0
- package/dist/harness-router.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/injector.d.ts +23 -0
- package/dist/injector.d.ts.map +1 -0
- package/dist/injector.js +62 -0
- package/dist/injector.js.map +1 -0
- package/dist/marks.d.ts +26 -0
- package/dist/marks.d.ts.map +1 -0
- package/dist/marks.js +59 -0
- package/dist/marks.js.map +1 -0
- package/dist/messages.d.ts +23 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +2 -0
- package/dist/messages.js.map +1 -0
- package/dist/registry.d.ts +94 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +582 -0
- package/dist/registry.js.map +1 -0
- package/dist/resolver.d.ts +15 -0
- package/dist/resolver.d.ts.map +1 -0
- package/dist/resolver.js +50 -0
- package/dist/resolver.js.map +1 -0
- package/dist/scanner.d.ts +32 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +252 -0
- package/dist/scanner.js.map +1 -0
- package/dist/scope.d.ts +25 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +180 -0
- package/dist/scope.js.map +1 -0
- package/dist/secret-store.d.ts +16 -0
- package/dist/secret-store.d.ts.map +1 -0
- package/dist/secret-store.js +89 -0
- package/dist/secret-store.js.map +1 -0
- package/dist/stream-events.d.ts +26 -0
- package/dist/stream-events.d.ts.map +1 -0
- package/dist/stream-events.js +2 -0
- package/dist/stream-events.js.map +1 -0
- package/dist/toml.d.ts +3 -0
- package/dist/toml.d.ts.map +1 -0
- package/dist/toml.js +117 -0
- package/dist/toml.js.map +1 -0
- package/dist/types.d.ts +182 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +5 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +61 -0
- package/dist/validation.js.map +1 -0
- package/package.json +23 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"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","sourcesContent":["import { lstatSync, readdirSync, readFileSync, statSync } from \"fs\";\nimport { homedir } from \"os\";\nimport { join } from \"path\";\nimport { parseElementToml } from \"./toml.ts\";\nimport type { BehavioralDimension, ElementDef, SectionRange } from \"./types.ts\";\nimport { detectCircularUses } from \"./validation.ts\";\n\nexport interface AliasConfig {\n\taliases: Record<string, string>;\n}\n\n/**\n * Parse alias configuration from a cue.toml-style config string.\n * Expects an [aliases] section with key = \"value\" pairs.\n */\nexport function parseAliasConfig(content: string): AliasConfig {\n\tconst aliases: Record<string, string> = {};\n\tconst lines = content.split(\"\\n\");\n\tlet inAliases = false;\n\n\tfor (const line of lines) {\n\t\tconst trimmed = line.trim();\n\t\tif (!trimmed || trimmed.startsWith(\"#\")) continue;\n\n\t\tif (trimmed === \"[aliases]\") {\n\t\t\tinAliases = true;\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Exit aliases section on any other section header\n\t\tif (trimmed.startsWith(\"[\") && trimmed.endsWith(\"]\") && inAliases) {\n\t\t\tinAliases = false;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (inAliases) {\n\t\t\tconst kvMatch = trimmed.match(/^([a-zA-Z0-9_-]+)\\s*=\\s*(.+)$/);\n\t\t\tif (kvMatch) {\n\t\t\t\tconst key = kvMatch[1]!;\n\t\t\t\tlet value = kvMatch[2]!.trim();\n\t\t\t\t// Strip quotes\n\t\t\t\tif ((value.startsWith('\"') && value.endsWith('\"')) || (value.startsWith(\"'\") && value.endsWith(\"'\"))) {\n\t\t\t\t\tvalue = value.slice(1, -1);\n\t\t\t\t}\n\t\t\t\taliases[key] = value;\n\t\t\t}\n\t\t}\n\t}\n\n\treturn { aliases };\n}\n\nconst VALID_DIMENSIONS: BehavioralDimension[] = [\n\t\"tone\",\n\t\"register\",\n\t\"length\",\n\t\"depth\",\n\t\"structure\",\n\t\"format\",\n\t\"voice\",\n\t\"vocabulary\",\n];\n\ninterface ElementEntry {\n\tdef: ElementDef;\n\ttomlPath: string;\n\tmdPath: string;\n\tsectionIndex?: Map<string, SectionRange>;\n\tmtime?: number;\n}\n\ninterface SharedTagEntry {\n\tpath: string;\n\tsectionIndex?: Map<string, SectionRange>;\n\tmtime?: number;\n}\n\nexport interface CueRoot {\n\t/** Element root directory. */\n\tpath: string;\n\t/** Config scope the root belongs to (drives override matching). */\n\tscope: \"user\" | \"project\";\n}\n\n/**\n * Single source of truth for cue element discovery roots. Used by\n * CueRegistry.discover(), watchedPaths(), and the package manager's\n * resource collection so the config UI lists exactly what will load.\n */\nexport function cueDiscoveryRoots(\n\toptions: { projectRoot?: string; skipHome?: boolean; additionalRoots?: string[] } = {},\n): CueRoot[] {\n\tconst roots: CueRoot[] = (options.additionalRoots ?? []).map((path) => ({ path, scope: \"user\" as const }));\n\tif (!options.skipHome) {\n\t\troots.push({ path: join(homedir(), \".ion\", \"agent\", \"cue\", \"elements\"), scope: \"user\" });\n\t}\n\tif (options.projectRoot) {\n\t\troots.push({ path: join(options.projectRoot, \".ion\", \"cue\", \"elements\"), scope: \"project\" });\n\t}\n\treturn roots;\n}\n\nexport class CueRegistry {\n\tprivate elements = new Map<string, ElementEntry>();\n\tprivate sharedTags = new Map<string, SharedTagEntry>();\n\tprivate realpathCache = new Map<string, string>();\n\tprivate baseRoots: string[];\n\tprivate skipHome: boolean;\n\tprivate disabledNames = new Set<string>();\n\n\tconstructor(additionalRoots: string[] = [], options?: { skipHome?: boolean }) {\n\t\tthis.baseRoots = [...additionalRoots];\n\t\tthis.skipHome = options?.skipHome ?? false;\n\t}\n\n\tprivate computeRoots(projectRoot?: string): string[] {\n\t\treturn cueDiscoveryRoots({\n\t\t\tprojectRoot,\n\t\t\tskipHome: this.skipHome,\n\t\t\tadditionalRoots: this.baseRoots,\n\t\t}).map((r) => r.path);\n\t}\n\n\t/**\n\t * Idempotent: clears all discovered state and re-scans, so it is safe\n\t * to call again when watched files change (see watchedPaths).\n\t * `disabled` holds lowercased element names to skip (config UI toggles).\n\t */\n\tdiscover(projectRoot?: string, options?: { disabled?: string[] }): void {\n\t\tthis.elements.clear();\n\t\tthis.sharedTags.clear();\n\t\tthis.realpathCache.clear();\n\t\tconst roots = this.computeRoots(projectRoot);\n\t\tthis.disabledNames = new Set((options?.disabled ?? []).map((n) => n.toLowerCase()));\n\n\t\tfor (const root of roots) {\n\t\t\tthis.discoverFromRoot(root);\n\t\t}\n\n\t\tthis.discoverSharedTags(roots);\n\n\t\tconst defs = new Map<string, ElementDef>();\n\t\tfor (const [name, entry] of this.elements) {\n\t\t\tdefs.set(name, entry.def);\n\t\t}\n\t\tconst circularErrors = detectCircularUses(defs);\n\t\tfor (const err of circularErrors) {\n\t\t\tconsole.warn(err);\n\t\t}\n\t}\n\n\t/**\n\t * Existing directories whose changes invalidate discovery: element\n\t * roots, shared-tags dirs, and cue.toml parents. Callers watch these\n\t * (non-recursively) and call discover() again on change, then re-arm\n\t * to pick up newly created subdirectories.\n\t */\n\twatchedPaths(projectRoot?: string): string[] {\n\t\tconst dirs = new Set<string>();\n\t\tconst roots = this.computeRoots(projectRoot);\n\t\tfor (const root of roots) {\n\t\t\tdirs.add(root);\n\t\t\tdirs.add(join(root, \"..\", \"tags\"));\n\t\t\ttry {\n\t\t\t\tfor (const entry of readdirSync(root, { withFileTypes: true })) {\n\t\t\t\t\tif (entry.isDirectory()) dirs.add(join(root, entry.name));\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Root does not exist yet; the parent re-arm covers its creation.\n\t\t\t}\n\t\t}\n\t\t// Alias configs live next to the elements roots.\n\t\tif (!this.skipHome) dirs.add(join(homedir(), \".ion\", \"agent\", \"cue\"));\n\t\tif (projectRoot) dirs.add(join(projectRoot, \".ion\", \"cue\"));\n\t\tconst existing: string[] = [];\n\t\tfor (const dir of dirs) {\n\t\t\ttry {\n\t\t\t\tif (statSync(dir).isDirectory() && !existing.includes(dir)) existing.push(dir);\n\t\t\t} catch {\n\t\t\t\t// Does not exist yet; covered when the parent appears.\n\t\t\t}\n\t\t}\n\t\treturn existing;\n\t}\n\n\tprivate discoverSharedTags(roots: string[]): void {\n\t\tconst tagsDirs = [join(homedir(), \".ion\", \"agent\", \"cue\", \"tags\"), ...roots.map((r) => join(r, \"..\", \"tags\"))];\n\n\t\tfor (const tagsDir of tagsDirs) {\n\t\t\tlet entries: string[];\n\t\t\ttry {\n\t\t\t\tentries = readdirSync(tagsDir);\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tfor (const file of entries) {\n\t\t\t\tif (!file.endsWith(\".md\")) continue;\n\t\t\t\tconst tagName = file.slice(0, -3).toLowerCase();\n\t\t\t\tif (this.sharedTags.has(tagName)) continue;\n\n\t\t\t\tconst filePath = join(tagsDir, file);\n\t\t\t\tthis.sharedTags.set(tagName, { path: filePath });\n\t\t\t}\n\t\t}\n\t}\n\n\tgetSharedTagSectionIndex(tagName: string): Map<string, SectionRange> | undefined {\n\t\tconst entry = this.sharedTags.get(tagName.toLowerCase());\n\t\tif (!entry) return undefined;\n\n\t\ttry {\n\t\t\tconst stat = statSync(entry.path);\n\t\t\tconst currentMtime = stat.mtimeMs;\n\n\t\t\tif (entry.sectionIndex && entry.mtime === currentMtime) {\n\t\t\t\treturn entry.sectionIndex;\n\t\t\t}\n\n\t\t\tconst body = readFileSync(entry.path, \"utf-8\");\n\t\t\tconst index = this.buildSectionIndex(body);\n\t\t\tentry.sectionIndex = index;\n\t\t\tentry.mtime = currentMtime;\n\t\t\treturn index;\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\tloadSharedTagBody(tagName: string): string | undefined {\n\t\tconst entry = this.sharedTags.get(tagName.toLowerCase());\n\t\tif (!entry) return undefined;\n\t\ttry {\n\t\t\treturn readFileSync(entry.path, \"utf-8\");\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\tgetSharedTagOverrides(tagName: string): BehavioralDimension[] | undefined {\n\t\tconst entry = this.sharedTags.get(tagName.toLowerCase());\n\t\tif (!entry) return undefined;\n\n\t\ttry {\n\t\t\tconst body = readFileSync(entry.path, \"utf-8\");\n\t\t\tconst fm = parseFrontmatter(body);\n\t\t\treturn fm.overrides.length > 0 ? fm.overrides : undefined;\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\tgetSharedTagDefs(): Map<string, { description: string; overrides: BehavioralDimension[] }> {\n\t\tconst defs = new Map<string, { description: string; overrides: BehavioralDimension[] }>();\n\n\t\tfor (const [name, entry] of this.sharedTags) {\n\t\t\ttry {\n\t\t\t\tconst body = readFileSync(entry.path, \"utf-8\");\n\t\t\t\tconst fm = parseFrontmatter(body);\n\n\t\t\t\tif (!fm.description) {\n\t\t\t\t\tconst descMatch = body.match(/^##\\s+(?:Tag:\\s+)?[^\\n]+\\n+([^\\n#]+)/m);\n\t\t\t\t\tif (descMatch) fm.description = descMatch[1].trim();\n\t\t\t\t}\n\n\t\t\t\tdefs.set(name, fm);\n\t\t\t} catch {}\n\t\t}\n\n\t\treturn defs;\n\t}\n\n\tprivate discoverFromRoot(root: string): void {\n\t\tlet entries: string[];\n\t\ttry {\n\t\t\tentries = readdirSync(root);\n\t\t} catch {\n\t\t\treturn;\n\t\t}\n\n\t\tconst rootTomls: string[] = [];\n\n\t\tfor (const authorDir of entries) {\n\t\t\tconst authorPath = join(root, authorDir);\n\t\t\tlet isDir = false;\n\t\t\ttry {\n\t\t\t\tisDir = statSync(authorPath).isDirectory();\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (!isDir) {\n\t\t\t\tif (authorDir.endsWith(\".toml\")) rootTomls.push(authorDir);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tlet authorEntries: string[];\n\t\t\ttry {\n\t\t\t\tauthorEntries = readdirSync(authorPath);\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tfor (const entry of authorEntries) {\n\t\t\t\tif (!entry.endsWith(\".toml\")) continue;\n\t\t\t\tthis.registerElement(authorPath, entry);\n\t\t\t}\n\t\t}\n\n\t\tfor (const entry of rootTomls) {\n\t\t\tthis.registerElement(root, entry);\n\t\t}\n\t}\n\n\tprivate registerElement(dir: string, tomlFile: string): void {\n\t\tconst name = tomlFile.slice(0, -5);\n\t\tif (this.disabledNames.has(name.toLowerCase())) return;\n\t\tconst tomlPath = join(dir, tomlFile);\n\t\tconst mdPath = join(dir, `${name}.md`);\n\n\t\ttry {\n\t\t\tstatSync(mdPath);\n\t\t} catch {\n\t\t\treturn;\n\t\t}\n\n\t\tconst realToml = this.resolveRealpath(tomlPath);\n\t\tif (this.isDuplicate(realToml)) return;\n\n\t\tconst content = readFileSync(tomlPath, \"utf-8\");\n\t\tconst def = parseElementToml(content);\n\n\t\tconst normalized = def.name.toLowerCase();\n\t\tconst versionedKey = def.version ? `${normalized}@${def.version}` : normalized;\n\n\t\t// Store versioned entry\n\t\tif (this.elements.has(versionedKey)) {\n\t\t\tconsole.warn(`Element \"${def.name}@${def.version}\" from ${tomlPath} shadowed by earlier definition; skipping`);\n\t\t\treturn;\n\t\t}\n\n\t\tthis.validateElement(def);\n\n\t\ttry {\n\t\t\tconst mdBody = readFileSync(mdPath, \"utf-8\");\n\t\t\tthis.validateTagHeaderAlignment(def, mdBody, normalized);\n\t\t} catch {\n\t\t\t// md read failure already handled by missing stat above\n\t\t}\n\n\t\tconst entry: ElementEntry = { def, tomlPath, mdPath };\n\t\tthis.elements.set(versionedKey, entry);\n\n\t\t// Also store under plain name if this is the latest version or first registration\n\t\tif (!this.elements.has(normalized)) {\n\t\t\tthis.elements.set(normalized, entry);\n\t\t} else {\n\t\t\t// Compare versions: keep the latest under the plain name\n\t\t\tconst existing = this.elements.get(normalized)!;\n\t\t\tif (def.version && existing.def.version && this.compareVersions(def.version, existing.def.version) > 0) {\n\t\t\t\tthis.elements.set(normalized, entry);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate resolveRealpath(path: string): string {\n\t\tconst cached = this.realpathCache.get(path);\n\t\tif (cached) return cached;\n\n\t\tlet real: string;\n\t\ttry {\n\t\t\tconst stat = lstatSync(path);\n\t\t\tif (stat.isSymbolicLink()) {\n\t\t\t\tconst { realpathSync } = require(\"fs\");\n\t\t\t\treal = realpathSync(path);\n\t\t\t} else {\n\t\t\t\treal = path;\n\t\t\t}\n\t\t} catch {\n\t\t\treal = path;\n\t\t}\n\n\t\tthis.realpathCache.set(path, real);\n\t\treturn real;\n\t}\n\n\tprivate isDuplicate(realpath: string): boolean {\n\t\tfor (const entry of this.elements.values()) {\n\t\t\tif (this.resolveRealpath(entry.tomlPath) === realpath) return true;\n\t\t}\n\t\treturn false;\n\t}\n\n\t/**\n\t * Load aliases from cue.toml config files in project root.\n\t * Project-level aliases override home-level aliases.\n\t */\n\tloadAliases(projectRoot?: string): Record<string, string> {\n\t\tconst aliases: Record<string, string> = {};\n\n\t\t// Home directory config\n\t\tif (!this.skipHome) {\n\t\t\tconst homeConfig = join(homedir(), \".ion\", \"agent\", \"cue\", \"cue.toml\");\n\t\t\ttry {\n\t\t\t\tconst content = readFileSync(homeConfig, \"utf-8\");\n\t\t\t\tconst parsed = parseAliasConfig(content);\n\t\t\t\tObject.assign(aliases, parsed.aliases);\n\t\t\t} catch {\n\t\t\t\t// Config file doesn't exist or unreadable, skip\n\t\t\t}\n\t\t}\n\n\t\t// Project directory config (overrides home)\n\t\tif (projectRoot) {\n\t\t\tconst projectConfig = join(projectRoot, \".ion\", \"cue\", \"cue.toml\");\n\t\t\ttry {\n\t\t\t\tconst content = readFileSync(projectConfig, \"utf-8\");\n\t\t\t\tconst parsed = parseAliasConfig(content);\n\t\t\t\tObject.assign(aliases, parsed.aliases);\n\t\t\t} catch {\n\t\t\t\t// Config file doesn't exist or unreadable, skip\n\t\t\t}\n\t\t}\n\n\t\treturn aliases;\n\t}\n\n\tprivate validateElement(def: ElementDef): void {\n\t\tif (def.class === \"harness\" && !def.handler) {\n\t\t\tconsole.warn(`Element \"${def.name}\" has class harness but no handler`);\n\t\t}\n\n\t\tfor (const [tagName, tagDef] of Object.entries(def.tags)) {\n\t\t\tfor (const dim of tagDef.overrides) {\n\t\t\t\tif (!VALID_DIMENSIONS.includes(dim)) {\n\t\t\t\t\tconsole.warn(`Element \"${def.name}\" tag \"${tagName}\" has invalid override dimension: ${dim}`);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate validateTagHeaderAlignment(def: ElementDef, body: string, elementName: string): void {\n\t\tconst headerRe = /^##\\s+Tag:\\s+(.+)$/gm;\n\t\tconst headerTags = new Map<string, string>();\n\n\t\tfor (let match = headerRe.exec(body); match !== null; match = headerRe.exec(body)) {\n\t\t\tconst originalName = match[1].trim();\n\t\t\tconst normalizedName = originalName.toLowerCase().replace(/\\s+/g, \"-\");\n\t\t\theaderTags.set(normalizedName, originalName);\n\t\t}\n\n\t\tconst tomlTags = new Set(Object.keys(def.tags).map((t) => t.toLowerCase()));\n\n\t\tfor (const [normalizedTag, originalTag] of headerTags) {\n\t\t\tif (!tomlTags.has(normalizedTag)) {\n\t\t\t\tconsole.warn(\n\t\t\t\t\t`Element \"${elementName}\" has orphan tag header \"## Tag: ${originalTag}\" with no [tags.${normalizedTag}] TOML entry`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\tfor (const tag of tomlTags) {\n\t\t\tif (!headerTags.has(tag)) {\n\t\t\t\tconsole.warn(\n\t\t\t\t\t`Element \"${elementName}\" has undeclared [tags.${tag}] TOML entry with no matching \"## Tag:\" header`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t}\n\n\tget(name: string): ElementDef | undefined {\n\t\treturn this.elements.get(name.toLowerCase())?.def;\n\t}\n\n\t/**\n\t * Get an element by name and optional version.\n\t * If version is provided, tries to find the exact version first, then falls back to latest compatible.\n\t * If version is not provided, returns the latest version.\n\t */\n\tgetByVersion(name: string, version?: string): ElementDef | undefined {\n\t\tconst normalized = name.toLowerCase();\n\n\t\tif (!version) {\n\t\t\t// No version pin: return latest\n\t\t\treturn this.elements.get(normalized)?.def;\n\t\t}\n\n\t\t// Try exact version match first\n\t\tconst versionedKey = `${normalized}@${version}`;\n\t\tconst exact = this.elements.get(versionedKey);\n\t\tif (exact) return exact.def;\n\n\t\t// Try prefix match for semver (e.g., \"1.2\" matches \"1.2.x\")\n\t\tconst prefix = version.endsWith(\".\") ? version : `${version}.`;\n\t\tfor (const [key, entry] of this.elements) {\n\t\t\tif (key.startsWith(`${normalized}@`) && key.slice(normalized.length + 1).startsWith(prefix)) {\n\t\t\t\treturn entry.def;\n\t\t\t}\n\t\t}\n\n\t\t// Fall back to latest\n\t\treturn this.elements.get(normalized)?.def;\n\t}\n\n\t/**\n\t * Compare two semver versions.\n\t * Returns positive if a > b, negative if a < b, 0 if equal.\n\t */\n\tprivate compareVersions(a: string, b: string): number {\n\t\tconst parseVersion = (v: string) => {\n\t\t\tconst parts = v.split(\".\").map((p) => parseInt(p, 10) || 0);\n\t\t\treturn { major: parts[0] ?? 0, minor: parts[1] ?? 0, patch: parts[2] ?? 0 };\n\t\t};\n\n\t\tconst va = parseVersion(a);\n\t\tconst vb = parseVersion(b);\n\n\t\tif (va.major !== vb.major) return va.major - vb.major;\n\t\tif (va.minor !== vb.minor) return va.minor - vb.minor;\n\t\treturn va.patch - vb.patch;\n\t}\n\n\thas(name: string): boolean {\n\t\treturn this.elements.has(name.toLowerCase());\n\t}\n\n\tlist(): ElementDef[] {\n\t\t// Deduplicate: versioned keys and plain-name keys point to the same entry.\n\t\t// Return only one entry per element by tracking seen toml paths.\n\t\tconst seen = new Set<string>();\n\t\tconst result: ElementDef[] = [];\n\t\tfor (const entry of this.elements.values()) {\n\t\t\tif (seen.has(entry.tomlPath)) continue;\n\t\t\tseen.add(entry.tomlPath);\n\t\t\tresult.push(entry.def);\n\t\t}\n\t\treturn result;\n\t}\n\n\tgetSectionIndex(name: string): Map<string, SectionRange> | undefined {\n\t\tconst entry = this.elements.get(name.toLowerCase());\n\t\tif (!entry) return undefined;\n\n\t\tconst stat = statSync(entry.mdPath);\n\t\tconst currentMtime = stat.mtimeMs;\n\n\t\tif (entry.sectionIndex && entry.mtime === currentMtime) {\n\t\t\treturn entry.sectionIndex;\n\t\t}\n\n\t\tconst body = readFileSync(entry.mdPath, \"utf-8\");\n\t\tconst index = this.buildSectionIndex(body);\n\t\tentry.sectionIndex = index;\n\t\tentry.mtime = currentMtime;\n\t\treturn index;\n\t}\n\n\tloadBody(name: string): string | undefined {\n\t\tconst entry = this.elements.get(name.toLowerCase());\n\t\tif (!entry) return undefined;\n\t\ttry {\n\t\t\treturn readFileSync(entry.mdPath, \"utf-8\");\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\t/**\n\t * Load body for a specific version of an element.\n\t */\n\tloadBodyByVersion(name: string, version?: string): string | undefined {\n\t\tconst normalized = name.toLowerCase();\n\n\t\tif (!version) {\n\t\t\treturn this.loadBody(name);\n\t\t}\n\n\t\t// Try exact version match first\n\t\tconst versionedKey = `${normalized}@${version}`;\n\t\tconst exact = this.elements.get(versionedKey);\n\t\tif (exact) {\n\t\t\ttry {\n\t\t\t\treturn readFileSync(exact.mdPath, \"utf-8\");\n\t\t\t} catch {\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t}\n\n\t\t// Fall back to latest\n\t\treturn this.loadBody(name);\n\t}\n\n\tgetMdPath(name: string): string | undefined {\n\t\treturn this.elements.get(name.toLowerCase())?.mdPath;\n\t}\n\n\tprivate buildSectionIndex(body: string): Map<string, SectionRange> {\n\t\tconst index = new Map<string, SectionRange>();\n\t\tconst headerRe = /^##\\s+(?:Tag:\\s+)?(.+)$/gm;\n\t\tconst headerPositions: Array<{ name: string; pos: number }> = [];\n\n\t\tfor (let match = headerRe.exec(body); match !== null; match = headerRe.exec(body)) {\n\t\t\theaderPositions.push({ name: match[1].trim(), pos: match.index });\n\t\t}\n\n\t\tfor (let i = 0; i < headerPositions.length; i++) {\n\t\t\tconst start = headerPositions[i].pos;\n\t\t\tconst end = i + 1 < headerPositions.length ? headerPositions[i + 1].pos : body.length;\n\t\t\tconst name = headerPositions[i].name;\n\n\t\t\tif (name.toLowerCase() === \"default behavior\") {\n\t\t\t\tindex.set(\"default\", { start, end, tag: \"default\" });\n\t\t\t} else if (name.toLowerCase().startsWith(\"tag:\")) {\n\t\t\t\tconst tag = name.slice(4).trim();\n\t\t\t\tindex.set(tag.toLowerCase(), { start, end, tag });\n\t\t\t} else {\n\t\t\t\tindex.set(name.toLowerCase(), { start, end, tag: name });\n\t\t\t}\n\t\t}\n\n\t\treturn index;\n\t}\n}\n\nfunction parseFrontmatter(body: string): { description: string; overrides: BehavioralDimension[] } {\n\tconst result: { description: string; overrides: BehavioralDimension[] } = { description: \"\", overrides: [] };\n\n\tconst fmMatch = body.match(/^---\\s*\\n([\\s\\S]*?)\\n---/);\n\tif (!fmMatch) return result;\n\n\tconst fm = fmMatch[1];\n\tfor (const line of fm.split(\"\\n\")) {\n\t\tconst trimmed = line.trim();\n\t\tif (!trimmed || trimmed.startsWith(\"#\")) continue;\n\n\t\tconst kvMatch = trimmed.match(/^([a-zA-Z_-]+)\\s*[:=]\\s*(.+)$/);\n\t\tif (!kvMatch) continue;\n\n\t\tconst [, key, valueStr] = kvMatch;\n\t\tif (key === \"description\") {\n\t\t\tresult.description = valueStr\n\t\t\t\t.trim()\n\t\t\t\t.replace(/^\"(.*)\"$/, \"$1\")\n\t\t\t\t.replace(/^'(.*)'$/, \"$1\");\n\t\t} else if (key === \"overrides\") {\n\t\t\tconst inner = valueStr.trim();\n\t\t\tif (inner.startsWith(\"[\") && inner.endsWith(\"]\")) {\n\t\t\t\tresult.overrides = inner\n\t\t\t\t\t.slice(1, -1)\n\t\t\t\t\t.split(\",\")\n\t\t\t\t\t.map((s) =>\n\t\t\t\t\t\ts\n\t\t\t\t\t\t\t.trim()\n\t\t\t\t\t\t\t.replace(/^\"(.*)\"$/, \"$1\")\n\t\t\t\t\t\t\t.replace(/^'(.*)'$/, \"$1\"),\n\t\t\t\t\t)\n\t\t\t\t\t.filter(Boolean) as BehavioralDimension[];\n\t\t\t}\n\t\t}\n\t}\n\n\treturn result;\n}\n"]}
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import { lstatSync, readdirSync, readFileSync, statSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { parseElementToml } from "./toml.js";
|
|
5
|
+
import { detectCircularUses } from "./validation.js";
|
|
6
|
+
/**
|
|
7
|
+
* Parse alias configuration from a cue.toml-style config string.
|
|
8
|
+
* Expects an [aliases] section with key = "value" pairs.
|
|
9
|
+
*/
|
|
10
|
+
export function parseAliasConfig(content) {
|
|
11
|
+
const aliases = {};
|
|
12
|
+
const lines = content.split("\n");
|
|
13
|
+
let inAliases = false;
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
const trimmed = line.trim();
|
|
16
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
17
|
+
continue;
|
|
18
|
+
if (trimmed === "[aliases]") {
|
|
19
|
+
inAliases = true;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
// Exit aliases section on any other section header
|
|
23
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]") && inAliases) {
|
|
24
|
+
inAliases = false;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (inAliases) {
|
|
28
|
+
const kvMatch = trimmed.match(/^([a-zA-Z0-9_-]+)\s*=\s*(.+)$/);
|
|
29
|
+
if (kvMatch) {
|
|
30
|
+
const key = kvMatch[1];
|
|
31
|
+
let value = kvMatch[2].trim();
|
|
32
|
+
// Strip quotes
|
|
33
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
34
|
+
value = value.slice(1, -1);
|
|
35
|
+
}
|
|
36
|
+
aliases[key] = value;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { aliases };
|
|
41
|
+
}
|
|
42
|
+
const VALID_DIMENSIONS = [
|
|
43
|
+
"tone",
|
|
44
|
+
"register",
|
|
45
|
+
"length",
|
|
46
|
+
"depth",
|
|
47
|
+
"structure",
|
|
48
|
+
"format",
|
|
49
|
+
"voice",
|
|
50
|
+
"vocabulary",
|
|
51
|
+
];
|
|
52
|
+
/**
|
|
53
|
+
* Single source of truth for cue element discovery roots. Used by
|
|
54
|
+
* CueRegistry.discover(), watchedPaths(), and the package manager's
|
|
55
|
+
* resource collection so the config UI lists exactly what will load.
|
|
56
|
+
*/
|
|
57
|
+
export function cueDiscoveryRoots(options = {}) {
|
|
58
|
+
const roots = (options.additionalRoots ?? []).map((path) => ({ path, scope: "user" }));
|
|
59
|
+
if (!options.skipHome) {
|
|
60
|
+
roots.push({ path: join(homedir(), ".ion", "agent", "cue", "elements"), scope: "user" });
|
|
61
|
+
}
|
|
62
|
+
if (options.projectRoot) {
|
|
63
|
+
roots.push({ path: join(options.projectRoot, ".ion", "cue", "elements"), scope: "project" });
|
|
64
|
+
}
|
|
65
|
+
return roots;
|
|
66
|
+
}
|
|
67
|
+
export class CueRegistry {
|
|
68
|
+
elements = new Map();
|
|
69
|
+
sharedTags = new Map();
|
|
70
|
+
realpathCache = new Map();
|
|
71
|
+
baseRoots;
|
|
72
|
+
skipHome;
|
|
73
|
+
disabledNames = new Set();
|
|
74
|
+
constructor(additionalRoots = [], options) {
|
|
75
|
+
this.baseRoots = [...additionalRoots];
|
|
76
|
+
this.skipHome = options?.skipHome ?? false;
|
|
77
|
+
}
|
|
78
|
+
computeRoots(projectRoot) {
|
|
79
|
+
return cueDiscoveryRoots({
|
|
80
|
+
projectRoot,
|
|
81
|
+
skipHome: this.skipHome,
|
|
82
|
+
additionalRoots: this.baseRoots,
|
|
83
|
+
}).map((r) => r.path);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Idempotent: clears all discovered state and re-scans, so it is safe
|
|
87
|
+
* to call again when watched files change (see watchedPaths).
|
|
88
|
+
* `disabled` holds lowercased element names to skip (config UI toggles).
|
|
89
|
+
*/
|
|
90
|
+
discover(projectRoot, options) {
|
|
91
|
+
this.elements.clear();
|
|
92
|
+
this.sharedTags.clear();
|
|
93
|
+
this.realpathCache.clear();
|
|
94
|
+
const roots = this.computeRoots(projectRoot);
|
|
95
|
+
this.disabledNames = new Set((options?.disabled ?? []).map((n) => n.toLowerCase()));
|
|
96
|
+
for (const root of roots) {
|
|
97
|
+
this.discoverFromRoot(root);
|
|
98
|
+
}
|
|
99
|
+
this.discoverSharedTags(roots);
|
|
100
|
+
const defs = new Map();
|
|
101
|
+
for (const [name, entry] of this.elements) {
|
|
102
|
+
defs.set(name, entry.def);
|
|
103
|
+
}
|
|
104
|
+
const circularErrors = detectCircularUses(defs);
|
|
105
|
+
for (const err of circularErrors) {
|
|
106
|
+
console.warn(err);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Existing directories whose changes invalidate discovery: element
|
|
111
|
+
* roots, shared-tags dirs, and cue.toml parents. Callers watch these
|
|
112
|
+
* (non-recursively) and call discover() again on change, then re-arm
|
|
113
|
+
* to pick up newly created subdirectories.
|
|
114
|
+
*/
|
|
115
|
+
watchedPaths(projectRoot) {
|
|
116
|
+
const dirs = new Set();
|
|
117
|
+
const roots = this.computeRoots(projectRoot);
|
|
118
|
+
for (const root of roots) {
|
|
119
|
+
dirs.add(root);
|
|
120
|
+
dirs.add(join(root, "..", "tags"));
|
|
121
|
+
try {
|
|
122
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
123
|
+
if (entry.isDirectory())
|
|
124
|
+
dirs.add(join(root, entry.name));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Root does not exist yet; the parent re-arm covers its creation.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Alias configs live next to the elements roots.
|
|
132
|
+
if (!this.skipHome)
|
|
133
|
+
dirs.add(join(homedir(), ".ion", "agent", "cue"));
|
|
134
|
+
if (projectRoot)
|
|
135
|
+
dirs.add(join(projectRoot, ".ion", "cue"));
|
|
136
|
+
const existing = [];
|
|
137
|
+
for (const dir of dirs) {
|
|
138
|
+
try {
|
|
139
|
+
if (statSync(dir).isDirectory() && !existing.includes(dir))
|
|
140
|
+
existing.push(dir);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Does not exist yet; covered when the parent appears.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return existing;
|
|
147
|
+
}
|
|
148
|
+
discoverSharedTags(roots) {
|
|
149
|
+
const tagsDirs = [join(homedir(), ".ion", "agent", "cue", "tags"), ...roots.map((r) => join(r, "..", "tags"))];
|
|
150
|
+
for (const tagsDir of tagsDirs) {
|
|
151
|
+
let entries;
|
|
152
|
+
try {
|
|
153
|
+
entries = readdirSync(tagsDir);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
for (const file of entries) {
|
|
159
|
+
if (!file.endsWith(".md"))
|
|
160
|
+
continue;
|
|
161
|
+
const tagName = file.slice(0, -3).toLowerCase();
|
|
162
|
+
if (this.sharedTags.has(tagName))
|
|
163
|
+
continue;
|
|
164
|
+
const filePath = join(tagsDir, file);
|
|
165
|
+
this.sharedTags.set(tagName, { path: filePath });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
getSharedTagSectionIndex(tagName) {
|
|
170
|
+
const entry = this.sharedTags.get(tagName.toLowerCase());
|
|
171
|
+
if (!entry)
|
|
172
|
+
return undefined;
|
|
173
|
+
try {
|
|
174
|
+
const stat = statSync(entry.path);
|
|
175
|
+
const currentMtime = stat.mtimeMs;
|
|
176
|
+
if (entry.sectionIndex && entry.mtime === currentMtime) {
|
|
177
|
+
return entry.sectionIndex;
|
|
178
|
+
}
|
|
179
|
+
const body = readFileSync(entry.path, "utf-8");
|
|
180
|
+
const index = this.buildSectionIndex(body);
|
|
181
|
+
entry.sectionIndex = index;
|
|
182
|
+
entry.mtime = currentMtime;
|
|
183
|
+
return index;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
loadSharedTagBody(tagName) {
|
|
190
|
+
const entry = this.sharedTags.get(tagName.toLowerCase());
|
|
191
|
+
if (!entry)
|
|
192
|
+
return undefined;
|
|
193
|
+
try {
|
|
194
|
+
return readFileSync(entry.path, "utf-8");
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
getSharedTagOverrides(tagName) {
|
|
201
|
+
const entry = this.sharedTags.get(tagName.toLowerCase());
|
|
202
|
+
if (!entry)
|
|
203
|
+
return undefined;
|
|
204
|
+
try {
|
|
205
|
+
const body = readFileSync(entry.path, "utf-8");
|
|
206
|
+
const fm = parseFrontmatter(body);
|
|
207
|
+
return fm.overrides.length > 0 ? fm.overrides : undefined;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
getSharedTagDefs() {
|
|
214
|
+
const defs = new Map();
|
|
215
|
+
for (const [name, entry] of this.sharedTags) {
|
|
216
|
+
try {
|
|
217
|
+
const body = readFileSync(entry.path, "utf-8");
|
|
218
|
+
const fm = parseFrontmatter(body);
|
|
219
|
+
if (!fm.description) {
|
|
220
|
+
const descMatch = body.match(/^##\s+(?:Tag:\s+)?[^\n]+\n+([^\n#]+)/m);
|
|
221
|
+
if (descMatch)
|
|
222
|
+
fm.description = descMatch[1].trim();
|
|
223
|
+
}
|
|
224
|
+
defs.set(name, fm);
|
|
225
|
+
}
|
|
226
|
+
catch { }
|
|
227
|
+
}
|
|
228
|
+
return defs;
|
|
229
|
+
}
|
|
230
|
+
discoverFromRoot(root) {
|
|
231
|
+
let entries;
|
|
232
|
+
try {
|
|
233
|
+
entries = readdirSync(root);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const rootTomls = [];
|
|
239
|
+
for (const authorDir of entries) {
|
|
240
|
+
const authorPath = join(root, authorDir);
|
|
241
|
+
let isDir = false;
|
|
242
|
+
try {
|
|
243
|
+
isDir = statSync(authorPath).isDirectory();
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (!isDir) {
|
|
249
|
+
if (authorDir.endsWith(".toml"))
|
|
250
|
+
rootTomls.push(authorDir);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
let authorEntries;
|
|
254
|
+
try {
|
|
255
|
+
authorEntries = readdirSync(authorPath);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
for (const entry of authorEntries) {
|
|
261
|
+
if (!entry.endsWith(".toml"))
|
|
262
|
+
continue;
|
|
263
|
+
this.registerElement(authorPath, entry);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
for (const entry of rootTomls) {
|
|
267
|
+
this.registerElement(root, entry);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
registerElement(dir, tomlFile) {
|
|
271
|
+
const name = tomlFile.slice(0, -5);
|
|
272
|
+
if (this.disabledNames.has(name.toLowerCase()))
|
|
273
|
+
return;
|
|
274
|
+
const tomlPath = join(dir, tomlFile);
|
|
275
|
+
const mdPath = join(dir, `${name}.md`);
|
|
276
|
+
try {
|
|
277
|
+
statSync(mdPath);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const realToml = this.resolveRealpath(tomlPath);
|
|
283
|
+
if (this.isDuplicate(realToml))
|
|
284
|
+
return;
|
|
285
|
+
const content = readFileSync(tomlPath, "utf-8");
|
|
286
|
+
const def = parseElementToml(content);
|
|
287
|
+
const normalized = def.name.toLowerCase();
|
|
288
|
+
const versionedKey = def.version ? `${normalized}@${def.version}` : normalized;
|
|
289
|
+
// Store versioned entry
|
|
290
|
+
if (this.elements.has(versionedKey)) {
|
|
291
|
+
console.warn(`Element "${def.name}@${def.version}" from ${tomlPath} shadowed by earlier definition; skipping`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
this.validateElement(def);
|
|
295
|
+
try {
|
|
296
|
+
const mdBody = readFileSync(mdPath, "utf-8");
|
|
297
|
+
this.validateTagHeaderAlignment(def, mdBody, normalized);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// md read failure already handled by missing stat above
|
|
301
|
+
}
|
|
302
|
+
const entry = { def, tomlPath, mdPath };
|
|
303
|
+
this.elements.set(versionedKey, entry);
|
|
304
|
+
// Also store under plain name if this is the latest version or first registration
|
|
305
|
+
if (!this.elements.has(normalized)) {
|
|
306
|
+
this.elements.set(normalized, entry);
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
// Compare versions: keep the latest under the plain name
|
|
310
|
+
const existing = this.elements.get(normalized);
|
|
311
|
+
if (def.version && existing.def.version && this.compareVersions(def.version, existing.def.version) > 0) {
|
|
312
|
+
this.elements.set(normalized, entry);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
resolveRealpath(path) {
|
|
317
|
+
const cached = this.realpathCache.get(path);
|
|
318
|
+
if (cached)
|
|
319
|
+
return cached;
|
|
320
|
+
let real;
|
|
321
|
+
try {
|
|
322
|
+
const stat = lstatSync(path);
|
|
323
|
+
if (stat.isSymbolicLink()) {
|
|
324
|
+
const { realpathSync } = require("fs");
|
|
325
|
+
real = realpathSync(path);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
real = path;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
real = path;
|
|
333
|
+
}
|
|
334
|
+
this.realpathCache.set(path, real);
|
|
335
|
+
return real;
|
|
336
|
+
}
|
|
337
|
+
isDuplicate(realpath) {
|
|
338
|
+
for (const entry of this.elements.values()) {
|
|
339
|
+
if (this.resolveRealpath(entry.tomlPath) === realpath)
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Load aliases from cue.toml config files in project root.
|
|
346
|
+
* Project-level aliases override home-level aliases.
|
|
347
|
+
*/
|
|
348
|
+
loadAliases(projectRoot) {
|
|
349
|
+
const aliases = {};
|
|
350
|
+
// Home directory config
|
|
351
|
+
if (!this.skipHome) {
|
|
352
|
+
const homeConfig = join(homedir(), ".ion", "agent", "cue", "cue.toml");
|
|
353
|
+
try {
|
|
354
|
+
const content = readFileSync(homeConfig, "utf-8");
|
|
355
|
+
const parsed = parseAliasConfig(content);
|
|
356
|
+
Object.assign(aliases, parsed.aliases);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
// Config file doesn't exist or unreadable, skip
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// Project directory config (overrides home)
|
|
363
|
+
if (projectRoot) {
|
|
364
|
+
const projectConfig = join(projectRoot, ".ion", "cue", "cue.toml");
|
|
365
|
+
try {
|
|
366
|
+
const content = readFileSync(projectConfig, "utf-8");
|
|
367
|
+
const parsed = parseAliasConfig(content);
|
|
368
|
+
Object.assign(aliases, parsed.aliases);
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
// Config file doesn't exist or unreadable, skip
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return aliases;
|
|
375
|
+
}
|
|
376
|
+
validateElement(def) {
|
|
377
|
+
if (def.class === "harness" && !def.handler) {
|
|
378
|
+
console.warn(`Element "${def.name}" has class harness but no handler`);
|
|
379
|
+
}
|
|
380
|
+
for (const [tagName, tagDef] of Object.entries(def.tags)) {
|
|
381
|
+
for (const dim of tagDef.overrides) {
|
|
382
|
+
if (!VALID_DIMENSIONS.includes(dim)) {
|
|
383
|
+
console.warn(`Element "${def.name}" tag "${tagName}" has invalid override dimension: ${dim}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
validateTagHeaderAlignment(def, body, elementName) {
|
|
389
|
+
const headerRe = /^##\s+Tag:\s+(.+)$/gm;
|
|
390
|
+
const headerTags = new Map();
|
|
391
|
+
for (let match = headerRe.exec(body); match !== null; match = headerRe.exec(body)) {
|
|
392
|
+
const originalName = match[1].trim();
|
|
393
|
+
const normalizedName = originalName.toLowerCase().replace(/\s+/g, "-");
|
|
394
|
+
headerTags.set(normalizedName, originalName);
|
|
395
|
+
}
|
|
396
|
+
const tomlTags = new Set(Object.keys(def.tags).map((t) => t.toLowerCase()));
|
|
397
|
+
for (const [normalizedTag, originalTag] of headerTags) {
|
|
398
|
+
if (!tomlTags.has(normalizedTag)) {
|
|
399
|
+
console.warn(`Element "${elementName}" has orphan tag header "## Tag: ${originalTag}" with no [tags.${normalizedTag}] TOML entry`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
for (const tag of tomlTags) {
|
|
403
|
+
if (!headerTags.has(tag)) {
|
|
404
|
+
console.warn(`Element "${elementName}" has undeclared [tags.${tag}] TOML entry with no matching "## Tag:" header`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
get(name) {
|
|
409
|
+
return this.elements.get(name.toLowerCase())?.def;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Get an element by name and optional version.
|
|
413
|
+
* If version is provided, tries to find the exact version first, then falls back to latest compatible.
|
|
414
|
+
* If version is not provided, returns the latest version.
|
|
415
|
+
*/
|
|
416
|
+
getByVersion(name, version) {
|
|
417
|
+
const normalized = name.toLowerCase();
|
|
418
|
+
if (!version) {
|
|
419
|
+
// No version pin: return latest
|
|
420
|
+
return this.elements.get(normalized)?.def;
|
|
421
|
+
}
|
|
422
|
+
// Try exact version match first
|
|
423
|
+
const versionedKey = `${normalized}@${version}`;
|
|
424
|
+
const exact = this.elements.get(versionedKey);
|
|
425
|
+
if (exact)
|
|
426
|
+
return exact.def;
|
|
427
|
+
// Try prefix match for semver (e.g., "1.2" matches "1.2.x")
|
|
428
|
+
const prefix = version.endsWith(".") ? version : `${version}.`;
|
|
429
|
+
for (const [key, entry] of this.elements) {
|
|
430
|
+
if (key.startsWith(`${normalized}@`) && key.slice(normalized.length + 1).startsWith(prefix)) {
|
|
431
|
+
return entry.def;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// Fall back to latest
|
|
435
|
+
return this.elements.get(normalized)?.def;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Compare two semver versions.
|
|
439
|
+
* Returns positive if a > b, negative if a < b, 0 if equal.
|
|
440
|
+
*/
|
|
441
|
+
compareVersions(a, b) {
|
|
442
|
+
const parseVersion = (v) => {
|
|
443
|
+
const parts = v.split(".").map((p) => parseInt(p, 10) || 0);
|
|
444
|
+
return { major: parts[0] ?? 0, minor: parts[1] ?? 0, patch: parts[2] ?? 0 };
|
|
445
|
+
};
|
|
446
|
+
const va = parseVersion(a);
|
|
447
|
+
const vb = parseVersion(b);
|
|
448
|
+
if (va.major !== vb.major)
|
|
449
|
+
return va.major - vb.major;
|
|
450
|
+
if (va.minor !== vb.minor)
|
|
451
|
+
return va.minor - vb.minor;
|
|
452
|
+
return va.patch - vb.patch;
|
|
453
|
+
}
|
|
454
|
+
has(name) {
|
|
455
|
+
return this.elements.has(name.toLowerCase());
|
|
456
|
+
}
|
|
457
|
+
list() {
|
|
458
|
+
// Deduplicate: versioned keys and plain-name keys point to the same entry.
|
|
459
|
+
// Return only one entry per element by tracking seen toml paths.
|
|
460
|
+
const seen = new Set();
|
|
461
|
+
const result = [];
|
|
462
|
+
for (const entry of this.elements.values()) {
|
|
463
|
+
if (seen.has(entry.tomlPath))
|
|
464
|
+
continue;
|
|
465
|
+
seen.add(entry.tomlPath);
|
|
466
|
+
result.push(entry.def);
|
|
467
|
+
}
|
|
468
|
+
return result;
|
|
469
|
+
}
|
|
470
|
+
getSectionIndex(name) {
|
|
471
|
+
const entry = this.elements.get(name.toLowerCase());
|
|
472
|
+
if (!entry)
|
|
473
|
+
return undefined;
|
|
474
|
+
const stat = statSync(entry.mdPath);
|
|
475
|
+
const currentMtime = stat.mtimeMs;
|
|
476
|
+
if (entry.sectionIndex && entry.mtime === currentMtime) {
|
|
477
|
+
return entry.sectionIndex;
|
|
478
|
+
}
|
|
479
|
+
const body = readFileSync(entry.mdPath, "utf-8");
|
|
480
|
+
const index = this.buildSectionIndex(body);
|
|
481
|
+
entry.sectionIndex = index;
|
|
482
|
+
entry.mtime = currentMtime;
|
|
483
|
+
return index;
|
|
484
|
+
}
|
|
485
|
+
loadBody(name) {
|
|
486
|
+
const entry = this.elements.get(name.toLowerCase());
|
|
487
|
+
if (!entry)
|
|
488
|
+
return undefined;
|
|
489
|
+
try {
|
|
490
|
+
return readFileSync(entry.mdPath, "utf-8");
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Load body for a specific version of an element.
|
|
498
|
+
*/
|
|
499
|
+
loadBodyByVersion(name, version) {
|
|
500
|
+
const normalized = name.toLowerCase();
|
|
501
|
+
if (!version) {
|
|
502
|
+
return this.loadBody(name);
|
|
503
|
+
}
|
|
504
|
+
// Try exact version match first
|
|
505
|
+
const versionedKey = `${normalized}@${version}`;
|
|
506
|
+
const exact = this.elements.get(versionedKey);
|
|
507
|
+
if (exact) {
|
|
508
|
+
try {
|
|
509
|
+
return readFileSync(exact.mdPath, "utf-8");
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// Fall back to latest
|
|
516
|
+
return this.loadBody(name);
|
|
517
|
+
}
|
|
518
|
+
getMdPath(name) {
|
|
519
|
+
return this.elements.get(name.toLowerCase())?.mdPath;
|
|
520
|
+
}
|
|
521
|
+
buildSectionIndex(body) {
|
|
522
|
+
const index = new Map();
|
|
523
|
+
const headerRe = /^##\s+(?:Tag:\s+)?(.+)$/gm;
|
|
524
|
+
const headerPositions = [];
|
|
525
|
+
for (let match = headerRe.exec(body); match !== null; match = headerRe.exec(body)) {
|
|
526
|
+
headerPositions.push({ name: match[1].trim(), pos: match.index });
|
|
527
|
+
}
|
|
528
|
+
for (let i = 0; i < headerPositions.length; i++) {
|
|
529
|
+
const start = headerPositions[i].pos;
|
|
530
|
+
const end = i + 1 < headerPositions.length ? headerPositions[i + 1].pos : body.length;
|
|
531
|
+
const name = headerPositions[i].name;
|
|
532
|
+
if (name.toLowerCase() === "default behavior") {
|
|
533
|
+
index.set("default", { start, end, tag: "default" });
|
|
534
|
+
}
|
|
535
|
+
else if (name.toLowerCase().startsWith("tag:")) {
|
|
536
|
+
const tag = name.slice(4).trim();
|
|
537
|
+
index.set(tag.toLowerCase(), { start, end, tag });
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
index.set(name.toLowerCase(), { start, end, tag: name });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return index;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
function parseFrontmatter(body) {
|
|
547
|
+
const result = { description: "", overrides: [] };
|
|
548
|
+
const fmMatch = body.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
549
|
+
if (!fmMatch)
|
|
550
|
+
return result;
|
|
551
|
+
const fm = fmMatch[1];
|
|
552
|
+
for (const line of fm.split("\n")) {
|
|
553
|
+
const trimmed = line.trim();
|
|
554
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
555
|
+
continue;
|
|
556
|
+
const kvMatch = trimmed.match(/^([a-zA-Z_-]+)\s*[:=]\s*(.+)$/);
|
|
557
|
+
if (!kvMatch)
|
|
558
|
+
continue;
|
|
559
|
+
const [, key, valueStr] = kvMatch;
|
|
560
|
+
if (key === "description") {
|
|
561
|
+
result.description = valueStr
|
|
562
|
+
.trim()
|
|
563
|
+
.replace(/^"(.*)"$/, "$1")
|
|
564
|
+
.replace(/^'(.*)'$/, "$1");
|
|
565
|
+
}
|
|
566
|
+
else if (key === "overrides") {
|
|
567
|
+
const inner = valueStr.trim();
|
|
568
|
+
if (inner.startsWith("[") && inner.endsWith("]")) {
|
|
569
|
+
result.overrides = inner
|
|
570
|
+
.slice(1, -1)
|
|
571
|
+
.split(",")
|
|
572
|
+
.map((s) => s
|
|
573
|
+
.trim()
|
|
574
|
+
.replace(/^"(.*)"$/, "$1")
|
|
575
|
+
.replace(/^'(.*)'$/, "$1"))
|
|
576
|
+
.filter(Boolean);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return result;
|
|
581
|
+
}
|
|
582
|
+
//# sourceMappingURL=registry.js.map
|