diff --git a/docs/resource-referrence-architech.md b/docs/resource-referrence-architech.md new file mode 100644 index 0000000..239b028 --- /dev/null +++ b/docs/resource-referrence-architech.md @@ -0,0 +1,475 @@ +# PromptX 统一资源协议架构设计文档 + +## 目录 +- [1. 概述](#1-概述) +- [2. 核心设计理念](#2-核心设计理念) +- [3. DPML协议体系设计](#3-dpml协议体系设计) +- [4. 多层级资源发现架构](#4-多层级资源发现架构) +- [5. 统一注册表设计](#5-统一注册表设计) +- [6. 协议解析执行流程](#6-协议解析执行流程) +- [7. 资源生命周期管理](#7-资源生命周期管理) +- [8. 扩展性设计](#8-扩展性设计) +- [9. 错误处理与容错机制](#9-错误处理与容错机制) +- [10. 性能优化策略](#10-性能优化策略) + +## 1. 概述 + +PromptX采用统一资源协议架构,支持多层级资源发现与动态注册机制。该架构通过DPML(Domain-Specific Prompt Markup Language)协议实现资源的统一标识、发现、注册和解析。 + +## 2. 核心设计理念 + +### 2.1 分层架构原则 + +- **协议层**: 统一的@协议标识体系 +- **发现层**: 多源资源发现机制 +- **注册层**: 统一的资源注册表 +- **解析层**: 协议到文件系统的映射 + +### 2.2 职责分离原则 + +- **Registry**: 专注资源映射存储和查询 +- **Discovery**: 专注特定来源的资源发现 +- **Protocol**: 专注协议解析和路径转换 +- **Manager**: 专注流程编排和生命周期管理 + +## 3. DPML协议体系设计 + +### 3.1 协议语法结构 + +```mermaid +graph LR + A["@[semantic][protocol]://[resource_path]"] --> B[加载语义] + A --> C[协议类型] + A --> D[资源路径] + + B --> B1["@ - 默认加载"] + B --> B2["@! - 热加载"] + B --> B3["@? - 懒加载"] + + C --> C1["package - NPM包资源"] + C --> C2["project - 项目本地资源"] + C --> C3["user - 用户全局资源"] + C --> C4["internet - 网络资源"] + C --> C5["file - 文件系统"] + C --> C6["thought - 思维模式"] + C --> C7["execution - 执行模式"] + C --> C8["role - AI角色"] + C --> C9["knowledge - 知识库"] +``` + +### 3.2 协议层级设计 + +```mermaid +classDiagram + class ProtocolSystem { + <> + +parseReference(ref: string) + +resolve(ref: string) + } + + class BasicProtocol { + +package + +project + +user + +internet + +file + } + + class LogicalProtocol { + +thought + +execution + +role + +knowledge + +memory + } + + ProtocolSystem <|-- BasicProtocol + ProtocolSystem <|-- LogicalProtocol + + BasicProtocol : +直接文件映射 + BasicProtocol : +路径解析 + LogicalProtocol : +注册表查询 + LogicalProtocol : +逻辑资源映射 +``` + +## 4. 多层级资源发现架构 + +### 4.1 发现层级结构 + +```mermaid +graph TD + A[资源发现生态系统] --> B[包级发现 PackageDiscovery] + A --> C[项目级发现 ProjectDiscovery] + A --> D[用户级发现 UserDiscovery] + A --> E[网络级发现 InternetDiscovery] + + B --> B1[NPM包内置资源] + B --> B2[src/resource.registry.json] + B --> B3[prompt/ 目录结构] + + C --> C1[项目本地资源] + C --> C2[.promptx/resource/] + C --> C3[动态角色发现] + + D --> D1[用户全局资源] + D --> D2[~/promptx/resource/] + D --> D3[用户自定义角色] + + E --> E1[远程资源库] + E --> E2[Git仓库资源] + E --> E3[在线角色市场] +``` + +### 4.2 发现优先级机制 + +```mermaid +sequenceDiagram + participant RM as ResourceManager + participant PD as PackageDiscovery + participant PRD as ProjectDiscovery + participant UD as UserDiscovery + participant ID as InternetDiscovery + participant RR as ResourceRegistry + + RM->>+PD: discover() [优先级: 1] + PD->>-RM: 系统内置资源列表 + + RM->>+PRD: discover() [优先级: 2] + PRD->>-RM: 项目本地资源列表 + + RM->>+UD: discover() [优先级: 3] + UD->>-RM: 用户全局资源列表 + + RM->>+ID: discover() [优先级: 4] + ID->>-RM: 网络资源列表 + + Note over RM: 按优先级合并,后发现的覆盖先发现的 + + RM->>RR: 批量注册所有发现的资源 +``` + +## 5. 统一注册表设计 + +### 5.1 注册表核心结构 + +```mermaid +classDiagram + class ResourceRegistry { + -Map~string,string~ index + -Map~string,ResourceMeta~ metadata + +register(id: string, reference: string) + +resolve(id: string): string + +merge(other: ResourceRegistry) + +has(id: string): boolean + +list(protocol?: string): string[] + } + + class ResourceMeta { + +source: DiscoverySource + +priority: number + +timestamp: Date + +metadata: object + } + + class DiscoverySource { + <> + PACKAGE + PROJECT + USER + INTERNET + } + + ResourceRegistry --> ResourceMeta + ResourceMeta --> DiscoverySource +``` + +### 5.2 注册表合并策略 + +```mermaid +flowchart TD + A[多源资源发现] --> B{资源ID冲突检测} + + B -->|无冲突| C[直接注册] + B -->|有冲突| D[优先级比较] + + D --> E{优先级策略} + E -->|用户 > 项目 > 包 > 网络| F[高优先级覆盖] + E -->|同优先级| G[时间戳比较] + + F --> H[更新注册表] + G --> H + C --> H + + H --> I[生成统一资源索引] +``` + +## 6. 协议解析执行流程 + +### 6.1 完整解析序列 + +```mermaid +sequenceDiagram + participant U as User + participant RM as ResourceManager + participant RR as ResourceRegistry + participant PR as ProtocolResolver + participant PP as PackageProtocol + participant PRP as ProjectProtocol + participant FS as FileSystem + + U->>RM: loadResource("role:xiaohongshu-copywriter") + + Note over RM: 1. 资源ID解析阶段 + RM->>RR: resolve("role:xiaohongshu-copywriter") + RR->>RM: "@project://.promptx/resource/domain/xiaohongshu-copywriter/xiaohongshu-copywriter.role.md" + + Note over RM: 2. 协议解析阶段 + RM->>PR: parseReference("@project://...") + PR->>RM: {semantic: "@", protocol: "project", path: "..."} + + Note over RM: 3. 路径解析阶段 + RM->>PRP: resolve(".promptx/resource/domain/...") + PRP->>FS: 定位项目根目录 + FS->>PRP: "/user/project/" + PRP->>RM: "/user/project/.promptx/resource/domain/xiaohongshu-copywriter/xiaohongshu-copywriter.role.md" + + Note over RM: 4. 文件加载阶段 + RM->>FS: readFile(absolutePath) + FS->>RM: 文件内容 + + RM->>U: {success: true, content: "...", path: "..."} +``` + +### 6.2 嵌套引用解析 + +```mermaid +sequenceDiagram + participant SR as SemanticRenderer + participant RM as ResourceManager + participant RR as ResourceRegistry + + Note over SR: 发现嵌套引用 @!thought://remember + + SR->>RM: resolve("@!thought://remember") + + Note over RM: 1. 解析加载语义 @! + RM->>RM: 识别热加载模式 + + Note over RM: 2. 解析逻辑协议 thought + RM->>RR: resolve("thought:remember") + RR->>RM: "@package://prompt/core/thought/remember.thought.md" + + Note over RM: 3. 解析基础协议 @package + RM->>RM: 委托给PackageProtocol + + Note over RM: 4. 返回解析结果 + RM->>SR: 思维模式内容 + + Note over SR: 5. 渲染到最终输出 +``` + +## 7. 资源生命周期管理 + +### 7.1 初始化流程 + +```mermaid +stateDiagram-v2 + [*] --> Uninitialized + + Uninitialized --> Discovering: initialize() + + Discovering --> PackageDiscovery: 发现包资源 + PackageDiscovery --> ProjectDiscovery: 发现项目资源 + ProjectDiscovery --> UserDiscovery: 发现用户资源 + UserDiscovery --> InternetDiscovery: 发现网络资源 + + InternetDiscovery --> Registering: 开始注册 + + Registering --> Merging: 合并资源 + Merging --> Indexing: 建立索引 + Indexing --> Ready: 完成初始化 + + Ready --> Resolving: resolve() + Resolving --> Ready: 返回结果 + + Ready --> Refreshing: refresh() + Refreshing --> Discovering: 重新发现 + + Ready --> [*]: destroy() +``` + +### 7.2 缓存与更新策略 + +```mermaid +flowchart TD + A[资源访问请求] --> B{缓存检查} + + B -->|命中| C[检查加载语义] + B -->|未命中| D[触发发现流程] + + C --> E{加载语义判断} + E -->|at 默认| F[返回缓存内容] + E -->|a! 热加载| G[强制重新加载] + E -->|a? 懒加载| H[延迟加载策略] + + D --> I[执行Discovery] + G --> I + H --> I + + I --> J[更新注册表] + J --> K[更新缓存] + K --> L[返回最新内容] + + F --> M[返回给用户] + L --> M +``` + +## 8. 扩展性设计 + +### 8.1 新协议扩展机制 + +```mermaid +classDiagram + class ProtocolExtension { + <> + +name: string + +resolve(path: string): string + +validate(path: string): boolean + } + + class DatabaseProtocol { + +name: "database" + +resolve(path): string + +validate(path): boolean + } + + class GitProtocol { + +name: "git" + +resolve(path): string + +validate(path): boolean + } + + class S3Protocol { + +name: "s3" + +resolve(path): string + +validate(path): boolean + } + + ProtocolExtension <|-- DatabaseProtocol + ProtocolExtension <|-- GitProtocol + ProtocolExtension <|-- S3Protocol +``` + +### 8.2 新发现源扩展 + +```mermaid +classDiagram + class DiscoveryExtension { + <> + +source: DiscoverySource + +priority: number + +discover(): Promise~Resource[]~ + } + + class GitDiscovery { + +source: GIT + +priority: 5 + +discover(): Promise~Resource[]~ + } + + class DatabaseDiscovery { + +source: DATABASE + +priority: 6 + +discover(): Promise~Resource[]~ + } + + class MarketplaceDiscovery { + +source: MARKETPLACE + +priority: 7 + +discover(): Promise~Resource[]~ + } + + DiscoveryExtension <|-- GitDiscovery + DiscoveryExtension <|-- DatabaseDiscovery + DiscoveryExtension <|-- MarketplaceDiscovery +``` + +## 9. 错误处理与容错机制 + +### 9.1 分层错误处理 + +```mermaid +flowchart TD + A[用户请求] --> B[ResourceManager] + + B --> C{Discovery阶段} + C -->|发现失败| D[降级到缓存] + C -->|部分失败| E[记录错误,继续处理] + C -->|完全失败| F[返回空注册表] + + B --> G{Registry阶段} + G -->|注册失败| H[跳过失败项] + G -->|冲突解决失败| I[使用默认策略] + + B --> J{Protocol阶段} + J -->|协议不支持| K[尝试文件协议] + J -->|路径解析失败| L[返回详细错误] + + B --> M{FileSystem阶段} + M -->|文件不存在| N[返回空内容标记] + M -->|权限错误| O[尝试替代路径] + + D --> P[用户获得部分功能] + E --> P + H --> P + I --> P + K --> P + + F --> Q[用户获得错误信息] + L --> Q + N --> Q + O --> Q +``` + +## 10. 性能优化策略 + +### 10.1 并行发现优化 + +| 模式 | PackageDiscovery | ProjectDiscovery | UserDiscovery | InternetDiscovery | 合并注册 | +|------|-----------------|------------------|---------------|-------------------|---------| +| 串行模式 | 0-200ms | 200-400ms | 400-600ms | 600-1000ms | - | +| 并行模式 | 0-200ms | 0-250ms | 0-180ms | 0-800ms | 800-850ms | + +### 10.2 缓存分层策略 + +```mermaid +graph TD + A[L1: 内存缓存] --> B[L2: 文件缓存] + B --> C[L3: 远程缓存] + + A --> A1[热点资源] + A --> A2[最近访问] + + B --> B1[用户资源] + B --> B2[项目资源] + + C --> C1[网络资源] + C --> C2[共享资源] + + D[缓存失效策略] --> D1[TTL过期] + D --> D2[版本变更] + D --> D3[手动刷新] +``` + +## 总结 + +这个架构设计确保了PromptX资源系统的高度可扩展性、跨平台兼容性和优秀的性能表现,同时保持了清晰的职责分离和统一的访问接口。 + +### 核心优势 + +1. **统一协议体系**: 通过DPML协议实现资源的标准化访问 +2. **多层级发现**: 支持包、项目、用户、网络多个层级的资源发现 +3. **智能注册表**: 自动处理资源冲突和优先级管理 +4. **高性能设计**: 并行发现和多层缓存策略 +5. **强容错性**: 完善的错误处理和降级机制 +6. **易扩展性**: 支持新协议和新发现源的动态扩展 \ No newline at end of file diff --git a/src/lib/core/resource/EnhancedResourceRegistry.js b/src/lib/core/resource/EnhancedResourceRegistry.js new file mode 100644 index 0000000..840cc79 --- /dev/null +++ b/src/lib/core/resource/EnhancedResourceRegistry.js @@ -0,0 +1,292 @@ +/** + * EnhancedResourceRegistry - 增强的资源注册表 + * + * 按照DPML协议架构文档设计,支持: + * 1. 资源元数据管理(source, priority, timestamp) + * 2. 智能合并策略(优先级和时间戳) + * 3. 发现源优先级管理 + * 4. 批量操作支持 + */ +class EnhancedResourceRegistry { + constructor() { + // 主索引:resourceId -> reference + this.index = new Map() + + // 元数据索引:resourceId -> metadata + this.metadata = new Map() + + // 发现源优先级映射 + this.sourcePriority = { + 'USER': 1, // 最高优先级 + 'PROJECT': 2, + 'PACKAGE': 3, + 'INTERNET': 4 // 最低优先级 + } + } + + /** + * 注册单个资源 + * @param {Object} resource - 资源对象 + * @param {string} resource.id - 资源ID + * @param {string} resource.reference - 资源引用 + * @param {Object} resource.metadata - 资源元数据 + */ + register(resource) { + this._validateResource(resource) + + const { id, reference, metadata } = resource + + // 如果资源已存在,检查是否应该覆盖 + if (this.has(id)) { + const existingMetadata = this.metadata.get(id) + if (!this._shouldOverride(existingMetadata, metadata)) { + return // 不覆盖,保持现有资源 + } + } + + // 注册资源 + this.index.set(id, reference) + this.metadata.set(id, { ...metadata }) + } + + /** + * 批量注册资源 + * @param {Array} resources - 资源数组 + */ + registerBatch(resources) { + if (!Array.isArray(resources)) { + throw new Error('Resources must be an array') + } + + resources.forEach(resource => { + try { + if (resource && typeof resource === 'object') { + this.register(resource) + } + } catch (error) { + console.warn(`[EnhancedResourceRegistry] Failed to register resource: ${error.message}`) + } + }) + } + + /** + * 合并另一个注册表 + * @param {EnhancedResourceRegistry} otherRegistry - 另一个注册表实例 + */ + merge(otherRegistry) { + if (!(otherRegistry instanceof EnhancedResourceRegistry)) { + throw new Error('Can only merge with another EnhancedResourceRegistry instance') + } + + // 获取所有资源并批量注册(会自动处理优先级) + const otherResources = otherRegistry.list().map(id => ({ + id, + reference: otherRegistry.resolve(id), + metadata: otherRegistry.getMetadata(id) + })) + + this.registerBatch(otherResources) + } + + /** + * 解析资源ID到引用 + * @param {string} resourceId - 资源ID + * @returns {string} 资源引用 + */ + resolve(resourceId) { + // 1. 直接查找 + if (this.index.has(resourceId)) { + return this.index.get(resourceId) + } + + // 2. 向后兼容:尝试添加协议前缀 + const protocols = ['role', 'thought', 'execution', 'memory'] + + for (const protocol of protocols) { + const fullId = `${protocol}:${resourceId}` + if (this.index.has(fullId)) { + return this.index.get(fullId) + } + } + + throw new Error(`Resource '${resourceId}' not found`) + } + + /** + * 检查资源是否存在 + * @param {string} resourceId - 资源ID + * @returns {boolean} 是否存在 + */ + has(resourceId) { + try { + this.resolve(resourceId) + return true + } catch (error) { + return false + } + } + + /** + * 获取资源元数据 + * @param {string} resourceId - 资源ID + * @returns {Object|null} 元数据对象或null + */ + getMetadata(resourceId) { + // 直接查找 + if (this.metadata.has(resourceId)) { + return { ...this.metadata.get(resourceId) } + } + + // 向后兼容查找 + const protocols = ['role', 'thought', 'execution', 'memory'] + + for (const protocol of protocols) { + const fullId = `${protocol}:${resourceId}` + if (this.metadata.has(fullId)) { + return { ...this.metadata.get(fullId) } + } + } + + return null + } + + /** + * 列出所有资源ID + * @param {string} [protocol] - 可选的协议过滤器 + * @returns {Array} 资源ID列表 + */ + list(protocol = null) { + const allIds = Array.from(this.index.keys()) + + if (!protocol) { + return allIds + } + + return allIds.filter(id => id.startsWith(`${protocol}:`)) + } + + /** + * 获取注册表大小 + * @returns {number} 资源数量 + */ + size() { + return this.index.size + } + + /** + * 清空注册表 + */ + clear() { + this.index.clear() + this.metadata.clear() + } + + /** + * 移除资源 + * @param {string} resourceId - 资源ID + */ + remove(resourceId) { + // 尝试直接移除 + if (this.index.has(resourceId)) { + this.index.delete(resourceId) + this.metadata.delete(resourceId) + return + } + + // 向后兼容移除 + const protocols = ['role', 'thought', 'execution', 'memory'] + + for (const protocol of protocols) { + const fullId = `${protocol}:${resourceId}` + if (this.index.has(fullId)) { + this.index.delete(fullId) + this.metadata.delete(fullId) + return + } + } + } + + /** + * 从发现管理器结果加载资源 + * @param {Array} discoveryResults - 发现器返回的资源数组 + */ + loadFromDiscoveryResults(discoveryResults) { + if (!Array.isArray(discoveryResults)) { + console.warn('[EnhancedResourceRegistry] Discovery results must be an array') + return + } + + this.registerBatch(discoveryResults) + } + + /** + * 验证资源对象 + * @param {Object} resource - 资源对象 + * @private + */ + _validateResource(resource) { + if (!resource || typeof resource !== 'object') { + throw new Error('Resource must be an object') + } + + if (!resource.id || !resource.reference) { + throw new Error('Resource must have id and reference') + } + + if (!resource.metadata || typeof resource.metadata !== 'object') { + throw new Error('Resource must have metadata with source and priority') + } + + if (!resource.metadata.source || typeof resource.metadata.priority !== 'number') { + throw new Error('Resource must have metadata with source and priority') + } + + // 验证ID格式 + if (typeof resource.id !== 'string' || !resource.id.includes(':')) { + throw new Error('Resource id must be in format "protocol:resourcePath"') + } + + // 验证引用格式 + if (typeof resource.reference !== 'string' || !resource.reference.startsWith('@')) { + throw new Error('Resource reference must be in DPML format "@protocol://path"') + } + } + + /** + * 判断是否应该覆盖现有资源 + * @param {Object} existingMetadata - 现有资源元数据 + * @param {Object} newMetadata - 新资源元数据 + * @returns {boolean} 是否应该覆盖 + * @private + */ + _shouldOverride(existingMetadata, newMetadata) { + // 1. 按发现源优先级比较 + const existingSourcePriority = this.sourcePriority[existingMetadata.source] || 999 + const newSourcePriority = this.sourcePriority[newMetadata.source] || 999 + + if (newSourcePriority < existingSourcePriority) { + return true // 新资源优先级更高 + } + + if (newSourcePriority > existingSourcePriority) { + return false // 现有资源优先级更高 + } + + // 2. 相同优先级,按数字优先级比较 + if (newMetadata.priority < existingMetadata.priority) { + return true // 数字越小优先级越高 + } + + if (newMetadata.priority > existingMetadata.priority) { + return false + } + + // 3. 相同优先级,按时间戳比较(新的覆盖旧的) + const existingTime = existingMetadata.timestamp ? new Date(existingMetadata.timestamp).getTime() : 0 + const newTime = newMetadata.timestamp ? new Date(newMetadata.timestamp).getTime() : 0 + + return newTime >= existingTime + } +} + +module.exports = EnhancedResourceRegistry \ No newline at end of file diff --git a/src/lib/core/resource/discovery/BaseDiscovery.js b/src/lib/core/resource/discovery/BaseDiscovery.js new file mode 100644 index 0000000..565b4fc --- /dev/null +++ b/src/lib/core/resource/discovery/BaseDiscovery.js @@ -0,0 +1,127 @@ +/** + * BaseDiscovery - 资源发现基础抽象类 + * + * 按照DPML协议架构文档设计,提供统一的资源发现接口 + * 所有具体的Discovery实现都应该继承这个基类 + */ +class BaseDiscovery { + /** + * 构造函数 + * @param {string} source - 发现源类型 (PACKAGE, PROJECT, USER, INTERNET) + * @param {number} priority - 优先级,数字越小优先级越高 + */ + constructor(source, priority = 0) { + if (!source) { + throw new Error('Discovery source is required') + } + + this.source = source + this.priority = priority + this.cache = new Map() + } + + /** + * 抽象方法:发现资源 + * 子类必须实现此方法 + * @returns {Promise} 发现的资源列表 + */ + async discover() { + throw new Error('discover method must be implemented by subclass') + } + + /** + * 获取发现器信息 + * @returns {Object} 发现器元数据 + */ + getDiscoveryInfo() { + return { + source: this.source, + priority: this.priority, + description: `${this.source} resource discovery` + } + } + + /** + * 验证资源结构 + * @param {Object} resource - 待验证的资源对象 + * @throws {Error} 如果资源结构无效 + */ + validateResource(resource) { + if (!resource || typeof resource !== 'object') { + throw new Error('Resource must be an object') + } + + if (!resource.id || !resource.reference) { + throw new Error('Resource must have id and reference') + } + + // 验证ID格式 (protocol:resourcePath) + if (typeof resource.id !== 'string' || !resource.id.includes(':')) { + throw new Error('Resource id must be in format "protocol:resourcePath"') + } + + // 验证引用格式 (@protocol://path) + if (typeof resource.reference !== 'string' || !resource.reference.startsWith('@')) { + throw new Error('Resource reference must be in DPML format "@protocol://path"') + } + } + + /** + * 规范化资源对象,添加元数据 + * @param {Object} resource - 原始资源对象 + * @returns {Object} 规范化后的资源对象 + */ + normalizeResource(resource) { + // 验证资源结构 + this.validateResource(resource) + + // 创建规范化的资源对象 + const normalizedResource = { + id: resource.id, + reference: resource.reference, + metadata: { + source: this.source, + priority: this.priority, + timestamp: new Date(), + ...resource.metadata // 保留现有元数据 + } + } + + return normalizedResource + } + + /** + * 清理缓存 + */ + clearCache() { + this.cache.clear() + } + + /** + * 获取缓存大小 + * @returns {number} 缓存条目数量 + */ + getCacheSize() { + return this.cache.size + } + + /** + * 从缓存获取资源 + * @param {string} key - 缓存键 + * @returns {*} 缓存的值或undefined + */ + getFromCache(key) { + return this.cache.get(key) + } + + /** + * 设置缓存 + * @param {string} key - 缓存键 + * @param {*} value - 缓存值 + */ + setCache(key, value) { + this.cache.set(key, value) + } +} + +module.exports = BaseDiscovery \ No newline at end of file diff --git a/src/lib/core/resource/discovery/CrossPlatformFileScanner.js b/src/lib/core/resource/discovery/CrossPlatformFileScanner.js new file mode 100644 index 0000000..8d10a8d --- /dev/null +++ b/src/lib/core/resource/discovery/CrossPlatformFileScanner.js @@ -0,0 +1,178 @@ +const fs = require('fs-extra') +const path = require('path') + +/** + * CrossPlatformFileScanner - 跨平台文件扫描器 + * + * 替代glob库,使用Node.js原生fs API实现跨平台文件扫描 + * 避免glob在Windows上的兼容性问题 + */ +class CrossPlatformFileScanner { + /** + * 递归扫描目录,查找匹配的文件 + * @param {string} baseDir - 基础目录 + * @param {Object} options - 扫描选项 + * @param {Array} options.extensions - 文件扩展名列表,如 ['.role.md', '.execution.md'] + * @param {Array} options.subdirs - 限制扫描的子目录,如 ['domain', 'execution'] + * @param {number} options.maxDepth - 最大扫描深度,默认10 + * @returns {Promise>} 匹配的文件路径列表 + */ + async scanFiles(baseDir, options = {}) { + const { + extensions = [], + subdirs = null, + maxDepth = 10 + } = options + + if (!await fs.pathExists(baseDir)) { + return [] + } + + const results = [] + await this._scanRecursive(baseDir, baseDir, extensions, subdirs, maxDepth, 0, results) + return results + } + + /** + * 扫描特定类型的资源文件 + * @param {string} baseDir - 基础目录 + * @param {string} resourceType - 资源类型 ('role', 'execution', 'thought') + * @returns {Promise>} 匹配的文件路径列表 + */ + async scanResourceFiles(baseDir, resourceType) { + const resourceConfig = { + role: { + extensions: ['.role.md'], + subdirs: ['domain'] // 角色文件通常在domain目录下 + }, + execution: { + extensions: ['.execution.md'], + subdirs: ['execution'] // 执行模式文件在execution目录下 + }, + thought: { + extensions: ['.thought.md'], + subdirs: ['thought'] // 思维模式文件在thought目录下 + } + } + + const config = resourceConfig[resourceType] + if (!config) { + throw new Error(`Unsupported resource type: ${resourceType}`) + } + + return await this.scanFiles(baseDir, config) + } + + /** + * 递归扫描目录的内部实现 + * @param {string} currentDir - 当前扫描目录 + * @param {string} baseDir - 基础目录 + * @param {Array} extensions - 文件扩展名列表 + * @param {Array|null} subdirs - 限制扫描的子目录 + * @param {number} maxDepth - 最大深度 + * @param {number} currentDepth - 当前深度 + * @param {Array} results - 结果数组 + * @private + */ + async _scanRecursive(currentDir, baseDir, extensions, subdirs, maxDepth, currentDepth, results) { + if (currentDepth >= maxDepth) { + return + } + + try { + const entries = await fs.readdir(currentDir, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = path.join(currentDir, entry.name) + + if (entry.isFile()) { + // 检查文件扩展名 + if (this._matchesExtensions(entry.name, extensions)) { + results.push(fullPath) + } + } else if (entry.isDirectory()) { + // 检查是否应该扫描这个子目录 + if (this._shouldScanDirectory(entry.name, subdirs, currentDepth)) { + await this._scanRecursive( + fullPath, + baseDir, + extensions, + subdirs, + maxDepth, + currentDepth + 1, + results + ) + } + } + } + } catch (error) { + // 忽略权限错误或其他文件系统错误 + console.warn(`[CrossPlatformFileScanner] Failed to scan directory ${currentDir}: ${error.message}`) + } + } + + /** + * 检查文件名是否匹配指定扩展名 + * @param {string} fileName - 文件名 + * @param {Array} extensions - 扩展名列表 + * @returns {boolean} 是否匹配 + * @private + */ + _matchesExtensions(fileName, extensions) { + if (!extensions || extensions.length === 0) { + return true // 如果没有指定扩展名,匹配所有文件 + } + + return extensions.some(ext => fileName.endsWith(ext)) + } + + /** + * 检查是否应该扫描指定目录 + * @param {string} dirName - 目录名 + * @param {Array|null} subdirs - 允许扫描的子目录列表 + * @param {number} currentDepth - 当前深度 + * @returns {boolean} 是否应该扫描 + * @private + */ + _shouldScanDirectory(dirName, subdirs, currentDepth) { + // 跳过隐藏目录和node_modules + if (dirName.startsWith('.') || dirName === 'node_modules') { + return false + } + + // 如果没有指定子目录限制,扫描所有目录 + if (!subdirs || subdirs.length === 0) { + return true + } + + // 在根级别,只扫描指定的子目录 + if (currentDepth === 0) { + return subdirs.includes(dirName) + } + + // 在更深层级,扫描所有目录 + return true + } + + /** + * 规范化路径,确保跨平台兼容性 + * @param {string} filePath - 文件路径 + * @returns {string} 规范化后的路径 + */ + normalizePath(filePath) { + return path.normalize(filePath).replace(/\\/g, '/') + } + + /** + * 生成相对路径,确保跨平台兼容性 + * @param {string} from - 起始路径 + * @param {string} to - 目标路径 + * @returns {string} 规范化的相对路径 + */ + getRelativePath(from, to) { + const relativePath = path.relative(from, to) + return relativePath.replace(/\\/g, '/') + } +} + +module.exports = CrossPlatformFileScanner \ No newline at end of file diff --git a/src/lib/core/resource/discovery/DiscoveryManager.js b/src/lib/core/resource/discovery/DiscoveryManager.js new file mode 100644 index 0000000..de615f5 --- /dev/null +++ b/src/lib/core/resource/discovery/DiscoveryManager.js @@ -0,0 +1,159 @@ +const PackageDiscovery = require('./PackageDiscovery') +const ProjectDiscovery = require('./ProjectDiscovery') + +/** + * DiscoveryManager - 资源发现管理器 + * + * 统一管理多个资源发现器,按照文档架构设计: + * 1. 按优先级排序发现器 (数字越小优先级越高) + * 2. 并行执行资源发现 + * 3. 收集并合并所有发现的资源 + * 4. 提供容错机制,单个发现器失败不影响整体 + */ +class DiscoveryManager { + /** + * 构造函数 + * @param {Array} discoveries - 自定义发现器列表,如果不提供则使用默认配置 + */ + constructor(discoveries = null) { + if (discoveries) { + this.discoveries = [...discoveries] + } else { + // 默认发现器配置:只包含包级和项目级发现 + this.discoveries = [ + new PackageDiscovery(), // 优先级: 1 + new ProjectDiscovery() // 优先级: 2 + ] + } + + // 按优先级排序 + this._sortDiscoveriesByPriority() + } + + /** + * 添加发现器 + * @param {Object} discovery - 实现了发现器接口的对象 + */ + addDiscovery(discovery) { + if (!discovery || typeof discovery.discover !== 'function') { + throw new Error('Discovery must implement discover method') + } + + this.discoveries.push(discovery) + this._sortDiscoveriesByPriority() + } + + /** + * 移除发现器 + * @param {string} source - 发现器源类型 + */ + removeDiscovery(source) { + this.discoveries = this.discoveries.filter(discovery => discovery.source !== source) + } + + /** + * 发现所有资源(并行模式) + * @returns {Promise} 所有发现的资源列表 + */ + async discoverAll() { + const discoveryPromises = this.discoveries.map(async (discovery) => { + try { + const resources = await discovery.discover() + return Array.isArray(resources) ? resources : [] + } catch (error) { + console.warn(`[DiscoveryManager] ${discovery.source} discovery failed: ${error.message}`) + return [] + } + }) + + // 并行执行所有发现器 + const discoveryResults = await Promise.allSettled(discoveryPromises) + + // 收集所有成功的结果 + const allResources = [] + discoveryResults.forEach((result, index) => { + if (result.status === 'fulfilled') { + allResources.push(...result.value) + } else { + console.warn(`[DiscoveryManager] ${this.discoveries[index].source} discovery rejected: ${result.reason}`) + } + }) + + return allResources + } + + /** + * 按源类型发现资源 + * @param {string} source - 发现器源类型 + * @returns {Promise} 指定源的资源列表 + */ + async discoverBySource(source) { + const discovery = this._findDiscoveryBySource(source) + if (!discovery) { + throw new Error(`Discovery source ${source} not found`) + } + + return await discovery.discover() + } + + /** + * 获取所有发现器信息 + * @returns {Array} 发现器信息列表 + */ + getDiscoveryInfo() { + return this.discoveries.map(discovery => { + if (typeof discovery.getDiscoveryInfo === 'function') { + return discovery.getDiscoveryInfo() + } else { + return { + source: discovery.source || 'UNKNOWN', + priority: discovery.priority || 0, + description: 'No description available' + } + } + }) + } + + /** + * 清理所有发现器缓存 + */ + clearCache() { + this.discoveries.forEach(discovery => { + if (typeof discovery.clearCache === 'function') { + discovery.clearCache() + } + }) + } + + /** + * 获取发现器数量 + * @returns {number} 注册的发现器数量 + */ + getDiscoveryCount() { + return this.discoveries.length + } + + /** + * 按优先级排序发现器 + * @private + */ + _sortDiscoveriesByPriority() { + this.discoveries.sort((a, b) => { + const priorityA = a.priority || 0 + const priorityB = b.priority || 0 + return priorityA - priorityB // 升序排序,数字越小优先级越高 + }) + } + + /** + * 根据源类型查找发现器 + * @param {string} source - 发现器源类型 + * @returns {Object|undefined} 找到的发现器或undefined + * @private + */ + _findDiscoveryBySource(source) { + return this.discoveries.find(discovery => discovery.source === source) + } +} + +module.exports = DiscoveryManager \ No newline at end of file diff --git a/src/lib/core/resource/discovery/PackageDiscovery.js b/src/lib/core/resource/discovery/PackageDiscovery.js new file mode 100644 index 0000000..b1c689a --- /dev/null +++ b/src/lib/core/resource/discovery/PackageDiscovery.js @@ -0,0 +1,228 @@ +const BaseDiscovery = require('./BaseDiscovery') +const fs = require('fs-extra') +const path = require('path') +const CrossPlatformFileScanner = require('./CrossPlatformFileScanner') + +/** + * PackageDiscovery - 包级资源发现器 + * + * 负责发现NPM包内的资源: + * 1. 从 src/resource.registry.json 加载静态注册表 + * 2. 扫描 prompt/ 目录发现动态资源 + * + * 优先级:1 (最高优先级) + */ +class PackageDiscovery extends BaseDiscovery { + constructor() { + super('PACKAGE', 1) + this.fileScanner = new CrossPlatformFileScanner() + } + + /** + * 发现包级资源 + * @returns {Promise} 发现的资源列表 + */ + async discover() { + const resources = [] + + try { + // 1. 加载静态注册表资源 + const registryResources = await this._loadStaticRegistryResources() + resources.push(...registryResources) + + // 2. 扫描prompt目录资源 + const scanResources = await this._scanPromptDirectory() + resources.push(...scanResources) + + // 3. 规范化所有资源 + return resources.map(resource => this.normalizeResource(resource)) + + } catch (error) { + console.warn(`[PackageDiscovery] Discovery failed: ${error.message}`) + return [] + } + } + + /** + * 从静态注册表加载资源 + * @returns {Promise} 注册表中的资源列表 + */ + async _loadStaticRegistryResources() { + try { + const registry = await this._loadStaticRegistry() + const resources = [] + + if (registry.protocols) { + // 遍历所有协议 + for (const [protocol, protocolInfo] of Object.entries(registry.protocols)) { + if (protocolInfo.registry) { + // 遍历协议下的所有资源 + for (const [resourceId, resourceInfo] of Object.entries(protocolInfo.registry)) { + const reference = typeof resourceInfo === 'string' + ? resourceInfo + : resourceInfo.file + + if (reference) { + resources.push({ + id: `${protocol}:${resourceId}`, + reference: reference + }) + } + } + } + } + } + + return resources + } catch (error) { + console.warn(`[PackageDiscovery] Failed to load static registry: ${error.message}`) + return [] + } + } + + /** + * 加载静态注册表文件 + * @returns {Promise} 注册表内容 + */ + async _loadStaticRegistry() { + const packageRoot = await this._findPackageRoot() + const registryPath = path.join(packageRoot, 'src', 'resource.registry.json') + + if (!await fs.pathExists(registryPath)) { + throw new Error('Static registry file not found') + } + + return await fs.readJSON(registryPath) + } + + /** + * 扫描prompt目录发现资源 + * @returns {Promise} 扫描发现的资源列表 + */ + async _scanPromptDirectory() { + try { + const packageRoot = await this._findPackageRoot() + const promptDir = path.join(packageRoot, 'prompt') + + if (!await fs.pathExists(promptDir)) { + return [] + } + + const resources = [] + + // 定义要扫描的资源类型 + const resourceTypes = ['role', 'execution', 'thought'] + + // 并行扫描所有资源类型 + for (const resourceType of resourceTypes) { + const files = await this.fileScanner.scanResourceFiles(promptDir, resourceType) + + for (const filePath of files) { + const suffix = `.${resourceType}.md` + const id = this._extractResourceId(filePath, resourceType, suffix) + const reference = this._generatePackageReference(filePath, packageRoot) + + resources.push({ + id: id, + reference: reference + }) + } + } + + return resources + } catch (error) { + console.warn(`[PackageDiscovery] Failed to scan prompt directory: ${error.message}`) + return [] + } + } + + /** + * 文件扫描(可以被测试mock) + * @param {string} baseDir - 基础目录 + * @param {string} resourceType - 资源类型 + * @returns {Promise} 匹配的文件路径列表 + */ + async _scanFiles(baseDir, resourceType) { + return await this.fileScanner.scanResourceFiles(baseDir, resourceType) + } + + /** + * 查找包根目录 + * @returns {Promise} 包根目录路径 + */ + async _findPackageRoot() { + const cacheKey = 'packageRoot' + const cached = this.getFromCache(cacheKey) + if (cached) { + return cached + } + + const packageRoot = await this._findPackageJsonWithPrompt() + if (!packageRoot) { + throw new Error('Package root with prompt directory not found') + } + + this.setCache(cacheKey, packageRoot) + return packageRoot + } + + /** + * 查找包含prompt目录的package.json + * @returns {Promise} 包根目录路径或null + */ + async _findPackageJsonWithPrompt() { + let currentDir = __dirname + + while (currentDir !== path.parse(currentDir).root) { + const packageJsonPath = path.join(currentDir, 'package.json') + const promptDirPath = path.join(currentDir, 'prompt') + + // 检查是否同时存在package.json和prompt目录 + const [hasPackageJson, hasPromptDir] = await Promise.all([ + fs.pathExists(packageJsonPath), + fs.pathExists(promptDirPath) + ]) + + if (hasPackageJson && hasPromptDir) { + // 验证是否是PromptX包 + try { + const packageJson = await fs.readJSON(packageJsonPath) + if (packageJson.name === 'promptx' || packageJson.name === 'dpml-prompt') { + return currentDir + } + } catch (error) { + // 忽略package.json读取错误 + } + } + + currentDir = path.dirname(currentDir) + } + + return null + } + + /** + * 生成包引用路径 + * @param {string} filePath - 文件绝对路径 + * @param {string} packageRoot - 包根目录 + * @returns {string} @package://相对路径 + */ + _generatePackageReference(filePath, packageRoot) { + const relativePath = this.fileScanner.getRelativePath(packageRoot, filePath) + return `@package://${relativePath}` + } + + /** + * 提取资源ID + * @param {string} filePath - 文件路径 + * @param {string} protocol - 协议类型 + * @param {string} suffix - 文件后缀 + * @returns {string} 资源ID (protocol:resourceName) + */ + _extractResourceId(filePath, protocol, suffix) { + const fileName = path.basename(filePath, suffix) + return `${protocol}:${fileName}` + } +} + +module.exports = PackageDiscovery \ No newline at end of file diff --git a/src/lib/core/resource/discovery/ProjectDiscovery.js b/src/lib/core/resource/discovery/ProjectDiscovery.js new file mode 100644 index 0000000..2c5b3ce --- /dev/null +++ b/src/lib/core/resource/discovery/ProjectDiscovery.js @@ -0,0 +1,223 @@ +const BaseDiscovery = require('./BaseDiscovery') +const fs = require('fs-extra') +const path = require('path') +const CrossPlatformFileScanner = require('./CrossPlatformFileScanner') + +/** + * ProjectDiscovery - 项目级资源发现器 + * + * 负责发现项目本地的资源: + * 1. 扫描 .promptx/resource/ 目录 + * 2. 发现用户自定义的角色、执行模式、思维模式等 + * + * 优先级:2 + */ +class ProjectDiscovery extends BaseDiscovery { + constructor() { + super('PROJECT', 2) + this.fileScanner = new CrossPlatformFileScanner() + } + + /** + * 发现项目级资源 + * @returns {Promise} 发现的资源列表 + */ + async discover() { + try { + // 1. 查找项目根目录 + const projectRoot = await this._findProjectRoot() + + // 2. 检查.promptx目录是否存在 + const hasPrompxDir = await this._checkPrompxDirectory(projectRoot) + if (!hasPrompxDir) { + return [] + } + + // 3. 扫描项目资源 + const resources = await this._scanProjectResources(projectRoot) + + // 4. 规范化所有资源 + return resources.map(resource => this.normalizeResource(resource)) + + } catch (error) { + console.warn(`[ProjectDiscovery] Discovery failed: ${error.message}`) + return [] + } + } + + /** + * 查找项目根目录 + * @returns {Promise} 项目根目录路径 + */ + async _findProjectRoot() { + const cacheKey = 'projectRoot' + const cached = this.getFromCache(cacheKey) + if (cached) { + return cached + } + + let currentDir = process.cwd() + + // 向上查找包含package.json的目录 + while (currentDir !== path.dirname(currentDir)) { + const packageJsonPath = path.join(currentDir, 'package.json') + + if (await this._fsExists(packageJsonPath)) { + this.setCache(cacheKey, currentDir) + return currentDir + } + + currentDir = path.dirname(currentDir) + } + + // 如果没找到package.json,返回当前工作目录 + const fallbackRoot = process.cwd() + this.setCache(cacheKey, fallbackRoot) + return fallbackRoot + } + + /** + * 检查.promptx目录是否存在 + * @param {string} projectRoot - 项目根目录 + * @returns {Promise} 是否存在.promptx/resource目录 + */ + async _checkPrompxDirectory(projectRoot) { + const promptxResourcePath = path.join(projectRoot, '.promptx', 'resource') + return await this._fsExists(promptxResourcePath) + } + + /** + * 扫描项目资源 + * @param {string} projectRoot - 项目根目录 + * @returns {Promise} 扫描发现的资源列表 + */ + async _scanProjectResources(projectRoot) { + try { + const resourcesDir = path.join(projectRoot, '.promptx', 'resource') + const resources = [] + + // 定义要扫描的资源类型 + const resourceTypes = ['role', 'execution', 'thought'] + + // 并行扫描所有资源类型 + for (const resourceType of resourceTypes) { + try { + const files = await this.fileScanner.scanResourceFiles(resourcesDir, resourceType) + + for (const filePath of files) { + // 验证文件内容 + const isValid = await this._validateResourceFile(filePath, resourceType) + if (!isValid) { + continue + } + + const suffix = `.${resourceType}.md` + const id = this._extractResourceId(filePath, resourceType, suffix) + const reference = this._generateProjectReference(filePath, projectRoot) + + resources.push({ + id: id, + reference: reference + }) + } + } catch (error) { + console.warn(`[ProjectDiscovery] Failed to scan ${resourceType} resources: ${error.message}`) + } + } + + return resources + } catch (error) { + console.warn(`[ProjectDiscovery] Failed to scan project resources: ${error.message}`) + return [] + } + } + + /** + * 文件扫描(可以被测试mock) + * @param {string} baseDir - 基础目录 + * @param {string} resourceType - 资源类型 + * @returns {Promise} 匹配的文件路径列表 + */ + async _scanFiles(baseDir, resourceType) { + return await this.fileScanner.scanResourceFiles(baseDir, resourceType) + } + + /** + * 文件系统存在性检查(可以被测试mock) + * @param {string} filePath - 文件路径 + * @returns {Promise} 文件是否存在 + */ + async _fsExists(filePath) { + return await fs.pathExists(filePath) + } + + /** + * 读取文件内容(可以被测试mock) + * @param {string} filePath - 文件路径 + * @returns {Promise} 文件内容 + */ + async _readFile(filePath) { + return await fs.readFile(filePath, 'utf8') + } + + /** + * 验证资源文件格式 + * @param {string} filePath - 文件路径 + * @param {string} protocol - 协议类型 + * @returns {Promise} 是否是有效的资源文件 + */ + async _validateResourceFile(filePath, protocol) { + try { + const content = await this._readFile(filePath) + + if (!content || typeof content !== 'string') { + return false + } + + const trimmedContent = content.trim() + if (trimmedContent.length === 0) { + return false + } + + // 根据协议类型验证DPML标签 + switch (protocol) { + case 'role': + return trimmedContent.includes('') && trimmedContent.includes('') + case 'execution': + return trimmedContent.includes('') && trimmedContent.includes('') + case 'thought': + return trimmedContent.includes('') && trimmedContent.includes('') + default: + return false + } + } catch (error) { + console.warn(`[ProjectDiscovery] Failed to validate ${filePath}: ${error.message}`) + return false + } + } + + /** + * 生成项目引用路径 + * @param {string} filePath - 文件绝对路径 + * @param {string} projectRoot - 项目根目录 + * @returns {string} @project://相对路径 + */ + _generateProjectReference(filePath, projectRoot) { + const relativePath = this.fileScanner.getRelativePath(projectRoot, filePath) + return `@project://${relativePath}` + } + + /** + * 提取资源ID + * @param {string} filePath - 文件路径 + * @param {string} protocol - 协议类型 + * @param {string} suffix - 文件后缀 + * @returns {string} 资源ID (protocol:resourceName) + */ + _extractResourceId(filePath, protocol, suffix) { + const fileName = path.basename(filePath, suffix) + return `${protocol}:${fileName}` + } +} + +module.exports = ProjectDiscovery \ No newline at end of file diff --git a/src/tests/core/resource/EnhancedResourceRegistry.unit.test.js b/src/tests/core/resource/EnhancedResourceRegistry.unit.test.js new file mode 100644 index 0000000..e8b7c83 --- /dev/null +++ b/src/tests/core/resource/EnhancedResourceRegistry.unit.test.js @@ -0,0 +1,420 @@ +const EnhancedResourceRegistry = require('../../../lib/core/resource/EnhancedResourceRegistry') + +describe('EnhancedResourceRegistry', () => { + let registry + + beforeEach(() => { + registry = new EnhancedResourceRegistry() + }) + + describe('constructor', () => { + test('should initialize with empty registry', () => { + expect(registry.size()).toBe(0) + expect(registry.list()).toEqual([]) + }) + }) + + describe('register', () => { + test('should register resource with metadata', () => { + const resource = { + id: 'role:test', + reference: '@package://test.md', + metadata: { + source: 'PACKAGE', + priority: 1, + timestamp: new Date() + } + } + + registry.register(resource) + + expect(registry.has('role:test')).toBe(true) + expect(registry.resolve('role:test')).toBe('@package://test.md') + }) + + test('should throw error for invalid resource', () => { + expect(() => { + registry.register({ id: 'test' }) // missing reference + }).toThrow('Resource must have id and reference') + }) + + test('should throw error for missing metadata', () => { + expect(() => { + registry.register({ + id: 'role:test', + reference: '@package://test.md' + // missing metadata + }) + }).toThrow('Resource must have metadata with source and priority') + }) + }) + + describe('registerBatch', () => { + test('should register multiple resources at once', () => { + const resources = [ + { + id: 'role:test1', + reference: '@package://test1.md', + metadata: { source: 'PACKAGE', priority: 1, timestamp: new Date() } + }, + { + id: 'role:test2', + reference: '@project://test2.md', + metadata: { source: 'PROJECT', priority: 2, timestamp: new Date() } + } + ] + + registry.registerBatch(resources) + + expect(registry.size()).toBe(2) + expect(registry.has('role:test1')).toBe(true) + expect(registry.has('role:test2')).toBe(true) + }) + + test('should handle batch registration failures gracefully', () => { + const resources = [ + { + id: 'role:valid', + reference: '@package://valid.md', + metadata: { source: 'PACKAGE', priority: 1, timestamp: new Date() } + }, + { + id: 'role:invalid' + // missing reference and metadata + } + ] + + // Should register valid resources and skip invalid ones + registry.registerBatch(resources) + + expect(registry.size()).toBe(1) + expect(registry.has('role:valid')).toBe(true) + expect(registry.has('role:invalid')).toBe(false) + }) + }) + + describe('merge', () => { + test('should merge with another registry using priority rules', () => { + // Setup first registry with package resources + const resource1 = { + id: 'role:test', + reference: '@package://test.md', + metadata: { source: 'PACKAGE', priority: 1, timestamp: new Date('2023-01-01') } + } + registry.register(resource1) + + // Create second registry with project resources (higher priority) + const otherRegistry = new EnhancedResourceRegistry() + const resource2 = { + id: 'role:test', // same ID, should override + reference: '@project://test.md', + metadata: { source: 'PROJECT', priority: 2, timestamp: new Date('2023-01-02') } + } + otherRegistry.register(resource2) + + // Merge - PROJECT should override PACKAGE due to higher priority + registry.merge(otherRegistry) + + expect(registry.resolve('role:test')).toBe('@project://test.md') + const metadata = registry.getMetadata('role:test') + expect(metadata.source).toBe('PROJECT') + }) + + test('should handle same priority by timestamp (newer wins)', () => { + const older = new Date('2023-01-01') + const newer = new Date('2023-01-02') + + // Setup first registry + const resource1 = { + id: 'role:test', + reference: '@package://old.md', + metadata: { source: 'PACKAGE', priority: 1, timestamp: older } + } + registry.register(resource1) + + // Create second registry with same priority but newer timestamp + const otherRegistry = new EnhancedResourceRegistry() + const resource2 = { + id: 'role:test', + reference: '@package://new.md', + metadata: { source: 'PACKAGE', priority: 1, timestamp: newer } + } + otherRegistry.register(resource2) + + registry.merge(otherRegistry) + + expect(registry.resolve('role:test')).toBe('@package://new.md') + }) + + test('should handle discovery source priority correctly', () => { + // Test priority order: USER > PROJECT > PACKAGE > INTERNET + const resources = [ + { + id: 'role:test', + reference: '@internet://remote.md', + metadata: { source: 'INTERNET', priority: 4, timestamp: new Date() } + }, + { + id: 'role:test', + reference: '@package://builtin.md', + metadata: { source: 'PACKAGE', priority: 1, timestamp: new Date() } + }, + { + id: 'role:test', + reference: '@project://project.md', + metadata: { source: 'PROJECT', priority: 2, timestamp: new Date() } + }, + { + id: 'role:test', + reference: '@user://custom.md', + metadata: { source: 'USER', priority: 3, timestamp: new Date() } + } + ] + + // Register in random order + registry.register(resources[0]) // INTERNET + registry.register(resources[1]) // PACKAGE (should override INTERNET) + registry.register(resources[2]) // PROJECT (should override PACKAGE) + registry.register(resources[3]) // USER (should override PROJECT) + + expect(registry.resolve('role:test')).toBe('@user://custom.md') + }) + }) + + describe('resolve', () => { + test('should resolve resource by exact ID', () => { + const resource = { + id: 'role:test', + reference: '@package://test.md', + metadata: { source: 'PACKAGE', priority: 1, timestamp: new Date() } + } + registry.register(resource) + + expect(registry.resolve('role:test')).toBe('@package://test.md') + }) + + test('should support backwards compatibility lookup', () => { + const resource = { + id: 'role:java-developer', + reference: '@package://java.md', + metadata: { source: 'PACKAGE', priority: 1, timestamp: new Date() } + } + registry.register(resource) + + // Should find by bare name if no exact match + expect(registry.resolve('java-developer')).toBe('@package://java.md') + }) + + test('should throw error if resource not found', () => { + expect(() => { + registry.resolve('non-existent') + }).toThrow('Resource \'non-existent\' not found') + }) + }) + + describe('list', () => { + test('should list all resources', () => { + const resources = [ + { + id: 'role:test1', + reference: '@package://test1.md', + metadata: { source: 'PACKAGE', priority: 1, timestamp: new Date() } + }, + { + id: 'execution:test2', + reference: '@project://test2.md', + metadata: { source: 'PROJECT', priority: 2, timestamp: new Date() } + } + ] + + registry.registerBatch(resources) + + const list = registry.list() + expect(list).toHaveLength(2) + expect(list).toContain('role:test1') + expect(list).toContain('execution:test2') + }) + + test('should filter by protocol', () => { + const resources = [ + { + id: 'role:test1', + reference: '@package://test1.md', + metadata: { source: 'PACKAGE', priority: 1, timestamp: new Date() } + }, + { + id: 'execution:test2', + reference: '@project://test2.md', + metadata: { source: 'PROJECT', priority: 2, timestamp: new Date() } + }, + { + id: 'role:test3', + reference: '@user://test3.md', + metadata: { source: 'USER', priority: 3, timestamp: new Date() } + } + ] + + registry.registerBatch(resources) + + const roleList = registry.list('role') + expect(roleList).toHaveLength(2) + expect(roleList).toContain('role:test1') + expect(roleList).toContain('role:test3') + + const executionList = registry.list('execution') + expect(executionList).toHaveLength(1) + expect(executionList).toContain('execution:test2') + }) + }) + + describe('getMetadata', () => { + test('should return resource metadata', () => { + const timestamp = new Date() + const resource = { + id: 'role:test', + reference: '@package://test.md', + metadata: { source: 'PACKAGE', priority: 1, timestamp: timestamp } + } + registry.register(resource) + + const metadata = registry.getMetadata('role:test') + expect(metadata).toEqual({ + source: 'PACKAGE', + priority: 1, + timestamp: timestamp + }) + }) + + test('should return null for non-existent resource', () => { + const metadata = registry.getMetadata('non-existent') + expect(metadata).toBeNull() + }) + }) + + describe('clear', () => { + test('should clear all resources', () => { + const resource = { + id: 'role:test', + reference: '@package://test.md', + metadata: { source: 'PACKAGE', priority: 1, timestamp: new Date() } + } + registry.register(resource) + + expect(registry.size()).toBe(1) + + registry.clear() + + expect(registry.size()).toBe(0) + expect(registry.list()).toEqual([]) + }) + }) + + describe('size', () => { + test('should return number of registered resources', () => { + expect(registry.size()).toBe(0) + + const resources = [ + { + id: 'role:test1', + reference: '@package://test1.md', + metadata: { source: 'PACKAGE', priority: 1, timestamp: new Date() } + }, + { + id: 'role:test2', + reference: '@project://test2.md', + metadata: { source: 'PROJECT', priority: 2, timestamp: new Date() } + } + ] + + registry.registerBatch(resources) + + expect(registry.size()).toBe(2) + }) + }) + + describe('has', () => { + test('should check if resource exists', () => { + const resource = { + id: 'role:test', + reference: '@package://test.md', + metadata: { source: 'PACKAGE', priority: 1, timestamp: new Date() } + } + registry.register(resource) + + expect(registry.has('role:test')).toBe(true) + expect(registry.has('non-existent')).toBe(false) + }) + }) + + describe('remove', () => { + test('should remove resource', () => { + const resource = { + id: 'role:test', + reference: '@package://test.md', + metadata: { source: 'PACKAGE', priority: 1, timestamp: new Date() } + } + registry.register(resource) + + expect(registry.has('role:test')).toBe(true) + + registry.remove('role:test') + + expect(registry.has('role:test')).toBe(false) + expect(registry.size()).toBe(0) + }) + + test('should do nothing if resource does not exist', () => { + registry.remove('non-existent') // Should not throw + expect(registry.size()).toBe(0) + }) + }) + + describe('loadFromDiscoveryResults', () => { + test('should load resources from discovery manager results', () => { + const discoveryResults = [ + { + id: 'role:test1', + reference: '@package://test1.md', + metadata: { source: 'PACKAGE', priority: 1, timestamp: new Date() } + }, + { + id: 'role:test2', + reference: '@project://test2.md', + metadata: { source: 'PROJECT', priority: 2, timestamp: new Date() } + } + ] + + registry.loadFromDiscoveryResults(discoveryResults) + + expect(registry.size()).toBe(2) + expect(registry.resolve('role:test1')).toBe('@package://test1.md') + expect(registry.resolve('role:test2')).toBe('@project://test2.md') + }) + + test('should handle empty discovery results', () => { + registry.loadFromDiscoveryResults([]) + expect(registry.size()).toBe(0) + }) + + test('should handle invalid discovery results gracefully', () => { + const discoveryResults = [ + { + id: 'role:valid', + reference: '@package://valid.md', + metadata: { source: 'PACKAGE', priority: 1, timestamp: new Date() } + }, + { + id: 'role:invalid' + // missing reference and metadata + }, + null, + undefined + ] + + registry.loadFromDiscoveryResults(discoveryResults) + + expect(registry.size()).toBe(1) + expect(registry.has('role:valid')).toBe(true) + }) + }) +}) \ No newline at end of file diff --git a/src/tests/core/resource/discovery/BaseDiscovery.unit.test.js b/src/tests/core/resource/discovery/BaseDiscovery.unit.test.js new file mode 100644 index 0000000..65c8f3a --- /dev/null +++ b/src/tests/core/resource/discovery/BaseDiscovery.unit.test.js @@ -0,0 +1,99 @@ +const BaseDiscovery = require('../../../../lib/core/resource/discovery/BaseDiscovery') + +describe('BaseDiscovery', () => { + let discovery + + beforeEach(() => { + discovery = new BaseDiscovery('test', 1) + }) + + describe('constructor', () => { + test('should initialize with source and priority', () => { + expect(discovery.source).toBe('test') + expect(discovery.priority).toBe(1) + }) + + test('should throw error if source is not provided', () => { + expect(() => new BaseDiscovery()).toThrow('Discovery source is required') + }) + + test('should use default priority if not provided', () => { + const defaultDiscovery = new BaseDiscovery('test') + expect(defaultDiscovery.priority).toBe(0) + }) + }) + + describe('discover method', () => { + test('should throw error for abstract method', async () => { + await expect(discovery.discover()).rejects.toThrow('discover method must be implemented by subclass') + }) + }) + + describe('getDiscoveryInfo', () => { + test('should return discovery metadata', () => { + const info = discovery.getDiscoveryInfo() + expect(info).toEqual({ + source: 'test', + priority: 1, + description: expect.any(String) + }) + }) + }) + + describe('validateResource', () => { + test('should validate resource structure', () => { + const validResource = { + id: 'role:test', + reference: '@package://test.md', + metadata: { + source: 'test', + priority: 1 + } + } + + expect(() => discovery.validateResource(validResource)).not.toThrow() + }) + + test('should throw error for invalid resource', () => { + const invalidResource = { id: 'test' } // missing reference + + expect(() => discovery.validateResource(invalidResource)).toThrow('Resource must have id and reference') + }) + }) + + describe('normalizeResource', () => { + test('should add metadata to resource', () => { + const resource = { + id: 'role:test', + reference: '@package://test.md' + } + + const normalized = discovery.normalizeResource(resource) + + expect(normalized).toEqual({ + id: 'role:test', + reference: '@package://test.md', + metadata: { + source: 'test', + priority: 1, + timestamp: expect.any(Date) + } + }) + }) + + test('should preserve existing metadata', () => { + const resource = { + id: 'role:test', + reference: '@package://test.md', + metadata: { + customField: 'value' + } + } + + const normalized = discovery.normalizeResource(resource) + + expect(normalized.metadata.customField).toBe('value') + expect(normalized.metadata.source).toBe('test') + }) + }) +}) \ No newline at end of file diff --git a/src/tests/core/resource/discovery/DiscoveryManager.unit.test.js b/src/tests/core/resource/discovery/DiscoveryManager.unit.test.js new file mode 100644 index 0000000..bb2d0df --- /dev/null +++ b/src/tests/core/resource/discovery/DiscoveryManager.unit.test.js @@ -0,0 +1,185 @@ +const DiscoveryManager = require('../../../../lib/core/resource/discovery/DiscoveryManager') +const PackageDiscovery = require('../../../../lib/core/resource/discovery/PackageDiscovery') +const ProjectDiscovery = require('../../../../lib/core/resource/discovery/ProjectDiscovery') + +describe('DiscoveryManager', () => { + let manager + + beforeEach(() => { + manager = new DiscoveryManager() + }) + + describe('constructor', () => { + test('should initialize with default discoveries', () => { + expect(manager.discoveries).toHaveLength(2) + expect(manager.discoveries[0]).toBeInstanceOf(PackageDiscovery) + expect(manager.discoveries[1]).toBeInstanceOf(ProjectDiscovery) + }) + + test('should allow custom discoveries in constructor', () => { + const customDiscovery = new PackageDiscovery() + const customManager = new DiscoveryManager([customDiscovery]) + + expect(customManager.discoveries).toHaveLength(1) + expect(customManager.discoveries[0]).toBe(customDiscovery) + }) + }) + + describe('addDiscovery', () => { + test('should add discovery and sort by priority', () => { + const customDiscovery = { source: 'CUSTOM', priority: 0, discover: jest.fn() } + + manager.addDiscovery(customDiscovery) + + expect(manager.discoveries).toHaveLength(3) + expect(manager.discoveries[0].source).toBe('CUSTOM') // priority 0 comes first + expect(manager.discoveries[1].source).toBe('PACKAGE') // priority 1 + expect(manager.discoveries[2].source).toBe('PROJECT') // priority 2 + }) + }) + + describe('removeDiscovery', () => { + test('should remove discovery by source', () => { + manager.removeDiscovery('PACKAGE') + + expect(manager.discoveries).toHaveLength(1) + expect(manager.discoveries[0].source).toBe('PROJECT') + }) + + test('should do nothing if discovery not found', () => { + manager.removeDiscovery('NON_EXISTENT') + + expect(manager.discoveries).toHaveLength(2) + }) + }) + + describe('discoverAll', () => { + test('should discover resources from all discoveries in parallel', async () => { + // Mock discovery methods + const packageResources = [ + { id: 'role:java-developer', reference: '@package://test1.md', metadata: { source: 'PACKAGE', priority: 1 } } + ] + const projectResources = [ + { id: 'role:custom-role', reference: '@project://test2.md', metadata: { source: 'PROJECT', priority: 2 } } + ] + + manager.discoveries[0].discover = jest.fn().mockResolvedValue(packageResources) + manager.discoveries[1].discover = jest.fn().mockResolvedValue(projectResources) + + const allResources = await manager.discoverAll() + + expect(allResources).toHaveLength(2) + expect(allResources[0].id).toBe('role:java-developer') + expect(allResources[1].id).toBe('role:custom-role') + expect(manager.discoveries[0].discover).toHaveBeenCalled() + expect(manager.discoveries[1].discover).toHaveBeenCalled() + }) + + test('should handle discovery failures gracefully', async () => { + const packageResources = [ + { id: 'role:java-developer', reference: '@package://test1.md', metadata: { source: 'PACKAGE', priority: 1 } } + ] + + manager.discoveries[0].discover = jest.fn().mockResolvedValue(packageResources) + manager.discoveries[1].discover = jest.fn().mockRejectedValue(new Error('Project discovery failed')) + + const allResources = await manager.discoverAll() + + expect(allResources).toHaveLength(1) + expect(allResources[0].id).toBe('role:java-developer') + }) + + test('should return empty array if all discoveries fail', async () => { + manager.discoveries[0].discover = jest.fn().mockRejectedValue(new Error('Package discovery failed')) + manager.discoveries[1].discover = jest.fn().mockRejectedValue(new Error('Project discovery failed')) + + const allResources = await manager.discoverAll() + + expect(allResources).toEqual([]) + }) + }) + + describe('discoverBySource', () => { + test('should discover resources from specific source', async () => { + const packageResources = [ + { id: 'role:java-developer', reference: '@package://test1.md', metadata: { source: 'PACKAGE', priority: 1 } } + ] + + manager.discoveries[0].discover = jest.fn().mockResolvedValue(packageResources) + + const resources = await manager.discoverBySource('PACKAGE') + + expect(resources).toEqual(packageResources) + expect(manager.discoveries[0].discover).toHaveBeenCalled() + }) + + test('should throw error if source not found', async () => { + await expect(manager.discoverBySource('NON_EXISTENT')).rejects.toThrow('Discovery source NON_EXISTENT not found') + }) + }) + + describe('getDiscoveryInfo', () => { + test('should return info for all discoveries', () => { + // Mock getDiscoveryInfo methods + manager.discoveries[0].getDiscoveryInfo = jest.fn().mockReturnValue({ + source: 'PACKAGE', + priority: 1, + description: 'Package discovery' + }) + manager.discoveries[1].getDiscoveryInfo = jest.fn().mockReturnValue({ + source: 'PROJECT', + priority: 2, + description: 'Project discovery' + }) + + const info = manager.getDiscoveryInfo() + + expect(info).toHaveLength(2) + expect(info[0].source).toBe('PACKAGE') + expect(info[1].source).toBe('PROJECT') + }) + }) + + describe('clearCache', () => { + test('should clear cache for all discoveries', () => { + // Mock clearCache methods + manager.discoveries[0].clearCache = jest.fn() + manager.discoveries[1].clearCache = jest.fn() + + manager.clearCache() + + expect(manager.discoveries[0].clearCache).toHaveBeenCalled() + expect(manager.discoveries[1].clearCache).toHaveBeenCalled() + }) + }) + + describe('_sortDiscoveriesByPriority', () => { + test('should sort discoveries by priority ascending', () => { + const discovery1 = { source: 'A', priority: 3 } + const discovery2 = { source: 'B', priority: 1 } + const discovery3 = { source: 'C', priority: 2 } + + manager.discoveries = [discovery1, discovery2, discovery3] + manager._sortDiscoveriesByPriority() + + expect(manager.discoveries[0].source).toBe('B') // priority 1 + expect(manager.discoveries[1].source).toBe('C') // priority 2 + expect(manager.discoveries[2].source).toBe('A') // priority 3 + }) + }) + + describe('_findDiscoveryBySource', () => { + test('should find discovery by source', () => { + const packageDiscovery = manager._findDiscoveryBySource('PACKAGE') + + expect(packageDiscovery).toBeDefined() + expect(packageDiscovery.source).toBe('PACKAGE') + }) + + test('should return undefined if source not found', () => { + const discovery = manager._findDiscoveryBySource('NON_EXISTENT') + + expect(discovery).toBeUndefined() + }) + }) +}) \ No newline at end of file diff --git a/src/tests/core/resource/discovery/PackageDiscovery.unit.test.js b/src/tests/core/resource/discovery/PackageDiscovery.unit.test.js new file mode 100644 index 0000000..6fed569 --- /dev/null +++ b/src/tests/core/resource/discovery/PackageDiscovery.unit.test.js @@ -0,0 +1,177 @@ +const PackageDiscovery = require('../../../../lib/core/resource/discovery/PackageDiscovery') +const path = require('path') + +describe('PackageDiscovery', () => { + let discovery + + beforeEach(() => { + discovery = new PackageDiscovery() + }) + + describe('constructor', () => { + test('should initialize with PACKAGE source and priority 1', () => { + expect(discovery.source).toBe('PACKAGE') + expect(discovery.priority).toBe(1) + }) + }) + + describe('discover', () => { + test('should discover package resources from static registry', async () => { + // Mock registry file content + jest.spyOn(discovery, '_loadStaticRegistry').mockResolvedValue({ + protocols: { + role: { + registry: { + 'java-backend-developer': '@package://prompt/domain/java-backend-developer/java-backend-developer.role.md', + 'product-manager': '@package://prompt/domain/product-manager/product-manager.role.md' + } + }, + thought: { + registry: { + 'remember': '@package://prompt/core/thought/remember.thought.md' + } + } + } + }) + + // Mock scan to return empty array to isolate static registry test + jest.spyOn(discovery, '_scanPromptDirectory').mockResolvedValue([]) + + const resources = await discovery.discover() + + expect(resources).toHaveLength(3) + expect(resources[0]).toMatchObject({ + id: 'role:java-backend-developer', + reference: '@package://prompt/domain/java-backend-developer/java-backend-developer.role.md', + metadata: { + source: 'PACKAGE', + priority: 1 + } + }) + }) + + test('should discover resources from prompt directory scan', async () => { + // Mock file system operations + jest.spyOn(discovery, '_scanPromptDirectory').mockResolvedValue([ + { + id: 'role:assistant', + reference: '@package://prompt/domain/assistant/assistant.role.md' + } + ]) + + jest.spyOn(discovery, '_loadStaticRegistry').mockResolvedValue({}) + + const resources = await discovery.discover() + + expect(resources).toHaveLength(1) + expect(resources[0].id).toBe('role:assistant') + }) + + test('should handle registry loading failures gracefully', async () => { + jest.spyOn(discovery, '_loadStaticRegistry').mockRejectedValue(new Error('Registry not found')) + jest.spyOn(discovery, '_scanPromptDirectory').mockResolvedValue([]) + + const resources = await discovery.discover() + + expect(resources).toEqual([]) + }) + }) + + describe('_loadStaticRegistry', () => { + test('should load registry from default path', async () => { + // This would be mocked in real tests + expect(typeof discovery._loadStaticRegistry).toBe('function') + }) + }) + + describe('_scanPromptDirectory', () => { + test('should scan for role, execution, thought files', async () => { + // Mock package root and prompt directory + jest.spyOn(discovery, '_findPackageRoot').mockResolvedValue('/mock/package/root') + + // Mock fs.pathExists to return true for prompt directory + const mockPathExists = jest.spyOn(require('fs-extra'), 'pathExists').mockResolvedValue(true) + + // Mock file scanner + const mockScanResourceFiles = jest.fn() + .mockResolvedValueOnce(['/mock/package/root/prompt/domain/test/test.role.md']) // roles + .mockResolvedValueOnce(['/mock/package/root/prompt/core/execution/test.execution.md']) // executions + .mockResolvedValueOnce(['/mock/package/root/prompt/core/thought/test.thought.md']) // thoughts + + discovery.fileScanner.scanResourceFiles = mockScanResourceFiles + + const resources = await discovery._scanPromptDirectory() + + expect(resources).toHaveLength(3) + expect(resources[0].id).toBe('role:test') + expect(resources[1].id).toBe('execution:test') + expect(resources[2].id).toBe('thought:test') + + // Cleanup + mockPathExists.mockRestore() + }) + }) + + describe('_findPackageRoot', () => { + test('should find package root containing prompt directory', async () => { + // Mock file system check + jest.spyOn(discovery, '_findPackageJsonWithPrompt').mockResolvedValue('/mock/package/root') + + const root = await discovery._findPackageRoot() + + expect(root).toBe('/mock/package/root') + }) + + test('should throw error if package root not found', async () => { + jest.spyOn(discovery, '_findPackageJsonWithPrompt').mockResolvedValue(null) + + await expect(discovery._findPackageRoot()).rejects.toThrow('Package root with prompt directory not found') + }) + }) + + describe('_generatePackageReference', () => { + test('should generate @package:// reference', () => { + const filePath = '/mock/package/root/prompt/domain/test/test.role.md' + const packageRoot = '/mock/package/root' + + // Mock the fileScanner.getRelativePath method + discovery.fileScanner.getRelativePath = jest.fn().mockReturnValue('prompt/domain/test/test.role.md') + + const reference = discovery._generatePackageReference(filePath, packageRoot) + + expect(reference).toBe('@package://prompt/domain/test/test.role.md') + }) + + test('should handle Windows paths correctly', () => { + const filePath = 'C:\\mock\\package\\root\\prompt\\domain\\test\\test.role.md' + const packageRoot = 'C:\\mock\\package\\root' + + // Mock the fileScanner.getRelativePath method + discovery.fileScanner.getRelativePath = jest.fn().mockReturnValue('prompt/domain/test/test.role.md') + + const reference = discovery._generatePackageReference(filePath, packageRoot) + + expect(reference).toBe('@package://prompt/domain/test/test.role.md') + }) + }) + + describe('_extractResourceId', () => { + test('should extract role id from path', () => { + const filePath = '/mock/package/root/prompt/domain/test/test.role.md' + const protocol = 'role' + + const id = discovery._extractResourceId(filePath, protocol, '.role.md') + + expect(id).toBe('role:test') + }) + + test('should extract execution id from path', () => { + const filePath = '/mock/package/root/prompt/core/execution/memory-trigger.execution.md' + const protocol = 'execution' + + const id = discovery._extractResourceId(filePath, protocol, '.execution.md') + + expect(id).toBe('execution:memory-trigger') + }) + }) +}) \ No newline at end of file diff --git a/src/tests/core/resource/discovery/ProjectDiscovery.unit.test.js b/src/tests/core/resource/discovery/ProjectDiscovery.unit.test.js new file mode 100644 index 0000000..a96412b --- /dev/null +++ b/src/tests/core/resource/discovery/ProjectDiscovery.unit.test.js @@ -0,0 +1,227 @@ +const ProjectDiscovery = require('../../../../lib/core/resource/discovery/ProjectDiscovery') +const path = require('path') + +describe('ProjectDiscovery', () => { + let discovery + + beforeEach(() => { + discovery = new ProjectDiscovery() + }) + + describe('constructor', () => { + test('should initialize with PROJECT source and priority 2', () => { + expect(discovery.source).toBe('PROJECT') + expect(discovery.priority).toBe(2) + }) + }) + + describe('discover', () => { + test('should discover project resources from .promptx directory', async () => { + // Mock project root and .promptx directory + jest.spyOn(discovery, '_findProjectRoot').mockResolvedValue('/mock/project/root') + jest.spyOn(discovery, '_checkPrompxDirectory').mockResolvedValue(true) + + jest.spyOn(discovery, '_scanProjectResources').mockResolvedValue([ + { + id: 'role:custom-role', + reference: '@project://.promptx/resource/domain/custom-role/custom-role.role.md' + }, + { + id: 'execution:custom-execution', + reference: '@project://.promptx/resource/execution/custom-execution.execution.md' + } + ]) + + const resources = await discovery.discover() + + expect(resources).toHaveLength(2) + expect(resources[0]).toMatchObject({ + id: 'role:custom-role', + reference: '@project://.promptx/resource/domain/custom-role/custom-role.role.md', + metadata: { + source: 'PROJECT', + priority: 2 + } + }) + }) + + test('should return empty array if .promptx directory does not exist', async () => { + jest.spyOn(discovery, '_findProjectRoot').mockResolvedValue('/mock/project/root') + jest.spyOn(discovery, '_checkPrompxDirectory').mockResolvedValue(false) + + const resources = await discovery.discover() + + expect(resources).toEqual([]) + }) + + test('should handle project root not found', async () => { + jest.spyOn(discovery, '_findProjectRoot').mockRejectedValue(new Error('Project root not found')) + + const resources = await discovery.discover() + + expect(resources).toEqual([]) + }) + }) + + describe('_findProjectRoot', () => { + test('should find project root containing package.json', async () => { + const mockFsExists = jest.fn() + .mockResolvedValueOnce(false) // /current/dir/package.json + .mockResolvedValueOnce(true) // /current/package.json + + discovery._fsExists = mockFsExists + + // Mock process.cwd() + const originalCwd = process.cwd + process.cwd = jest.fn().mockReturnValue('/current/dir') + + const root = await discovery._findProjectRoot() + + expect(root).toBe('/current') + + // Restore + process.cwd = originalCwd + }) + + test('should return current directory if no package.json found', async () => { + const mockFsExists = jest.fn().mockResolvedValue(false) + discovery._fsExists = mockFsExists + + const originalCwd = process.cwd + process.cwd = jest.fn().mockReturnValue('/current/dir') + + const root = await discovery._findProjectRoot() + + expect(root).toBe('/current/dir') + + process.cwd = originalCwd + }) + }) + + describe('_checkPrompxDirectory', () => { + test('should check if .promptx/resource directory exists', async () => { + const mockFsExists = jest.fn().mockResolvedValue(true) + discovery._fsExists = mockFsExists + + const exists = await discovery._checkPrompxDirectory('/mock/project/root') + + expect(exists).toBe(true) + expect(mockFsExists).toHaveBeenCalledWith('/mock/project/root/.promptx/resource') + }) + }) + + describe('_scanProjectResources', () => { + test('should scan for role, execution, thought files in .promptx', async () => { + const projectRoot = '/mock/project/root' + + // Mock file scanner + const mockScanResourceFiles = jest.fn() + .mockResolvedValueOnce([`${projectRoot}/.promptx/resource/domain/test/test.role.md`]) // roles + .mockResolvedValueOnce([`${projectRoot}/.promptx/resource/execution/test.execution.md`]) // executions + .mockResolvedValueOnce([`${projectRoot}/.promptx/resource/thought/test.thought.md`]) // thoughts + + discovery.fileScanner.scanResourceFiles = mockScanResourceFiles + + // Mock file validation + discovery._validateResourceFile = jest.fn().mockResolvedValue(true) + + const resources = await discovery._scanProjectResources(projectRoot) + + expect(resources).toHaveLength(3) + expect(resources[0].id).toBe('role:test') + expect(resources[1].id).toBe('execution:test') + expect(resources[2].id).toBe('thought:test') + }) + + test('should handle scan errors gracefully', async () => { + const projectRoot = '/mock/project/root' + + const mockScanResourceFiles = jest.fn().mockRejectedValue(new Error('Scan failed')) + discovery.fileScanner.scanResourceFiles = mockScanResourceFiles + + const resources = await discovery._scanProjectResources(projectRoot) + + expect(resources).toEqual([]) + }) + }) + + describe('_generateProjectReference', () => { + test('should generate @project:// reference', () => { + const filePath = '/mock/project/root/.promptx/resource/domain/test/test.role.md' + const projectRoot = '/mock/project/root' + + // Mock the fileScanner.getRelativePath method + discovery.fileScanner.getRelativePath = jest.fn().mockReturnValue('.promptx/resource/domain/test/test.role.md') + + const reference = discovery._generateProjectReference(filePath, projectRoot) + + expect(reference).toBe('@project://.promptx/resource/domain/test/test.role.md') + }) + + test('should handle Windows paths correctly', () => { + const filePath = 'C:\\mock\\project\\root\\.promptx\\resource\\domain\\test\\test.role.md' + const projectRoot = 'C:\\mock\\project\\root' + + // Mock the fileScanner.getRelativePath method + discovery.fileScanner.getRelativePath = jest.fn().mockReturnValue('.promptx/resource/domain/test/test.role.md') + + const reference = discovery._generateProjectReference(filePath, projectRoot) + + expect(reference).toBe('@project://.promptx/resource/domain/test/test.role.md') + }) + }) + + describe('_extractResourceId', () => { + test('should extract resource id with protocol prefix', () => { + const filePath = '/mock/project/root/.promptx/resource/domain/test/test.role.md' + const protocol = 'role' + + const id = discovery._extractResourceId(filePath, protocol, '.role.md') + + expect(id).toBe('role:test') + }) + }) + + describe('_validateResourceFile', () => { + test('should validate role file contains required DPML tags', async () => { + const filePath = '/mock/test.role.md' + const mockContent = ` +# Test Role + + +Test role content + + ` + + const mockReadFile = jest.fn().mockResolvedValue(mockContent) + discovery._readFile = mockReadFile + + const isValid = await discovery._validateResourceFile(filePath, 'role') + + expect(isValid).toBe(true) + }) + + test('should return false for invalid role file', async () => { + const filePath = '/mock/test.role.md' + const mockContent = 'Invalid content without DPML tags' + + const mockReadFile = jest.fn().mockResolvedValue(mockContent) + discovery._readFile = mockReadFile + + const isValid = await discovery._validateResourceFile(filePath, 'role') + + expect(isValid).toBe(false) + }) + + test('should handle file read errors', async () => { + const filePath = '/mock/test.role.md' + + const mockReadFile = jest.fn().mockRejectedValue(new Error('File not found')) + discovery._readFile = mockReadFile + + const isValid = await discovery._validateResourceFile(filePath, 'role') + + expect(isValid).toBe(false) + }) + }) +}) \ No newline at end of file