UNPKG

@fission-ai/openspec

Version:

AI-native system for spec-driven development

200 lines 7.86 kB
import path from 'path'; import * as fs from 'fs'; import { AI_TOOLS } from './config.js'; import { ALL_WORKFLOWS } from './profiles.js'; import { CommandAdapterRegistry } from './command-generation/index.js'; import { COMMAND_IDS, getConfiguredTools } from './shared/index.js'; /** * Maps workflow IDs to their skill directory names. */ export const WORKFLOW_TO_SKILL_DIR = { 'explore': 'openspec-explore', 'new': 'openspec-new-change', 'continue': 'openspec-continue-change', 'apply': 'openspec-apply-change', 'ff': 'openspec-ff-change', 'sync': 'openspec-sync-specs', 'archive': 'openspec-archive-change', 'bulk-archive': 'openspec-bulk-archive-change', 'verify': 'openspec-verify-change', 'onboard': 'openspec-onboard', 'propose': 'openspec-propose', }; function toKnownWorkflows(workflows) { return workflows.filter((workflow) => ALL_WORKFLOWS.includes(workflow)); } /** * Checks whether a tool has at least one generated OpenSpec command file. */ export function toolHasAnyConfiguredCommand(projectPath, toolId) { const adapter = CommandAdapterRegistry.get(toolId); if (!adapter) return false; for (const commandId of COMMAND_IDS) { const cmdPath = adapter.getFilePath(commandId); const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); if (fs.existsSync(fullPath)) { return true; } } return false; } /** * Returns tools with at least one generated command file on disk. */ export function getCommandConfiguredTools(projectPath) { return AI_TOOLS .filter((tool) => { if (!tool.skillsDir) return false; const toolDir = path.join(projectPath, tool.skillsDir); try { return fs.statSync(toolDir).isDirectory(); } catch { return false; } }) .map((tool) => tool.value) .filter((toolId) => toolHasAnyConfiguredCommand(projectPath, toolId)); } /** * Returns tools that are configured via either skills or commands. */ export function getConfiguredToolsForProfileSync(projectPath) { const skillConfigured = getConfiguredTools(projectPath); const commandConfigured = getCommandConfiguredTools(projectPath); return [...new Set([...skillConfigured, ...commandConfigured])]; } /** * Detects if a single tool has profile/delivery drift against the desired state. * * This function covers: * - required artifacts missing for selected workflows * - artifacts that should not exist for the selected delivery mode * - artifacts for workflows that were deselected from the current profile */ export function hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkflows, delivery) { const tool = AI_TOOLS.find((t) => t.value === toolId); if (!tool?.skillsDir) return false; const knownDesiredWorkflows = toKnownWorkflows(desiredWorkflows); const desiredWorkflowSet = new Set(knownDesiredWorkflows); const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); const adapter = CommandAdapterRegistry.get(toolId); const shouldGenerateSkills = delivery !== 'commands'; const shouldGenerateCommands = delivery !== 'skills'; if (shouldGenerateSkills) { for (const workflow of knownDesiredWorkflows) { const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; const skillFile = path.join(skillsDir, dirName, 'SKILL.md'); if (!fs.existsSync(skillFile)) { return true; } } // Deselecting workflows in a profile should trigger sync. for (const workflow of ALL_WORKFLOWS) { if (desiredWorkflowSet.has(workflow)) continue; const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; const skillDir = path.join(skillsDir, dirName); if (fs.existsSync(skillDir)) { return true; } } } else { for (const workflow of ALL_WORKFLOWS) { const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; const skillDir = path.join(skillsDir, dirName); if (fs.existsSync(skillDir)) { return true; } } } if (shouldGenerateCommands && adapter) { for (const workflow of knownDesiredWorkflows) { const cmdPath = adapter.getFilePath(workflow); const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); if (!fs.existsSync(fullPath)) { return true; } } // Deselecting workflows in a profile should trigger sync. for (const workflow of ALL_WORKFLOWS) { if (desiredWorkflowSet.has(workflow)) continue; const cmdPath = adapter.getFilePath(workflow); const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); if (fs.existsSync(fullPath)) { return true; } } } else if (!shouldGenerateCommands && adapter) { for (const workflow of ALL_WORKFLOWS) { const cmdPath = adapter.getFilePath(workflow); const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); if (fs.existsSync(fullPath)) { return true; } } } return false; } /** * Returns configured tools that currently need a profile/delivery sync. */ export function getToolsNeedingProfileSync(projectPath, desiredWorkflows, delivery, configuredTools) { const tools = configuredTools ? [...new Set(configuredTools)] : getConfiguredToolsForProfileSync(projectPath); return tools.filter((toolId) => hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkflows, delivery)); } function getInstalledWorkflowsForTool(projectPath, toolId, options) { const tool = AI_TOOLS.find((t) => t.value === toolId); if (!tool?.skillsDir) return []; const installed = new Set(); const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); if (options.includeSkills) { for (const workflow of ALL_WORKFLOWS) { const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; const skillFile = path.join(skillsDir, dirName, 'SKILL.md'); if (fs.existsSync(skillFile)) { installed.add(workflow); } } } if (options.includeCommands) { const adapter = CommandAdapterRegistry.get(toolId); if (adapter) { for (const workflow of ALL_WORKFLOWS) { const cmdPath = adapter.getFilePath(workflow); const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); if (fs.existsSync(fullPath)) { installed.add(workflow); } } } } return [...installed]; } /** * Detects whether the current project has any profile/delivery drift. */ export function hasProjectConfigDrift(projectPath, desiredWorkflows, delivery) { const configuredTools = getConfiguredToolsForProfileSync(projectPath); if (getToolsNeedingProfileSync(projectPath, desiredWorkflows, delivery, configuredTools).length > 0) { return true; } const desiredSet = new Set(toKnownWorkflows(desiredWorkflows)); const includeSkills = delivery !== 'commands'; const includeCommands = delivery !== 'skills'; for (const toolId of configuredTools) { const installed = getInstalledWorkflowsForTool(projectPath, toolId, { includeSkills, includeCommands }); if (installed.some((workflow) => !desiredSet.has(workflow))) { return true; } } return false; } //# sourceMappingURL=profile-sync-drift.js.map