From 1e0238de96412bb21d1a370146d081860a878941 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sun, 15 Mar 2026 21:47:22 -0700 Subject: [PATCH] feat: wire manifest resolution into install execution (#509) --- scripts/install-apply.js | 15 +-- scripts/lib/install-executor.js | 96 ++++++++++++++---- scripts/lib/install-manifests.js | 151 ++++++++++++++++++++++++++-- scripts/lib/install/request.js | 21 ++-- scripts/lib/install/runtime.js | 12 +++ tests/lib/install-manifests.test.js | 77 +++++++++++++- tests/lib/install-request.test.js | 22 +++- tests/scripts/ecc.test.js | 10 +- tests/scripts/install-apply.test.js | 81 ++++++++++++--- 9 files changed, 417 insertions(+), 68 deletions(-) diff --git a/scripts/install-apply.js b/scripts/install-apply.js index d3041c20..c9104511 100644 --- a/scripts/install-apply.js +++ b/scripts/install-apply.js @@ -8,19 +8,16 @@ const { SUPPORTED_INSTALL_TARGETS, - listAvailableLanguages, -} = require('./lib/install-executor'); + listLegacyCompatibilityLanguages, +} = require('./lib/install-manifests'); const { LEGACY_INSTALL_TARGETS, normalizeInstallRequest, parseInstallArgs, } = require('./lib/install/request'); -const { loadInstallConfig } = require('./lib/install/config'); -const { applyInstallPlan } = require('./lib/install/apply'); -const { createInstallPlanFromRequest } = require('./lib/install/runtime'); function showHelp(exitCode = 0) { - const languages = listAvailableLanguages(); + const languages = listLegacyCompatibilityLanguages(); console.log(` Usage: install.sh [--target <${LEGACY_INSTALL_TARGETS.join('|')}>] [--dry-run] [--json] [ ...] @@ -61,6 +58,9 @@ function printHumanPlan(plan, dryRun) { if (plan.mode === 'legacy') { console.log(`Languages: ${plan.languages.join(', ')}`); } else { + if (plan.mode === 'legacy-compat') { + console.log(`Legacy languages: ${plan.legacyLanguages.join(', ')}`); + } console.log(`Profile: ${plan.profileId || '(custom modules)'}`); console.log(`Included components: ${plan.includedComponentIds.join(', ') || '(none)'}`); console.log(`Excluded components: ${plan.excludedComponentIds.join(', ') || '(none)'}`); @@ -100,6 +100,9 @@ function main() { showHelp(0); } + const { loadInstallConfig } = require('./lib/install/config'); + const { applyInstallPlan } = require('./lib/install-executor'); + const { createInstallPlanFromRequest } = require('./lib/install/runtime'); const config = options.configPath ? loadInstallConfig(options.configPath, { cwd: process.cwd() }) : null; diff --git a/scripts/lib/install-executor.js b/scripts/lib/install-executor.js index 3f823801..c2b31e9d 100644 --- a/scripts/lib/install-executor.js +++ b/scripts/lib/install-executor.js @@ -2,14 +2,14 @@ const fs = require('fs'); const path = require('path'); const { execFileSync } = require('child_process'); -const { applyInstallPlan } = require('./install/apply'); const { LEGACY_INSTALL_TARGETS, parseInstallArgs } = require('./install/request'); const { SUPPORTED_INSTALL_TARGETS, + listLegacyCompatibilityLanguages, + resolveLegacyCompatibilitySelection, resolveInstallPlan, } = require('./install-manifests'); const { getInstallTargetAdapter } = require('./install-targets/registry'); -const { createInstallState } = require('./install-state'); const LANGUAGE_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; const EXCLUDED_GENERATED_SOURCE_SUFFIXES = [ @@ -68,8 +68,11 @@ function readDirectoryNames(dirPath) { } function listAvailableLanguages(sourceRoot = getSourceRoot()) { - return readDirectoryNames(path.join(sourceRoot, 'rules')) - .filter(name => name !== 'common'); + return [...new Set([ + ...listLegacyCompatibilityLanguages(), + ...readDirectoryNames(path.join(sourceRoot, 'rules')) + .filter(name => name !== 'common'), + ])].sort(); } function validateLegacyTarget(target) { @@ -108,6 +111,16 @@ function isGeneratedRuntimeSourcePath(sourceRelativePath) { return EXCLUDED_GENERATED_SOURCE_SUFFIXES.some(suffix => normalizedPath.endsWith(suffix)); } +function createStatePreview(options) { + const { createInstallState } = require('./install-state'); + return createInstallState(options); +} + +function applyInstallPlan(plan) { + const { applyInstallPlan: applyPlan } = require('./install/apply'); + return applyPlan(plan); +} + function buildCopyFileOperation({ moduleId, sourcePath, sourceRelativePath, destinationPath, strategy }) { return { kind: 'copy-file', @@ -449,7 +462,7 @@ function createLegacyInstallPlan(options = {}) { manifestVersion: getManifestVersion(sourceRoot), }; - const statePreview = createInstallState({ + const statePreview = createStatePreview({ adapter: plan.adapter, targetRoot: plan.targetRoot, installStatePath: plan.installStatePath, @@ -485,6 +498,38 @@ function createLegacyInstallPlan(options = {}) { }; } +function createLegacyCompatInstallPlan(options = {}) { + const sourceRoot = options.sourceRoot || getSourceRoot(); + const projectRoot = options.projectRoot || process.cwd(); + const target = options.target || 'claude'; + + validateLegacyTarget(target); + + const selection = resolveLegacyCompatibilitySelection({ + repoRoot: sourceRoot, + target, + legacyLanguages: options.legacyLanguages || [], + }); + + return createManifestInstallPlan({ + sourceRoot, + projectRoot, + homeDir: options.homeDir, + target, + profileId: null, + moduleIds: selection.moduleIds, + includeComponentIds: [], + excludeComponentIds: [], + legacyLanguages: selection.legacyLanguages, + legacyMode: true, + requestProfileId: null, + requestModuleIds: [], + requestIncludeComponentIds: [], + requestExcludeComponentIds: [], + mode: 'legacy-compat', + }); +} + function materializeScaffoldOperation(sourceRoot, operation) { const sourcePath = path.join(sourceRoot, operation.sourceRelativePath); if (!fs.existsSync(sourcePath)) { @@ -526,6 +571,21 @@ function createManifestInstallPlan(options = {}) { const sourceRoot = options.sourceRoot || getSourceRoot(); const projectRoot = options.projectRoot || process.cwd(); const target = options.target || 'claude'; + const legacyLanguages = Array.isArray(options.legacyLanguages) + ? [...options.legacyLanguages] + : []; + const requestProfileId = Object.hasOwn(options, 'requestProfileId') + ? options.requestProfileId + : (options.profileId || null); + const requestModuleIds = Object.hasOwn(options, 'requestModuleIds') + ? [...options.requestModuleIds] + : (Array.isArray(options.moduleIds) ? [...options.moduleIds] : []); + const requestIncludeComponentIds = Object.hasOwn(options, 'requestIncludeComponentIds') + ? [...options.requestIncludeComponentIds] + : (Array.isArray(options.includeComponentIds) ? [...options.includeComponentIds] : []); + const requestExcludeComponentIds = Object.hasOwn(options, 'requestExcludeComponentIds') + ? [...options.requestExcludeComponentIds] + : (Array.isArray(options.excludeComponentIds) ? [...options.excludeComponentIds] : []); const plan = resolveInstallPlan({ repoRoot: sourceRoot, projectRoot, @@ -543,21 +603,17 @@ function createManifestInstallPlan(options = {}) { repoCommit: getRepoCommit(sourceRoot), manifestVersion: getManifestVersion(sourceRoot), }; - const statePreview = createInstallState({ + const statePreview = createStatePreview({ adapter, targetRoot: plan.targetRoot, installStatePath: plan.installStatePath, request: { - profile: plan.profileId, - modules: Array.isArray(options.moduleIds) ? [...options.moduleIds] : [], - includeComponents: Array.isArray(options.includeComponentIds) - ? [...options.includeComponentIds] - : [], - excludeComponents: Array.isArray(options.excludeComponentIds) - ? [...options.excludeComponentIds] - : [], - legacyLanguages: [], - legacyMode: false, + profile: requestProfileId, + modules: requestModuleIds, + includeComponents: requestIncludeComponentIds, + excludeComponents: requestExcludeComponentIds, + legacyLanguages, + legacyMode: Boolean(options.legacyMode), }, resolution: { selectedModules: plan.selectedModuleIds, @@ -568,7 +624,7 @@ function createManifestInstallPlan(options = {}) { }); return { - mode: 'manifest', + mode: options.mode || 'manifest', target, adapter: { id: adapter.id, @@ -578,8 +634,9 @@ function createManifestInstallPlan(options = {}) { targetRoot: plan.targetRoot, installRoot: plan.targetRoot, installStatePath: plan.installStatePath, - warnings: [], - languages: [], + warnings: Array.isArray(options.warnings) ? [...options.warnings] : [], + languages: legacyLanguages, + legacyLanguages, profileId: plan.profileId, requestedModuleIds: plan.requestedModuleIds, explicitModuleIds: plan.explicitModuleIds, @@ -597,6 +654,7 @@ module.exports = { SUPPORTED_INSTALL_TARGETS, LEGACY_INSTALL_TARGETS, applyInstallPlan, + createLegacyCompatInstallPlan, createManifestInstallPlan, createLegacyInstallPlan, getSourceRoot, diff --git a/scripts/lib/install-manifests.js b/scripts/lib/install-manifests.js index 63274a37..99c4102e 100644 --- a/scripts/lib/install-manifests.js +++ b/scripts/lib/install-manifests.js @@ -11,6 +11,50 @@ const COMPONENT_FAMILY_PREFIXES = { framework: 'framework:', capability: 'capability:', }; +const LEGACY_COMPAT_BASE_MODULE_IDS_BY_TARGET = Object.freeze({ + claude: [ + 'rules-core', + 'agents-core', + 'commands-core', + 'hooks-runtime', + 'platform-configs', + 'workflow-quality', + ], + cursor: [ + 'rules-core', + 'agents-core', + 'commands-core', + 'hooks-runtime', + 'platform-configs', + 'workflow-quality', + ], + antigravity: [ + 'rules-core', + 'agents-core', + 'commands-core', + ], +}); +const LEGACY_LANGUAGE_ALIAS_TO_CANONICAL = Object.freeze({ + go: 'go', + golang: 'go', + java: 'java', + javascript: 'typescript', + kotlin: 'java', + perl: 'perl', + php: 'php', + python: 'python', + swift: 'swift', + typescript: 'typescript', +}); +const LEGACY_LANGUAGE_EXTRA_MODULE_IDS = Object.freeze({ + go: ['framework-language'], + java: ['framework-language'], + perl: [], + php: [], + python: ['framework-language'], + swift: [], + typescript: ['framework-language'], +}); function readJson(filePath, label) { try { @@ -24,6 +68,19 @@ function dedupeStrings(values) { return [...new Set((Array.isArray(values) ? values : []).map(value => String(value).trim()).filter(Boolean))]; } +function assertKnownModuleIds(moduleIds, manifests) { + const unknownModuleIds = dedupeStrings(moduleIds) + .filter(moduleId => !manifests.modulesById.has(moduleId)); + + if (unknownModuleIds.length === 1) { + throw new Error(`Unknown install module: ${unknownModuleIds[0]}`); + } + + if (unknownModuleIds.length > 1) { + throw new Error(`Unknown install modules: ${unknownModuleIds.join(', ')}`); + } +} + function intersectTargets(modules) { if (!Array.isArray(modules) || modules.length === 0) { return []; @@ -102,6 +159,17 @@ function listInstallModules(options = {}) { })); } +function listLegacyCompatibilityLanguages() { + return Object.keys(LEGACY_LANGUAGE_ALIAS_TO_CANONICAL).sort(); +} + +function validateInstallModuleIds(moduleIds, options = {}) { + const manifests = loadInstallManifests(options); + const normalizedModuleIds = dedupeStrings(moduleIds); + assertKnownModuleIds(normalizedModuleIds, manifests); + return normalizedModuleIds; +} + function listInstallComponents(options = {}) { const manifests = loadInstallManifests(options); const family = options.family || null; @@ -154,6 +222,59 @@ function expandComponentIdsToModuleIds(componentIds, manifests) { return dedupeStrings(expandedModuleIds); } +function resolveLegacyCompatibilitySelection(options = {}) { + const manifests = loadInstallManifests(options); + const target = options.target || null; + + if (target && !SUPPORTED_INSTALL_TARGETS.includes(target)) { + throw new Error( + `Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}` + ); + } + + const legacyLanguages = dedupeStrings(options.legacyLanguages) + .map(language => language.toLowerCase()); + const normalizedLegacyLanguages = dedupeStrings(legacyLanguages); + + if (normalizedLegacyLanguages.length === 0) { + throw new Error('No legacy languages were provided'); + } + + const unknownLegacyLanguages = normalizedLegacyLanguages + .filter(language => !Object.hasOwn(LEGACY_LANGUAGE_ALIAS_TO_CANONICAL, language)); + + if (unknownLegacyLanguages.length === 1) { + throw new Error( + `Unknown legacy language: ${unknownLegacyLanguages[0]}. Expected one of ${listLegacyCompatibilityLanguages().join(', ')}` + ); + } + + if (unknownLegacyLanguages.length > 1) { + throw new Error( + `Unknown legacy languages: ${unknownLegacyLanguages.join(', ')}. Expected one of ${listLegacyCompatibilityLanguages().join(', ')}` + ); + } + + const canonicalLegacyLanguages = normalizedLegacyLanguages + .map(language => LEGACY_LANGUAGE_ALIAS_TO_CANONICAL[language]); + const baseModuleIds = LEGACY_COMPAT_BASE_MODULE_IDS_BY_TARGET[target || 'claude'] + || LEGACY_COMPAT_BASE_MODULE_IDS_BY_TARGET.claude; + const moduleIds = dedupeStrings([ + ...baseModuleIds, + ...(target === 'antigravity' + ? [] + : canonicalLegacyLanguages.flatMap(language => LEGACY_LANGUAGE_EXTRA_MODULE_IDS[language] || [])), + ]); + + assertKnownModuleIds(moduleIds, manifests); + + return { + legacyLanguages: normalizedLegacyLanguages, + canonicalLegacyLanguages, + moduleIds, + }; +} + function resolveInstallPlan(options = {}) { const manifests = loadInstallManifests(options); const profileId = options.profileId || null; @@ -212,7 +333,7 @@ function resolveInstallPlan(options = {}) { const visitingIds = new Set(); const resolvedIds = new Set(); - function resolveModule(moduleId, dependencyOf) { + function resolveModule(moduleId, dependencyOf, rootRequesterId) { const module = manifests.modulesById.get(moduleId); if (!module) { throw new Error(`Unknown install module: ${moduleId}`); @@ -230,16 +351,15 @@ function resolveInstallPlan(options = {}) { if (target && !module.targets.includes(target)) { if (dependencyOf) { - throw new Error( - `Module ${dependencyOf} depends on ${moduleId}, which does not support target ${target}` - ); + skippedTargetIds.add(rootRequesterId || dependencyOf); + return false; } skippedTargetIds.add(moduleId); - return; + return false; } if (resolvedIds.has(moduleId)) { - return; + return true; } if (visitingIds.has(moduleId)) { @@ -248,15 +368,27 @@ function resolveInstallPlan(options = {}) { visitingIds.add(moduleId); for (const dependencyId of module.dependencies) { - resolveModule(dependencyId, moduleId); + const dependencyResolved = resolveModule( + dependencyId, + moduleId, + rootRequesterId || moduleId + ); + if (!dependencyResolved) { + visitingIds.delete(moduleId); + if (!dependencyOf) { + skippedTargetIds.add(moduleId); + } + return false; + } } visitingIds.delete(moduleId); resolvedIds.add(moduleId); selectedIds.add(moduleId); + return true; } for (const moduleId of effectiveRequestedIds) { - resolveModule(moduleId, null); + resolveModule(moduleId, null, moduleId); } const selectedModules = manifests.modules.filter(module => selectedIds.has(module.id)); @@ -299,7 +431,10 @@ module.exports = { getManifestPaths, loadInstallManifests, listInstallComponents, + listLegacyCompatibilityLanguages, listInstallModules, listInstallProfiles, resolveInstallPlan, + resolveLegacyCompatibilitySelection, + validateInstallModuleIds, }; diff --git a/scripts/lib/install/request.js b/scripts/lib/install/request.js index 29dffe0d..592e6e01 100644 --- a/scripts/lib/install/request.js +++ b/scripts/lib/install/request.js @@ -1,5 +1,7 @@ 'use strict'; +const { validateInstallModuleIds } = require('../install-manifests'); + const LEGACY_INSTALL_TARGETS = ['claude', 'cursor', 'antigravity']; function dedupeStrings(values) { @@ -35,7 +37,7 @@ function parseInstallArgs(argv) { index += 1; } else if (arg === '--modules') { const raw = args[index + 1] || ''; - parsed.moduleIds = raw.split(',').map(value => value.trim()).filter(Boolean); + parsed.moduleIds = dedupeStrings(raw.split(',')); index += 1; } else if (arg === '--with') { const componentId = args[index + 1] || ''; @@ -70,7 +72,9 @@ function normalizeInstallRequest(options = {}) { ? options.config : null; const profileId = options.profileId || config?.profileId || null; - const moduleIds = dedupeStrings([...(config?.moduleIds || []), ...(options.moduleIds || [])]); + const moduleIds = validateInstallModuleIds( + dedupeStrings([...(config?.moduleIds || []), ...(options.moduleIds || [])]) + ); const includeComponentIds = dedupeStrings([ ...(config?.includeComponentIds || []), ...(options.includeComponentIds || []), @@ -79,29 +83,32 @@ function normalizeInstallRequest(options = {}) { ...(config?.excludeComponentIds || []), ...(options.excludeComponentIds || []), ]); - const languages = Array.isArray(options.languages) ? [...options.languages] : []; + const legacyLanguages = dedupeStrings(dedupeStrings([ + ...(Array.isArray(options.legacyLanguages) ? options.legacyLanguages : []), + ...(Array.isArray(options.languages) ? options.languages : []), + ]).map(language => language.toLowerCase())); const target = options.target || config?.target || 'claude'; const hasManifestBaseSelection = Boolean(profileId) || moduleIds.length > 0 || includeComponentIds.length > 0; const usingManifestMode = hasManifestBaseSelection || excludeComponentIds.length > 0; - if (usingManifestMode && languages.length > 0) { + if (usingManifestMode && legacyLanguages.length > 0) { throw new Error( 'Legacy language arguments cannot be combined with --profile, --modules, --with, --without, or manifest config selections' ); } - if (!options.help && !hasManifestBaseSelection && languages.length === 0) { + if (!options.help && !hasManifestBaseSelection && legacyLanguages.length === 0) { throw new Error('No install profile, module IDs, included components, or legacy languages were provided'); } return { - mode: usingManifestMode ? 'manifest' : 'legacy', + mode: usingManifestMode ? 'manifest' : 'legacy-compat', target, profileId, moduleIds, includeComponentIds, excludeComponentIds, - languages, + legacyLanguages, configPath: config?.path || options.configPath || null, }; } diff --git a/scripts/lib/install/runtime.js b/scripts/lib/install/runtime.js index 4770b2f5..91a340c8 100644 --- a/scripts/lib/install/runtime.js +++ b/scripts/lib/install/runtime.js @@ -1,6 +1,7 @@ 'use strict'; const { + createLegacyCompatInstallPlan, createLegacyInstallPlan, createManifestInstallPlan, } = require('../install-executor'); @@ -23,6 +24,17 @@ function createInstallPlanFromRequest(request, options = {}) { }); } + if (request.mode === 'legacy-compat') { + return createLegacyCompatInstallPlan({ + target: request.target, + legacyLanguages: request.legacyLanguages, + projectRoot: options.projectRoot, + homeDir: options.homeDir, + claudeRulesDir: options.claudeRulesDir, + sourceRoot: options.sourceRoot, + }); + } + if (request.mode === 'legacy') { return createLegacyInstallPlan({ target: request.target, diff --git a/tests/lib/install-manifests.test.js b/tests/lib/install-manifests.test.js index cfdcdcf7..596cadde 100644 --- a/tests/lib/install-manifests.test.js +++ b/tests/lib/install-manifests.test.js @@ -10,9 +10,12 @@ const path = require('path'); const { loadInstallManifests, listInstallComponents, + listLegacyCompatibilityLanguages, listInstallModules, listInstallProfiles, resolveInstallPlan, + resolveLegacyCompatibilitySelection, + validateInstallModuleIds, } = require('../../scripts/lib/install-manifests'); function test(name, fn) { @@ -75,6 +78,15 @@ function runTests() { 'Should include capability:security'); })) passed++; else failed++; + if (test('lists supported legacy compatibility languages', () => { + const languages = listLegacyCompatibilityLanguages(); + assert.ok(languages.includes('typescript')); + assert.ok(languages.includes('python')); + assert.ok(languages.includes('go')); + assert.ok(languages.includes('golang')); + assert.ok(languages.includes('kotlin')); + })) passed++; else failed++; + if (test('resolves a real project profile with target-specific skips', () => { const projectRoot = '/workspace/app'; const plan = resolveInstallPlan({ profileId: 'developer', target: 'cursor', projectRoot }); @@ -97,6 +109,18 @@ function runTests() { ); })) passed++; else failed++; + if (test('resolves antigravity profiles by skipping incompatible dependency trees', () => { + const projectRoot = '/workspace/app'; + const plan = resolveInstallPlan({ profileId: 'core', target: 'antigravity', projectRoot }); + + assert.deepStrictEqual(plan.selectedModuleIds, ['rules-core', 'agents-core', 'commands-core']); + assert.ok(plan.skippedModuleIds.includes('hooks-runtime')); + assert.ok(plan.skippedModuleIds.includes('platform-configs')); + assert.ok(plan.skippedModuleIds.includes('workflow-quality')); + assert.strictEqual(plan.targetAdapterId, 'antigravity-project'); + assert.strictEqual(plan.targetRoot, path.join(projectRoot, '.agent')); + })) passed++; else failed++; + if (test('resolves explicit modules with dependency expansion', () => { const plan = resolveInstallPlan({ moduleIds: ['security'] }); assert.ok(plan.selectedModuleIds.includes('security'), 'Should include requested module'); @@ -106,6 +130,50 @@ function runTests() { 'Should include nested dependency'); })) passed++; else failed++; + if (test('validates explicit module IDs against the real manifest catalog', () => { + const moduleIds = validateInstallModuleIds(['security', 'security', 'platform-configs']); + assert.deepStrictEqual(moduleIds, ['security', 'platform-configs']); + assert.throws( + () => validateInstallModuleIds(['ghost-module']), + /Unknown install module: ghost-module/ + ); + })) passed++; else failed++; + + if (test('resolves legacy compatibility selections into manifest module IDs', () => { + const selection = resolveLegacyCompatibilitySelection({ + target: 'cursor', + legacyLanguages: ['typescript', 'go', 'golang'], + }); + + assert.deepStrictEqual(selection.legacyLanguages, ['typescript', 'go', 'golang']); + assert.ok(selection.moduleIds.includes('rules-core')); + assert.ok(selection.moduleIds.includes('agents-core')); + assert.ok(selection.moduleIds.includes('commands-core')); + assert.ok(selection.moduleIds.includes('hooks-runtime')); + assert.ok(selection.moduleIds.includes('platform-configs')); + assert.ok(selection.moduleIds.includes('workflow-quality')); + assert.ok(selection.moduleIds.includes('framework-language')); + })) passed++; else failed++; + + if (test('keeps antigravity legacy compatibility selections target-safe', () => { + const selection = resolveLegacyCompatibilitySelection({ + target: 'antigravity', + legacyLanguages: ['typescript'], + }); + + assert.deepStrictEqual(selection.moduleIds, ['rules-core', 'agents-core', 'commands-core']); + })) passed++; else failed++; + + if (test('rejects unknown legacy compatibility languages', () => { + assert.throws( + () => resolveLegacyCompatibilitySelection({ + target: 'cursor', + legacyLanguages: ['brainfuck'], + }), + /Unknown legacy language: brainfuck/ + ); + })) passed++; else failed++; + if (test('resolves included and excluded user-facing components', () => { const plan = resolveInstallPlan({ profileId: 'core', @@ -146,7 +214,7 @@ function runTests() { ); })) passed++; else failed++; - if (test('throws when a dependency does not support the requested target', () => { + if (test('skips a requested module when its dependency chain does not support the target', () => { const repoRoot = createTestRepo(); writeJson(path.join(repoRoot, 'manifests', 'install-modules.json'), { version: 1, @@ -182,10 +250,9 @@ function runTests() { } }); - assert.throws( - () => resolveInstallPlan({ repoRoot, profileId: 'core', target: 'claude' }), - /does not support target claude/ - ); + const plan = resolveInstallPlan({ repoRoot, profileId: 'core', target: 'claude' }); + assert.deepStrictEqual(plan.selectedModuleIds, []); + assert.deepStrictEqual(plan.skippedModuleIds, ['parent']); cleanupTestRepo(repoRoot); })) passed++; else failed++; diff --git a/tests/lib/install-request.test.js b/tests/lib/install-request.test.js index 95e8e333..18e0ec37 100644 --- a/tests/lib/install-request.test.js +++ b/tests/lib/install-request.test.js @@ -33,6 +33,7 @@ function runTests() { 'scripts/install-apply.js', '--target', 'cursor', '--profile', 'developer', + '--modules', 'platform-configs, workflow-quality ,platform-configs', '--with', 'lang:typescript', '--without', 'capability:media', '--config', 'ecc-install.json', @@ -43,6 +44,7 @@ function runTests() { assert.strictEqual(parsed.target, 'cursor'); assert.strictEqual(parsed.profileId, 'developer'); assert.strictEqual(parsed.configPath, 'ecc-install.json'); + assert.deepStrictEqual(parsed.moduleIds, ['platform-configs', 'workflow-quality']); assert.deepStrictEqual(parsed.includeComponentIds, ['lang:typescript']); assert.deepStrictEqual(parsed.excludeComponentIds, ['capability:media']); assert.strictEqual(parsed.dryRun, true); @@ -58,9 +60,9 @@ function runTests() { languages: ['typescript', 'python'] }); - assert.strictEqual(request.mode, 'legacy'); + assert.strictEqual(request.mode, 'legacy-compat'); assert.strictEqual(request.target, 'claude'); - assert.deepStrictEqual(request.languages, ['typescript', 'python']); + assert.deepStrictEqual(request.legacyLanguages, ['typescript', 'python']); assert.deepStrictEqual(request.moduleIds, []); assert.strictEqual(request.profileId, null); })) passed++; else failed++; @@ -80,7 +82,7 @@ function runTests() { assert.strictEqual(request.profileId, 'developer'); assert.deepStrictEqual(request.includeComponentIds, ['lang:typescript']); assert.deepStrictEqual(request.excludeComponentIds, ['capability:media']); - assert.deepStrictEqual(request.languages, []); + assert.deepStrictEqual(request.legacyLanguages, []); })) passed++; else failed++; if (test('merges config-backed component selections with CLI overrides', () => { @@ -111,6 +113,20 @@ function runTests() { assert.strictEqual(request.configPath, '/workspace/app/ecc-install.json'); })) passed++; else failed++; + if (test('validates explicit module IDs against the manifest catalog', () => { + assert.throws( + () => normalizeInstallRequest({ + target: 'cursor', + profileId: null, + moduleIds: ['ghost-module'], + includeComponentIds: [], + excludeComponentIds: [], + languages: [], + }), + /Unknown install module: ghost-module/ + ); + })) passed++; else failed++; + if (test('rejects mixing legacy languages with manifest flags', () => { assert.throws( () => normalizeInstallRequest({ diff --git a/tests/scripts/ecc.test.js b/tests/scripts/ecc.test.js index b3641b69..9d515bce 100644 --- a/tests/scripts/ecc.test.js +++ b/tests/scripts/ecc.test.js @@ -60,16 +60,18 @@ function main() { assert.strictEqual(result.status, 0, result.stderr); const payload = parseJson(result.stdout); assert.strictEqual(payload.dryRun, true); - assert.strictEqual(payload.plan.mode, 'legacy'); - assert.deepStrictEqual(payload.plan.languages, ['typescript']); + assert.strictEqual(payload.plan.mode, 'legacy-compat'); + assert.deepStrictEqual(payload.plan.legacyLanguages, ['typescript']); + assert.ok(payload.plan.selectedModuleIds.includes('framework-language')); }], ['routes implicit top-level args to install', () => { const result = runCli(['--dry-run', '--json', 'typescript']); assert.strictEqual(result.status, 0, result.stderr); const payload = parseJson(result.stdout); assert.strictEqual(payload.dryRun, true); - assert.strictEqual(payload.plan.mode, 'legacy'); - assert.deepStrictEqual(payload.plan.languages, ['typescript']); + assert.strictEqual(payload.plan.mode, 'legacy-compat'); + assert.deepStrictEqual(payload.plan.legacyLanguages, ['typescript']); + assert.ok(payload.plan.selectedModuleIds.includes('framework-language')); }], ['delegates plan command', () => { const result = runCli(['plan', '--list-profiles', '--json']); diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 9f66db15..3e7193e3 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -89,18 +89,26 @@ function runTests() { const result = run(['typescript'], { cwd: projectDir, homeDir }); assert.strictEqual(result.code, 0, result.stderr); - const rulesDir = path.join(homeDir, '.claude', 'rules'); - assert.ok(fs.existsSync(path.join(rulesDir, 'common', 'coding-style.md'))); - assert.ok(fs.existsSync(path.join(rulesDir, 'typescript', 'testing.md'))); + const claudeRoot = path.join(homeDir, '.claude'); + assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'common', 'coding-style.md'))); + assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'typescript', 'testing.md'))); + assert.ok(fs.existsSync(path.join(claudeRoot, 'commands', 'plan.md'))); + assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'hooks', 'session-end.js'))); + assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md'))); + assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'coding-standards', 'SKILL.md'))); + assert.ok(fs.existsSync(path.join(claudeRoot, 'plugin.json'))); const statePath = path.join(homeDir, '.claude', 'ecc', 'install-state.json'); const state = readJson(statePath); assert.strictEqual(state.target.id, 'claude-home'); assert.deepStrictEqual(state.request.legacyLanguages, ['typescript']); assert.strictEqual(state.request.legacyMode, true); + assert.deepStrictEqual(state.request.modules, []); + assert.ok(state.resolution.selectedModules.includes('rules-core')); + assert.ok(state.resolution.selectedModules.includes('framework-language')); assert.ok( state.operations.some(operation => ( - operation.destinationPath === path.join(rulesDir, 'common', 'coding-style.md') + operation.destinationPath === path.join(claudeRoot, 'rules', 'common', 'coding-style.md') )), 'Should record common rule file operation' ); @@ -118,22 +126,28 @@ function runTests() { const result = run(['--target', 'cursor', 'typescript'], { cwd: projectDir, homeDir }); assert.strictEqual(result.code, 0, result.stderr); - assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-coding-style.md'))); - assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'typescript-testing.md'))); + assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common', 'coding-style.md'))); + assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'typescript', 'testing.md'))); + assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'agents', 'architect.md'))); + assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'commands', 'plan.md'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks', 'session-start.js'))); - assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'skills', 'article-writing', 'SKILL.md'))); + assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'skills', 'tdd-workflow', 'SKILL.md'))); + assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'skills', 'coding-standards', 'SKILL.md'))); const statePath = path.join(projectDir, '.cursor', 'ecc-install-state.json'); const state = readJson(statePath); const normalizedProjectDir = fs.realpathSync(projectDir); assert.strictEqual(state.target.id, 'cursor-project'); assert.strictEqual(state.target.root, path.join(normalizedProjectDir, '.cursor')); + assert.deepStrictEqual(state.request.legacyLanguages, ['typescript']); + assert.strictEqual(state.request.legacyMode, true); + assert.ok(state.resolution.selectedModules.includes('framework-language')); assert.ok( state.operations.some(operation => ( - operation.destinationPath === path.join(normalizedProjectDir, '.cursor', 'hooks', 'session-start.js') + operation.destinationPath === path.join(normalizedProjectDir, '.cursor', 'commands', 'plan.md') )), - 'Should record hook file copy operation' + 'Should record manifest command file copy operation' ); } finally { cleanup(homeDir); @@ -149,20 +163,22 @@ function runTests() { const result = run(['--target', 'antigravity', 'typescript'], { cwd: projectDir, homeDir }); assert.strictEqual(result.code, 0, result.stderr); - assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'rules', 'common-coding-style.md'))); - assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'rules', 'typescript-testing.md'))); - assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'workflows', 'code-review.md'))); - assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'skills', 'architect.md'))); - assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'skills', 'article-writing', 'SKILL.md'))); + assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'rules', 'common', 'coding-style.md'))); + assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'rules', 'typescript', 'testing.md'))); + assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'commands', 'plan.md'))); + assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'agents', 'architect.md'))); const statePath = path.join(projectDir, '.agent', 'ecc-install-state.json'); const state = readJson(statePath); assert.strictEqual(state.target.id, 'antigravity-project'); + assert.deepStrictEqual(state.request.legacyLanguages, ['typescript']); + assert.strictEqual(state.request.legacyMode, true); + assert.deepStrictEqual(state.resolution.selectedModules, ['rules-core', 'agents-core', 'commands-core']); assert.ok( state.operations.some(operation => ( - operation.destinationPath.endsWith(path.join('.agent', 'workflows', 'code-review.md')) + operation.destinationPath.endsWith(path.join('.agent', 'commands', 'plan.md')) )), - 'Should record workflow file copy operation' + 'Should record manifest command file copy operation' ); } finally { cleanup(homeDir); @@ -181,6 +197,8 @@ function runTests() { }); assert.strictEqual(result.code, 0, result.stderr); assert.ok(result.stdout.includes('Dry-run install plan')); + assert.ok(result.stdout.includes('Mode: legacy-compat')); + assert.ok(result.stdout.includes('Legacy languages: typescript')); assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json'))); assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'ecc-install-state.json'))); } finally { @@ -240,6 +258,31 @@ function runTests() { } })) passed++; else failed++; + if (test('installs antigravity manifest profiles while skipping incompatible modules', () => { + const homeDir = createTempDir('install-apply-home-'); + const projectDir = createTempDir('install-apply-project-'); + + try { + const result = run(['--target', 'antigravity', '--profile', 'core'], { cwd: projectDir, homeDir }); + assert.strictEqual(result.code, 0, result.stderr); + + assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'rules', 'common', 'coding-style.md'))); + assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'agents', 'architect.md'))); + assert.ok(fs.existsSync(path.join(projectDir, '.agent', 'commands', 'plan.md'))); + assert.ok(!fs.existsSync(path.join(projectDir, '.agent', 'skills', 'tdd-workflow', 'SKILL.md'))); + + const state = readJson(path.join(projectDir, '.agent', 'ecc-install-state.json')); + assert.strictEqual(state.request.profile, 'core'); + assert.strictEqual(state.request.legacyMode, false); + assert.deepStrictEqual(state.resolution.selectedModules, ['rules-core', 'agents-core', 'commands-core']); + assert.ok(state.resolution.skippedModules.includes('workflow-quality')); + assert.ok(state.resolution.skippedModules.includes('platform-configs')); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + if (test('installs explicit modules for cursor using manifest operations', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-'); @@ -270,6 +313,12 @@ function runTests() { } })) passed++; else failed++; + if (test('rejects unknown explicit manifest modules before resolution', () => { + const result = run(['--modules', 'ghost-module']); + assert.strictEqual(result.code, 1); + assert.ok(result.stderr.includes('Unknown install module: ghost-module')); + })) passed++; else failed++; + if (test('installs from ecc-install.json and persists component selections', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-');