fix(install): require explicit optional cross-target dependencies

This commit is contained in:
Dante committed 2026-10-02 10:40:47 +08:00
1 parent 32ac3aff78
commit e5a5201dbd
5 files changed
+69 -16

No files matched your search

+8
View File
@@ -243,6 +243,11 @@
"commands-core",
"platform-configs"
],
"optionalDependencies": [
"rules-core",
"agents-core",
"commands-core"
],
"defaultInstall": false,
"cost": "medium",
"stability": "stable"
@@ -787,6 +792,9 @@
"commands-core",
"platform-configs"
],
"optionalDependencies": [
"commands-core"
],
"defaultInstall": false,
"cost": "medium",
"stability": "beta"
+9
View File
@@ -73,6 +73,15 @@
"pattern": "^[a-z0-9-]+$"
}
},
"optionalDependencies": {
"description": "Subset of dependencies that may be omitted when unsupported by the selected target. Supported dependencies are still installed.",
"type": "array",
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "^[a-z0-9-]+$"
}
},
"defaultInstall": {
"type": "boolean"
},
+7
View File
@@ -123,6 +123,13 @@ function validateInstallManifests() {
}
}
for (const dependency of module.optionalDependencies || []) {
if (!module.dependencies.includes(dependency)) {
console.error(`ERROR: Module ${module.id} optional dependency ${dependency} is not a declared dependency`);
hasErrors = true;
}
}
for (const relativePath of module.paths) {
const normalizedPath = normalizeRelativePath(relativePath);
const absolutePath = path.join(REPO_ROOT, normalizedPath);
+12 -10
View File
@@ -325,6 +325,12 @@ function loadInstallManifests(options = {}) {
for (const module of modules) {
readModuleTargetsOrThrow(module);
if (module.optionalDependencies !== undefined && (
!Array.isArray(module.optionalDependencies)
|| module.optionalDependencies.some(id => !module.dependencies.includes(id))
)) {
throw new Error(`Install module ${module.id} optionalDependencies must be an array of declared dependency ids`);
}
}
const modulesById = new Map(modules.map(module => [module.id, module]));
@@ -633,7 +639,7 @@ function resolveInstallPlan(options = {}) {
const visitingIds = new Set();
const resolvedIds = new Set();
function resolveModule(moduleId, dependencyOf) {
function resolveModule(moduleId, dependencyOf, optional = false) {
const module = manifests.modulesById.get(moduleId);
if (!module) {
throw new Error(`Unknown install module: ${moduleId}`);
@@ -657,12 +663,9 @@ function resolveInstallPlan(options = {}) {
if (!supportsTarget) {
if (dependencyOf) {
// A module that explicitly supports the selected target remains useful
// when one of its cross-harness dependencies does not. Treat that
// dependency as inapplicable for this target instead of dropping the
// target-capable root module with it.
// Only explicitly optional edges may be omitted for this target.
skippedTargetIds.add(moduleId);
return true;
return optional;
}
skippedTargetIds.add(moduleId);
return false;
@@ -680,13 +683,12 @@ function resolveInstallPlan(options = {}) {
for (const dependencyId of module.dependencies) {
const dependencyResolved = resolveModule(
dependencyId,
moduleId
moduleId,
(module.optionalDependencies || []).includes(dependencyId)
);
if (!dependencyResolved) {
visitingIds.delete(moduleId);
if (!dependencyOf) {
skippedTargetIds.add(moduleId);
}
skippedTargetIds.add(moduleId);
return false;
}
}
+33 -6
View File
@@ -404,8 +404,8 @@ function runTests() {
)), 'Should install the MLE workflow skill');
})) passed++; else failed++;
if (test('resolves machine-learning component on JoyCode and Qwen targets', () => {
for (const target of ['joycode', 'qwen']) {
if (test('resolves machine-learning component across native and shared harness targets', () => {
for (const target of ['joycode', 'qwen', 'codex', 'opencode']) {
const plan = resolveInstallPlan({
includeComponentIds: ['capability:machine-learning'],
target,
@@ -698,7 +698,7 @@ function runTests() {
);
})) passed++; else failed++;
if (test('keeps a requested target-capable module when a dependency does not support the target', () => {
if (test('skips required unsupported dependencies but permits explicitly optional ones', () => {
const repoRoot = createTestRepo();
try {
writeJson(path.join(repoRoot, 'manifests', 'install-modules.json'), {
@@ -735,9 +735,36 @@ function runTests() {
}
});
const plan = resolveInstallPlan({ repoRoot, profileId: 'core', target: 'claude' });
assert.deepStrictEqual(plan.selectedModuleIds, ['parent']);
assert.deepStrictEqual(plan.skippedModuleIds, ['child']);
const requiredPlan = resolveInstallPlan({ repoRoot, profileId: 'core', target: 'claude' });
assert.deepStrictEqual(requiredPlan.selectedModuleIds, []);
assert.deepStrictEqual(requiredPlan.skippedModuleIds, ['parent', 'child']);
const modulesPath = path.join(repoRoot, 'manifests', 'install-modules.json');
const manifest = JSON.parse(fs.readFileSync(modulesPath, 'utf8'));
manifest.modules[0].optionalDependencies = ['child'];
writeJson(modulesPath, manifest);
const optionalPlan = resolveInstallPlan({ repoRoot, profileId: 'core', target: 'claude' });
assert.deepStrictEqual(optionalPlan.selectedModuleIds, ['parent']);
assert.deepStrictEqual(optionalPlan.skippedModuleIds, ['child']);
manifest.modules[1].targets = ['claude'];
writeJson(modulesPath, manifest);
const supportedPlan = resolveInstallPlan({ repoRoot, profileId: 'core', target: 'claude' });
assert.deepStrictEqual(supportedPlan.selectedModuleIds, ['parent', 'child']);
manifest.modules[1].dependencies = ['grandchild'];
manifest.modules.push({ ...manifest.modules[1], id: 'grandchild', targets: ['cursor'], dependencies: [] });
writeJson(modulesPath, manifest);
const transitivePlan = resolveInstallPlan({ repoRoot, profileId: 'core', target: 'claude' });
assert.deepStrictEqual(transitivePlan.selectedModuleIds, []);
assert.deepStrictEqual(transitivePlan.skippedModuleIds, ['parent', 'child', 'grandchild']);
manifest.modules[0].optionalDependencies = ['missing'];
writeJson(modulesPath, manifest);
assert.throws(
() => resolveInstallPlan({ repoRoot, profileId: 'core', target: 'claude' }),
/optionalDependencies must be an array of declared dependency ids/
);
} finally {
cleanupTestRepo(repoRoot);
}