@fission-ai/openspec
Version:
AI-native system for spec-driven development
200 lines • 7.86 kB
JavaScript
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