mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-08 07:37:48 +02:00
Merge pull request #2854 from samartomar/codex/issue-744-pure-plan
refactor(install): expose pure manifest planner
This commit is contained in:
+13
-308
@@ -1,52 +1,27 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const { toCursorAgentRelativePath } = require('./cursor-agent-names');
|
||||
const { LEGACY_INSTALL_TARGETS, parseInstallArgs } = require('./install/request');
|
||||
const { SUPPORTED_INSTALL_TARGETS, listLegacyCompatibilityLanguages, resolveLegacyCompatibilitySelection, resolveInstallPlan } = require('./install-manifests');
|
||||
const {
|
||||
buildCopyFileOperation,
|
||||
createManifestInstallPlan,
|
||||
createStatePreview,
|
||||
dedupeCopyFileOperations,
|
||||
getManifestVersion,
|
||||
getPackageVersion,
|
||||
getRepoCommit,
|
||||
getSourceRoot,
|
||||
listFilesRecursive,
|
||||
readJsonObject,
|
||||
} = require('./install/plan');
|
||||
const { SUPPORTED_INSTALL_TARGETS, listLegacyCompatibilityLanguages, resolveLegacyCompatibilitySelection } = require('./install-manifests');
|
||||
const { getInstallTargetAdapter } = require('./install-targets/registry');
|
||||
const { resolveInvocationEnvironment } = require('./invocation-environment');
|
||||
|
||||
const LANGUAGE_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
||||
const CLAUDE_ECC_NAMESPACE = 'ecc';
|
||||
const EXCLUDED_GENERATED_SOURCE_SUFFIXES = ['/ecc-install-state.json', '/ecc/install-state.json'];
|
||||
|
||||
function getSourceRoot() {
|
||||
return path.join(__dirname, '../..');
|
||||
}
|
||||
|
||||
function getPackageVersion(sourceRoot) {
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'package.json'), 'utf8'));
|
||||
return packageJson.version || null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getManifestVersion(sourceRoot) {
|
||||
try {
|
||||
const modulesManifest = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'manifests', 'install-modules.json'), 'utf8'));
|
||||
return modulesManifest.version || 1;
|
||||
} catch (_error) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function getRepoCommit(sourceRoot) {
|
||||
try {
|
||||
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: sourceRoot,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 5000
|
||||
}).trim();
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readDirectoryNames(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
@@ -81,53 +56,6 @@ function validateLegacyTarget(target) {
|
||||
throw new Error(`Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}`);
|
||||
}
|
||||
|
||||
const IGNORED_DIRECTORY_NAMES = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'__pycache__',
|
||||
'.pytest_cache',
|
||||
]);
|
||||
const IGNORED_FILE_EXTENSIONS = new Set(['.pyc', '.pyo', '.pyd']);
|
||||
|
||||
function listFilesRecursive(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (IGNORED_DIRECTORY_NAMES.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const childFiles = listFilesRecursive(absolutePath);
|
||||
for (const childFile of childFiles) {
|
||||
files.push(path.join(entry.name, childFile));
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
if (IGNORED_FILE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
files.push(entry.name);
|
||||
}
|
||||
}
|
||||
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
function isGeneratedRuntimeSourcePath(sourceRelativePath) {
|
||||
const normalizedPath = String(sourceRelativePath || '').replace(/\\/g, '/');
|
||||
return EXCLUDED_GENERATED_SOURCE_SUFFIXES.some(suffix => normalizedPath.endsWith(suffix));
|
||||
}
|
||||
|
||||
function createStatePreview(options) {
|
||||
const { createInstallState } = require('./install-state');
|
||||
return createInstallState(options);
|
||||
}
|
||||
|
||||
function applyInstallPlan(plan, dependencies = {}) {
|
||||
const { applyInstallPlan: applyPlan } = require('./install/apply');
|
||||
return applyPlan(plan, dependencies);
|
||||
@@ -138,27 +66,6 @@ function previewInstallPlan(plan) {
|
||||
return previewPlan(plan);
|
||||
}
|
||||
|
||||
function buildCopyFileOperation({
|
||||
moduleId,
|
||||
sourcePath,
|
||||
sourceRelativePath,
|
||||
destinationPath,
|
||||
strategy,
|
||||
contentTransform,
|
||||
}) {
|
||||
return {
|
||||
kind: 'copy-file',
|
||||
moduleId,
|
||||
sourcePath,
|
||||
sourceRelativePath,
|
||||
destinationPath,
|
||||
strategy,
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
...(contentTransform ? { contentTransform } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function addRecursiveCopyOperations(operations, options) {
|
||||
const sourceDir = path.join(options.sourceRoot, options.sourceRelativeDir);
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
@@ -209,21 +116,6 @@ function addFileCopyOperation(operations, options) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function readJsonObject(filePath, label) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse ${label} at ${filePath}: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`Invalid ${label} at ${filePath}: expected a JSON object`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function addCursorAgentDataScaffoldOperations(operations, options) {
|
||||
const scaffoldRoot = path.join(options.sourceRoot, 'scaffolds', 'cursor');
|
||||
if (!fs.existsSync(scaffoldRoot)) {
|
||||
@@ -663,193 +555,6 @@ function createLegacyCompatInstallPlan(options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function materializeScaffoldOperation(sourceRoot, operation) {
|
||||
if (operation.kind === 'merge-json') {
|
||||
return [
|
||||
{
|
||||
kind: 'merge-json',
|
||||
moduleId: operation.moduleId,
|
||||
sourceRelativePath: operation.sourceRelativePath,
|
||||
destinationPath: operation.destinationPath,
|
||||
strategy: operation.strategy || 'merge-json',
|
||||
ownership: operation.ownership || 'managed',
|
||||
scaffoldOnly: Object.hasOwn(operation, 'scaffoldOnly') ? operation.scaffoldOnly : false,
|
||||
mergePayload: readJsonObject(path.join(sourceRoot, operation.sourceRelativePath), operation.sourceRelativePath)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
const sourcePath = path.join(sourceRoot, operation.sourceRelativePath);
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isGeneratedRuntimeSourcePath(operation.sourceRelativePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stat = fs.statSync(sourcePath);
|
||||
if (stat.isFile()) {
|
||||
return [
|
||||
buildCopyFileOperation({
|
||||
moduleId: operation.moduleId,
|
||||
sourcePath,
|
||||
sourceRelativePath: operation.sourceRelativePath,
|
||||
destinationPath: operation.destinationPath,
|
||||
strategy: operation.strategy,
|
||||
contentTransform: operation.contentTransform,
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
const relativeFiles = listFilesRecursive(sourcePath).filter(relativeFile => {
|
||||
const sourceRelativePath = path.join(operation.sourceRelativePath, relativeFile);
|
||||
return !isGeneratedRuntimeSourcePath(sourceRelativePath);
|
||||
});
|
||||
return relativeFiles.map(relativeFile => {
|
||||
const sourceRelativePath = path.join(operation.sourceRelativePath, relativeFile);
|
||||
return buildCopyFileOperation({
|
||||
moduleId: operation.moduleId,
|
||||
sourcePath: path.join(sourcePath, relativeFile),
|
||||
sourceRelativePath,
|
||||
destinationPath: path.join(operation.destinationPath, relativeFile),
|
||||
strategy: operation.strategy,
|
||||
contentTransform: operation.contentTransform,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isSelectedAntigravityLegacyRule(operation, ruleLanguages) {
|
||||
const normalizedSourcePath = String(operation.sourceRelativePath || '').replace(/\\/g, '/');
|
||||
if (!normalizedSourcePath.startsWith('rules/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const namespace = normalizedSourcePath.split('/')[1];
|
||||
return namespace === 'common' || ruleLanguages.includes(namespace);
|
||||
}
|
||||
|
||||
function dedupeCopyFileOperations(operations) {
|
||||
// A `copy-file` operation fully overwrites its destination, so when several
|
||||
// of them target the same path (e.g. a generic `commands/<name>.md` shadowed
|
||||
// by an OpenCode `.opencode/commands/<name>.md` override) only the last one
|
||||
// actually determines the installed content. Recording the shadowed earlier
|
||||
// writes in install-state makes `doctor` report perpetual drift and drives
|
||||
// `repair` to clobber the override with the generic source (issue #2414).
|
||||
// Keep only the last `copy-file` per destination - matching the sequential
|
||||
// apply order in applyInstallPlan - and leave every other operation kind
|
||||
// (e.g. accumulating `merge-json` writes into a shared config) untouched and
|
||||
// in order.
|
||||
const lastCopyIndexByDestination = new Map();
|
||||
operations.forEach((operation, index) => {
|
||||
if (operation.kind === 'copy-file' && operation.destinationPath) {
|
||||
lastCopyIndexByDestination.set(operation.destinationPath, index);
|
||||
}
|
||||
});
|
||||
|
||||
return operations.filter((operation, index) => {
|
||||
if (operation.kind !== 'copy-file' || !operation.destinationPath) {
|
||||
return true;
|
||||
}
|
||||
return lastCopyIndexByDestination.get(operation.destinationPath) === index;
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
homeDir: options.homeDir,
|
||||
env: resolveInvocationEnvironment(options),
|
||||
profileId: options.profileId || null,
|
||||
moduleIds: options.moduleIds || [],
|
||||
includeComponentIds: options.includeComponentIds || [],
|
||||
excludeComponentIds: options.excludeComponentIds || [],
|
||||
target,
|
||||
exemptValidationCodes: options.exemptValidationCodes || [],
|
||||
});
|
||||
const adapter = getInstallTargetAdapter(target);
|
||||
const materializedOperations = plan.operations.flatMap(operation => (
|
||||
materializeScaffoldOperation(sourceRoot, operation)
|
||||
));
|
||||
const ruleLanguages = Array.isArray(options.ruleLanguages) ? [...options.ruleLanguages] : [];
|
||||
const operations = dedupeCopyFileOperations(
|
||||
options.legacyMode && target === 'antigravity'
|
||||
? materializedOperations.filter(operation => (
|
||||
isSelectedAntigravityLegacyRule(operation, ruleLanguages)
|
||||
))
|
||||
: materializedOperations
|
||||
);
|
||||
const source = {
|
||||
repoVersion: getPackageVersion(sourceRoot),
|
||||
repoCommit: getRepoCommit(sourceRoot),
|
||||
manifestVersion: getManifestVersion(sourceRoot)
|
||||
};
|
||||
const statePreview = createStatePreview({
|
||||
adapter,
|
||||
targetRoot: plan.targetRoot,
|
||||
installStatePath: plan.installStatePath,
|
||||
request: {
|
||||
profile: requestProfileId,
|
||||
modules: requestModuleIds,
|
||||
includeComponents: requestIncludeComponentIds,
|
||||
excludeComponents: requestExcludeComponentIds,
|
||||
legacyLanguages,
|
||||
legacyMode: Boolean(options.legacyMode)
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: plan.selectedModuleIds,
|
||||
skippedModules: plan.skippedModuleIds
|
||||
},
|
||||
operations,
|
||||
source
|
||||
});
|
||||
|
||||
return {
|
||||
mode: options.mode || 'manifest',
|
||||
sourceRoot,
|
||||
target,
|
||||
adapter: {
|
||||
id: adapter.id,
|
||||
target: adapter.target,
|
||||
kind: adapter.kind
|
||||
},
|
||||
homeDir: plan.homeDir,
|
||||
targetRoot: plan.targetRoot,
|
||||
installRoot: plan.targetRoot,
|
||||
installStatePath: plan.installStatePath,
|
||||
warnings: Array.isArray(options.warnings) ? [...options.warnings] : [],
|
||||
languages: legacyLanguages,
|
||||
legacyLanguages,
|
||||
profileId: plan.profileId,
|
||||
requestedModuleIds: plan.requestedModuleIds,
|
||||
explicitModuleIds: plan.explicitModuleIds,
|
||||
includedComponentIds: plan.includedComponentIds,
|
||||
excludedComponentIds: plan.excludedComponentIds,
|
||||
selectedModuleIds: plan.selectedModuleIds,
|
||||
skippedModuleIds: plan.skippedModuleIds,
|
||||
excludedModuleIds: plan.excludedModuleIds,
|
||||
operations,
|
||||
statePreview
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SUPPORTED_INSTALL_TARGETS,
|
||||
LEGACY_INSTALL_TARGETS,
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const { resolveInstallPlan } = require('../install-manifests');
|
||||
const { getInstallTargetAdapter } = require('../install-targets/registry');
|
||||
const { resolveInvocationEnvironment } = require('../invocation-environment');
|
||||
|
||||
const EXCLUDED_GENERATED_SOURCE_SUFFIXES = ['/ecc-install-state.json', '/ecc/install-state.json'];
|
||||
const IGNORED_DIRECTORY_NAMES = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'__pycache__',
|
||||
'.pytest_cache',
|
||||
]);
|
||||
const IGNORED_FILE_EXTENSIONS = new Set(['.pyc', '.pyo', '.pyd']);
|
||||
|
||||
function getSourceRoot() {
|
||||
return path.join(__dirname, '../../..');
|
||||
}
|
||||
|
||||
function getPackageVersion(sourceRoot) {
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'package.json'), 'utf8'));
|
||||
return packageJson.version || null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getManifestVersion(sourceRoot) {
|
||||
try {
|
||||
const modulesManifest = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'manifests', 'install-modules.json'), 'utf8'));
|
||||
return modulesManifest.version || 1;
|
||||
} catch (_error) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function getRepoCommit(sourceRoot) {
|
||||
try {
|
||||
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: sourceRoot,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 5000
|
||||
}).trim();
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listFilesRecursive(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (IGNORED_DIRECTORY_NAMES.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
const childFiles = listFilesRecursive(absolutePath);
|
||||
for (const childFile of childFiles) {
|
||||
files.push(path.join(entry.name, childFile));
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
if (IGNORED_FILE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
files.push(entry.name);
|
||||
}
|
||||
}
|
||||
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
function isGeneratedRuntimeSourcePath(sourceRelativePath) {
|
||||
const normalizedPath = String(sourceRelativePath || '').replace(/\\/g, '/');
|
||||
return EXCLUDED_GENERATED_SOURCE_SUFFIXES.some(suffix => normalizedPath.endsWith(suffix));
|
||||
}
|
||||
|
||||
function createStatePreview(options) {
|
||||
const { createInstallState } = require('../install-state');
|
||||
return createInstallState(options);
|
||||
}
|
||||
|
||||
function buildCopyFileOperation({
|
||||
moduleId,
|
||||
sourcePath,
|
||||
sourceRelativePath,
|
||||
destinationPath,
|
||||
strategy,
|
||||
contentTransform,
|
||||
}) {
|
||||
return {
|
||||
kind: 'copy-file',
|
||||
moduleId,
|
||||
sourcePath,
|
||||
sourceRelativePath,
|
||||
destinationPath,
|
||||
strategy,
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
...(contentTransform ? { contentTransform } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readJsonObject(filePath, label) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse ${label} at ${filePath}: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`Invalid ${label} at ${filePath}: expected a JSON object`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function materializeScaffoldOperation(sourceRoot, operation) {
|
||||
if (operation.kind === 'merge-json') {
|
||||
return [
|
||||
{
|
||||
kind: 'merge-json',
|
||||
moduleId: operation.moduleId,
|
||||
sourceRelativePath: operation.sourceRelativePath,
|
||||
destinationPath: operation.destinationPath,
|
||||
strategy: operation.strategy || 'merge-json',
|
||||
ownership: operation.ownership || 'managed',
|
||||
scaffoldOnly: Object.hasOwn(operation, 'scaffoldOnly') ? operation.scaffoldOnly : false,
|
||||
mergePayload: readJsonObject(path.join(sourceRoot, operation.sourceRelativePath), operation.sourceRelativePath)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
const sourcePath = path.join(sourceRoot, operation.sourceRelativePath);
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isGeneratedRuntimeSourcePath(operation.sourceRelativePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stat = fs.statSync(sourcePath);
|
||||
if (stat.isFile()) {
|
||||
return [
|
||||
buildCopyFileOperation({
|
||||
moduleId: operation.moduleId,
|
||||
sourcePath,
|
||||
sourceRelativePath: operation.sourceRelativePath,
|
||||
destinationPath: operation.destinationPath,
|
||||
strategy: operation.strategy,
|
||||
contentTransform: operation.contentTransform,
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
const relativeFiles = listFilesRecursive(sourcePath).filter(relativeFile => {
|
||||
const sourceRelativePath = path.join(operation.sourceRelativePath, relativeFile);
|
||||
return !isGeneratedRuntimeSourcePath(sourceRelativePath);
|
||||
});
|
||||
return relativeFiles.map(relativeFile => {
|
||||
const sourceRelativePath = path.join(operation.sourceRelativePath, relativeFile);
|
||||
return buildCopyFileOperation({
|
||||
moduleId: operation.moduleId,
|
||||
sourcePath: path.join(sourcePath, relativeFile),
|
||||
sourceRelativePath,
|
||||
destinationPath: path.join(operation.destinationPath, relativeFile),
|
||||
strategy: operation.strategy,
|
||||
contentTransform: operation.contentTransform,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isSelectedAntigravityLegacyRule(operation, ruleLanguages) {
|
||||
const normalizedSourcePath = String(operation.sourceRelativePath || '').replace(/\\/g, '/');
|
||||
if (!normalizedSourcePath.startsWith('rules/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const namespace = normalizedSourcePath.split('/')[1];
|
||||
return namespace === 'common' || ruleLanguages.includes(namespace);
|
||||
}
|
||||
|
||||
function dedupeCopyFileOperations(operations) {
|
||||
// A `copy-file` operation fully overwrites its destination, so when several
|
||||
// of them target the same path (e.g. a generic `commands/<name>.md` shadowed
|
||||
// by an OpenCode `.opencode/commands/<name>.md` override) only the last one
|
||||
// actually determines the installed content. Recording the shadowed earlier
|
||||
// writes in install-state makes `doctor` report perpetual drift and drives
|
||||
// `repair` to clobber the override with the generic source (issue #2414).
|
||||
// Keep only the last `copy-file` per destination - matching the sequential
|
||||
// apply order in applyInstallPlan - and leave every other operation kind
|
||||
// (e.g. accumulating `merge-json` writes into a shared config) untouched and
|
||||
// in order.
|
||||
const lastCopyIndexByDestination = new Map();
|
||||
operations.forEach((operation, index) => {
|
||||
if (operation.kind === 'copy-file' && operation.destinationPath) {
|
||||
lastCopyIndexByDestination.set(operation.destinationPath, index);
|
||||
}
|
||||
});
|
||||
|
||||
return operations.filter((operation, index) => {
|
||||
if (operation.kind !== 'copy-file' || !operation.destinationPath) {
|
||||
return true;
|
||||
}
|
||||
return lastCopyIndexByDestination.get(operation.destinationPath) === index;
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
homeDir: options.homeDir,
|
||||
env: resolveInvocationEnvironment(options),
|
||||
profileId: options.profileId || null,
|
||||
moduleIds: options.moduleIds || [],
|
||||
includeComponentIds: options.includeComponentIds || [],
|
||||
excludeComponentIds: options.excludeComponentIds || [],
|
||||
target,
|
||||
exemptValidationCodes: options.exemptValidationCodes || [],
|
||||
});
|
||||
const adapter = getInstallTargetAdapter(target);
|
||||
const materializedOperations = plan.operations.flatMap(operation => (
|
||||
materializeScaffoldOperation(sourceRoot, operation)
|
||||
));
|
||||
const ruleLanguages = Array.isArray(options.ruleLanguages) ? [...options.ruleLanguages] : [];
|
||||
const operations = dedupeCopyFileOperations(
|
||||
options.legacyMode && target === 'antigravity'
|
||||
? materializedOperations.filter(operation => (
|
||||
isSelectedAntigravityLegacyRule(operation, ruleLanguages)
|
||||
))
|
||||
: materializedOperations
|
||||
);
|
||||
const source = {
|
||||
repoVersion: getPackageVersion(sourceRoot),
|
||||
repoCommit: getRepoCommit(sourceRoot),
|
||||
manifestVersion: getManifestVersion(sourceRoot)
|
||||
};
|
||||
const statePreview = createStatePreview({
|
||||
adapter,
|
||||
targetRoot: plan.targetRoot,
|
||||
installStatePath: plan.installStatePath,
|
||||
request: {
|
||||
profile: requestProfileId,
|
||||
modules: requestModuleIds,
|
||||
includeComponents: requestIncludeComponentIds,
|
||||
excludeComponents: requestExcludeComponentIds,
|
||||
legacyLanguages,
|
||||
legacyMode: Boolean(options.legacyMode)
|
||||
},
|
||||
resolution: {
|
||||
selectedModules: plan.selectedModuleIds,
|
||||
skippedModules: plan.skippedModuleIds
|
||||
},
|
||||
operations,
|
||||
source
|
||||
});
|
||||
|
||||
return {
|
||||
mode: options.mode || 'manifest',
|
||||
sourceRoot,
|
||||
target,
|
||||
adapter: {
|
||||
id: adapter.id,
|
||||
target: adapter.target,
|
||||
kind: adapter.kind
|
||||
},
|
||||
homeDir: plan.homeDir,
|
||||
targetRoot: plan.targetRoot,
|
||||
installRoot: plan.targetRoot,
|
||||
installStatePath: plan.installStatePath,
|
||||
warnings: Array.isArray(options.warnings) ? [...options.warnings] : [],
|
||||
languages: legacyLanguages,
|
||||
legacyLanguages,
|
||||
profileId: plan.profileId,
|
||||
requestedModuleIds: plan.requestedModuleIds,
|
||||
explicitModuleIds: plan.explicitModuleIds,
|
||||
includedComponentIds: plan.includedComponentIds,
|
||||
excludedComponentIds: plan.excludedComponentIds,
|
||||
selectedModuleIds: plan.selectedModuleIds,
|
||||
skippedModuleIds: plan.skippedModuleIds,
|
||||
excludedModuleIds: plan.excludedModuleIds,
|
||||
operations,
|
||||
statePreview
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildCopyFileOperation,
|
||||
createManifestInstallPlan,
|
||||
createStatePreview,
|
||||
dedupeCopyFileOperations,
|
||||
getManifestVersion,
|
||||
getPackageVersion,
|
||||
getRepoCommit,
|
||||
getSourceRoot,
|
||||
listFilesRecursive,
|
||||
readJsonObject,
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Contract tests for the planning-only install entry point.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const Module = require('module');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
||||
const PLAN_ENTRY = path.join(REPO_ROOT, 'scripts', 'lib', 'install', 'plan.js');
|
||||
const NODE_BUILTINS = new Set(Module.builtinModules.flatMap(name => [name, `node:${name}`]));
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRelativeModule(from, specifier) {
|
||||
const base = path.resolve(path.dirname(from), specifier);
|
||||
const candidates = [base, `${base}.js`, `${base}.json`, path.join(base, 'index.js')];
|
||||
const found = candidates.find(candidate => fs.existsSync(candidate) && fs.statSync(candidate).isFile());
|
||||
assert.ok(found, `Could not resolve planning dependency from ${path.relative(REPO_ROOT, from)}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function planningDependencyClosure(entry) {
|
||||
const pending = [entry];
|
||||
const visited = new Set();
|
||||
|
||||
while (pending.length > 0) {
|
||||
const filePath = pending.pop();
|
||||
if (visited.has(filePath)) continue;
|
||||
visited.add(filePath);
|
||||
if (!filePath.endsWith('.js')) continue;
|
||||
|
||||
const source = fs.readFileSync(filePath, 'utf8');
|
||||
for (const match of source.matchAll(/\brequire\s*\(([^)\r\n]*)\)/g)) {
|
||||
const argument = match[1].trim();
|
||||
const literal = argument.match(/^(['"])([^'"]+)\1$/);
|
||||
assert.ok(literal, `Dynamic require in planning dependency ${path.relative(REPO_ROOT, filePath)}`);
|
||||
const specifier = literal[2];
|
||||
if (NODE_BUILTINS.has(specifier)) continue;
|
||||
assert.ok(specifier.startsWith('.'), `Package import in planning dependency ${path.relative(REPO_ROOT, filePath)}`);
|
||||
pending.push(resolveRelativeModule(filePath, specifier));
|
||||
}
|
||||
}
|
||||
|
||||
return [...visited].map(filePath => path.relative(REPO_ROOT, filePath).split(path.sep).join('/')).sort();
|
||||
}
|
||||
|
||||
function createPlan(createManifestInstallPlan, homeDir) {
|
||||
return createManifestInstallPlan({
|
||||
sourceRoot: REPO_ROOT,
|
||||
target: 'antigravity',
|
||||
moduleIds: ['agents-core'],
|
||||
homeDir,
|
||||
});
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
if (test('exposes a planning-only module with a package-free lexical dependency closure', () => {
|
||||
const closure = planningDependencyClosure(PLAN_ENTRY);
|
||||
assert.ok(closure.includes('scripts/lib/install/plan.js'));
|
||||
assert.ok(!closure.includes('scripts/lib/install/apply.js'));
|
||||
assert.ok(!closure.includes('scripts/lib/install/antigravity-agent.js'));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('does not load js-yaml while generating a real manifest plan', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-pure-plan-load-'));
|
||||
const loaded = [];
|
||||
const originalLoad = Module._load;
|
||||
try {
|
||||
Module._load = function(request, parent, isMain) {
|
||||
loaded.push(request);
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
const { createManifestInstallPlan } = require(PLAN_ENTRY);
|
||||
const plan = createPlan(createManifestInstallPlan, tempDir);
|
||||
assert.ok(plan.operations.length > 0);
|
||||
assert.ok(plan.operations.some(operation => operation.contentTransform === 'antigravity-agent-frontmatter'));
|
||||
assert.deepStrictEqual(loaded.filter(request => request === 'js-yaml' || request.startsWith('js-yaml/')), []);
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('preserves the install-executor manifest-plan contract exactly', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-pure-plan-contract-'));
|
||||
try {
|
||||
const pure = require(PLAN_ENTRY).createManifestInstallPlan;
|
||||
const facade = require('../../scripts/lib/install-executor').createManifestInstallPlan;
|
||||
const purePlan = createPlan(pure, tempDir);
|
||||
const facadePlan = createPlan(facade, tempDir);
|
||||
assert.match(purePlan.statePreview.installedAt, /^\d{4}-\d{2}-\d{2}T/);
|
||||
assert.match(facadePlan.statePreview.installedAt, /^\d{4}-\d{2}-\d{2}T/);
|
||||
assert.deepStrictEqual(
|
||||
{ ...purePlan, statePreview: { ...purePlan.statePreview, installedAt: '<timestamp>' } },
|
||||
{ ...facadePlan, statePreview: { ...facadePlan.statePreview, installedAt: '<timestamp>' } }
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
Reference in New Issue
Block a user