From 5caf398a91599029a176ca6d806409b00d1052c4 Mon Sep 17 00:00:00 2001 From: Samarjeet Singh Tomar Date: Mon, 24 Aug 2026 00:24:45 -0500 Subject: [PATCH] refactor(install): expose pure manifest planner --- scripts/lib/install-executor.js | 321 +---------------------- scripts/lib/install/plan.js | 328 ++++++++++++++++++++++++ tests/lib/install-plan-boundary.test.js | 124 +++++++++ 3 files changed, 465 insertions(+), 308 deletions(-) create mode 100644 scripts/lib/install/plan.js create mode 100644 tests/lib/install-plan-boundary.test.js diff --git a/scripts/lib/install-executor.js b/scripts/lib/install-executor.js index 197823302..a903825c4 100644 --- a/scripts/lib/install-executor.js +++ b/scripts/lib/install-executor.js @@ -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/.md` shadowed - // by an OpenCode `.opencode/commands/.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, diff --git a/scripts/lib/install/plan.js b/scripts/lib/install/plan.js new file mode 100644 index 000000000..d98ef8f0b --- /dev/null +++ b/scripts/lib/install/plan.js @@ -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/.md` shadowed + // by an OpenCode `.opencode/commands/.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, +}; diff --git a/tests/lib/install-plan-boundary.test.js b/tests/lib/install-plan-boundary.test.js new file mode 100644 index 000000000..b1eafddf3 --- /dev/null +++ b/tests/lib/install-plan-boundary.test.js @@ -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: '' } }, + { ...facadePlan, statePreview: { ...facadePlan.statePreview, installedAt: '' } } + ); + } 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();