refactor: split install-lifecycle god-module, harden test runner and lint

Extract contained-fs (TOCTOU-safe fs ops) and json-ops helpers into scripts/lib/install/. install-lifecycle 2453->1957 lines, public API unchanged (66/0 lifecycle tests pass). Harden tests/run-all.js with exit-code-first verdict, TAP fallback, file-level counts and filter arg. Add warn-level eslint guards (prefer-const, complexity, max-depth, max-params). Split test scripts into test:validators/test:js/test:python and coverage:js/coverage:python.
This commit is contained in:
Geronimo
2026-09-20 20:46:45 +05:30
parent 9d579fbcfa
commit 5191a908d1
6 changed files with 657 additions and 547 deletions
+7 -1
View File
@@ -22,7 +22,13 @@ module.exports = [
caughtErrorsIgnorePattern: '^_'
}],
'no-undef': 'error',
'eqeqeq': 'warn'
'eqeqeq': 'warn',
'prefer-const': 'warn',
'no-var': 'warn',
'no-console': 'off',
'complexity': ['warn', 20],
'max-depth': ['warn', 5],
'max-params': ['warn', 6]
}
},
{
+7 -2
View File
@@ -491,8 +491,13 @@
"orchestrate:status": "node scripts/orchestration-status.js",
"orchestrate:worker": "bash scripts/orchestrate-codex-worker.sh",
"orchestrate:tmux": "node scripts/orchestrate-worktrees.js",
"test": "node scripts/ci/check-unicode-safety.js && node scripts/ci/validate-agents.js && node scripts/ci/validate-commands.js && node scripts/ci/validate-rules.js && node scripts/ci/validate-skills.js && node scripts/ci/validate-hooks.js && node scripts/ci/check-hooks-schema-keys.js && node scripts/ci/validate-install-manifests.js && node scripts/ci/validate-no-personal-paths.js && npm run catalog:check && npm run command-registry:check && node tests/run-all.js",
"coverage": "c8 --all --include=\"scripts/**/*.js\" --include=\"scripts/**/*.mjs\" --check-coverage --lines 80 --functions 80 --branches 79 --statements 80 --reporter=text --reporter=lcov node tests/run-all.js",
"test:validators": "node scripts/ci/check-unicode-safety.js && node scripts/ci/validate-agents.js && node scripts/ci/validate-commands.js && node scripts/ci/validate-rules.js && node scripts/ci/validate-skills.js && node scripts/ci/validate-hooks.js && node scripts/ci/check-hooks-schema-keys.js && node scripts/ci/validate-install-manifests.js && node scripts/ci/validate-no-personal-paths.js && npm run catalog:check && npm run command-registry:check",
"test:js": "node tests/run-all.js",
"test:python": "python3 -m pytest tests/ -q",
"test": "npm run test:validators && npm run test:js",
"coverage:js": "c8 --all --include=\"scripts/**/*.js\" --include=\"scripts/**/*.mjs\" --check-coverage --lines 80 --functions 80 --branches 79 --statements 80 --reporter=text --reporter=lcov node tests/run-all.js",
"coverage:python": "python3 -m pytest tests/ -q --cov=src --cov-report=term --cov-fail-under=80",
"coverage": "npm run coverage:js",
"build:opencode": "node scripts/build-opencode.js",
"prepack": "npm run build:opencode",
"dashboard": "python3 ./ecc_dashboard.py",
+25 -521
View File
@@ -25,7 +25,6 @@ const {
} = require('./install/opencode-legacy-migration');
const {
acquireSettingsLock,
assertClaudeSettingsPath,
getClaudeSettingsPath,
inspectManagedHooks,
materializeManagedHooks,
@@ -38,7 +37,31 @@ const { adaptAntigravityAgent } = require('./install/antigravity-agent');
const { buildInstallIndex, rewriteRelativeLinks } = require('./install/link-rewrite');
const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry');
const { resolveInvocationEnvironment } = require('./invocation-environment');
const { mergeHooksMetadata, metadataPathFor } = require('./hooks-config');
const {
assertClaudeSettingsDestination,
cleanupEmptyParentDirs,
copyContainedFile,
getContainedExistingPath,
getManagedDestination,
prepareContainedWriteDestination,
readFileNoFollow,
readFileWithMetadataNoFollow,
readHooksConfigNoFollow,
readJsonNoFollow,
removeContainedPath,
writeContainedFile,
} = require('./install/contained-fs');
const {
JSON_REMOVE_SENTINEL,
deepMergeJson,
deepRemoveJsonSubset,
formatJson,
getOperationJsonPayload,
getOperationPreviousContent,
getOperationPreviousJson,
getOperationTextContent,
jsonContainsSubset,
} = require('./install/json-ops');
const OPENCODE_BUILD_ARTIFACT = path.join('.opencode', 'dist');
const OPENCODE_BUILD_SCRIPT = path.join('scripts', 'build-opencode.js');
const OPENCODE_PLUGIN_NOT_BUILT_CODE = 'opencode-plugin-not-built';
@@ -246,488 +269,6 @@ function getExpectedCopyFileContent(operation, content, linkIndex) {
});
}
function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function cloneJsonValue(value) {
if (value === undefined) {
return undefined;
}
return JSON.parse(JSON.stringify(value));
}
function parseJsonLikeValue(value, label) {
if (value === undefined) {
return undefined;
}
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch (error) {
throw new Error(`Invalid ${label}: ${error.message}`);
}
}
if (value === null || Array.isArray(value) || isPlainObject(value) || typeof value === 'number' || typeof value === 'boolean') {
return cloneJsonValue(value);
}
throw new Error(`Invalid ${label}: expected JSON-compatible data`);
}
function getOperationTextContent(operation) {
const candidateKeys = ['renderedContent', 'content', 'managedContent', 'expectedContent', 'templateOutput'];
for (const key of candidateKeys) {
if (typeof operation[key] === 'string') {
return operation[key];
}
}
return null;
}
function getOperationJsonPayload(operation) {
const candidateKeys = ['mergePayload', 'managedPayload', 'payload', 'value', 'expectedValue'];
for (const key of candidateKeys) {
if (operation[key] !== undefined) {
return parseJsonLikeValue(operation[key], `${operation.kind}.${key}`);
}
}
return undefined;
}
function getOperationPreviousContent(operation) {
const candidateKeys = ['previousContent', 'originalContent', 'backupContent'];
for (const key of candidateKeys) {
if (typeof operation[key] === 'string') {
return operation[key];
}
}
return null;
}
function getOperationPreviousJson(operation) {
const candidateKeys = ['previousValue', 'previousJson', 'originalValue'];
for (const key of candidateKeys) {
if (operation[key] !== undefined) {
return parseJsonLikeValue(operation[key], `${operation.kind}.${key}`);
}
}
return undefined;
}
function formatJson(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
function getManagedDestination(
destinationPath,
trustedRoot,
action,
{ allowFinalSymlink = false } = {}
) {
if (!destinationPath || typeof destinationPath !== 'string') {
throw new Error(`Refusing to ${action}: missing destination path.`);
}
const canonicalRoot = assertWithinTrustedRoot(trustedRoot, trustedRoot, action);
const resolvedDestination = path.resolve(destinationPath);
const canonicalParent = assertWithinTrustedRoot(
path.dirname(resolvedDestination),
canonicalRoot,
action
);
const managedPath = path.join(canonicalParent, path.basename(resolvedDestination));
let stat = null;
try {
stat = fs.lstatSync(managedPath);
} catch (error) {
if (!error || (error.code !== 'ENOENT' && error.code !== 'ENOTDIR')) {
throw error;
}
}
if (stat && stat.isSymbolicLink() && !allowFinalSymlink) {
const error = new Error(
`Refusing to ${action}: managed destination is a final symlink.`
);
error.code = 'ECC_FINAL_DESTINATION_SYMLINK';
throw error;
}
return {
canonicalRoot,
exists: stat !== null,
isFinalSymlink: Boolean(stat && stat.isSymbolicLink()),
managedPath
};
}
function ensureContainedParentDir(destinationPath, trustedRoot, action) {
const initialDestination = getManagedDestination(
destinationPath,
trustedRoot,
action
);
const { canonicalRoot, managedPath } = initialDestination;
const canonicalParent = path.dirname(managedPath);
const relativeParent = path.relative(canonicalRoot, canonicalParent);
const pathSegments = relativeParent
? relativeParent.split(path.sep).filter(Boolean)
: [];
let currentPath = canonicalRoot;
for (const segment of pathSegments) {
const validatedParent = assertWithinTrustedRoot(currentPath, canonicalRoot, action);
const nextPath = path.join(validatedParent, segment);
try {
fs.mkdirSync(nextPath);
} catch (error) {
if (!error || error.code !== 'EEXIST') {
throw error;
}
}
const validatedNext = assertWithinTrustedRoot(nextPath, canonicalRoot, action);
const nextStat = fs.lstatSync(validatedNext);
if (!nextStat.isDirectory() || nextStat.isSymbolicLink()) {
throw new Error(`Refusing to ${action}: destination parent is not a trusted directory.`);
}
currentPath = validatedNext;
}
return getManagedDestination(managedPath, canonicalRoot, action).managedPath;
}
function prepareContainedWriteDestination(destinationPath, trustedRoot, action) {
return ensureContainedParentDir(destinationPath, trustedRoot, action);
}
function getContainedExistingPath(
destinationPath,
trustedRoot,
action,
{ allowFinalSymlink = false } = {}
) {
const initialDestination = getManagedDestination(
destinationPath,
trustedRoot,
action,
{ allowFinalSymlink }
);
const followsToExistingPath = fs.existsSync(initialDestination.managedPath);
if (!followsToExistingPath && !initialDestination.isFinalSymlink) {
return null;
}
const finalDestination = getManagedDestination(
initialDestination.managedPath,
trustedRoot,
action,
{ allowFinalSymlink }
);
return finalDestination.exists ? finalDestination.managedPath : null;
}
function hasSameFileIdentity(leftStat, rightStat) {
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
}
function createChangedDestinationError(action) {
return new Error(
`Refusing to ${action}: managed destination changed during the write.`
);
}
function getStableParentStat(filePath, action) {
const parentStat = fs.lstatSync(path.dirname(filePath));
if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) {
throw createChangedDestinationError(action);
}
return parentStat;
}
function assertPinnedWriteDestination(
filePath,
fileDescriptor,
expectedParentStat,
trustedRoot,
action
) {
const liveDestination = getManagedDestination(filePath, trustedRoot, action);
if (path.resolve(liveDestination.managedPath) !== path.resolve(filePath)) {
throw createChangedDestinationError(action);
}
const liveParentStat = getStableParentStat(filePath, action);
if (!hasSameFileIdentity(expectedParentStat, liveParentStat)) {
throw createChangedDestinationError(action);
}
const descriptorStat = fs.fstatSync(fileDescriptor);
const livePathStat = fs.lstatSync(liveDestination.managedPath);
if (
!descriptorStat.isFile()
|| !livePathStat.isFile()
|| livePathStat.isSymbolicLink()
|| !hasSameFileIdentity(descriptorStat, livePathStat)
) {
throw createChangedDestinationError(action);
}
}
function writeFileNoFollow(filePath, content, mode, trustedRoot, action) {
const expectedParentStat = getStableParentStat(filePath, action);
const flags = fs.constants.O_WRONLY
| fs.constants.O_CREAT
| (fs.constants.O_NOFOLLOW || 0);
const fileDescriptor = fs.openSync(filePath, flags, mode);
try {
assertPinnedWriteDestination(
filePath,
fileDescriptor,
expectedParentStat,
trustedRoot,
action
);
fs.ftruncateSync(fileDescriptor, 0);
fs.writeFileSync(fileDescriptor, content);
if (mode !== undefined) {
fs.fchmodSync(fileDescriptor, mode);
}
} finally {
fs.closeSync(fileDescriptor);
}
}
function readFileWithMetadataNoFollow(filePath, encoding) {
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
const fileDescriptor = fs.openSync(filePath, flags);
try {
const stat = fs.fstatSync(fileDescriptor);
if (!stat.isFile()) {
throw new Error(`Refusing to read non-file path: ${filePath}`);
}
return {
content: fs.readFileSync(fileDescriptor, encoding),
mode: stat.mode,
};
} finally {
fs.closeSync(fileDescriptor);
}
}
function readFileNoFollow(filePath, encoding) {
return readFileWithMetadataNoFollow(filePath, encoding).content;
}
function readJsonNoFollow(filePath) {
return JSON.parse(readFileNoFollow(filePath, 'utf8'));
}
/**
* Read hooks.json and merge in hooks/hooks.metadata.json without following
* symlinks. The sidecar holds the stable matcher ids that hooks.json cannot
* carry, because Claude Code reports unknown keys when the plugin loads. A
* sidecar that does not line up with hooks.json is rejected before repair can
* reconcile matchers under the wrong ids.
*
* @param {string} hooksPath - Path to the source hooks.json.
* @returns {object} the hooks configuration with ids and descriptions restored.
*/
function readHooksConfigNoFollow(hooksPath) {
const hooksConfig = readJsonNoFollow(hooksPath);
const metadataPath = metadataPathFor(hooksPath);
if (!fs.existsSync(metadataPath)) {
return hooksConfig;
}
return mergeHooksMetadata(hooksConfig, readJsonNoFollow(metadataPath), hooksPath);
}
function assertClaudeSettingsDestination(operation, trustedRoot, target = null) {
if (target && target !== 'claude' && target !== 'claude-project') {
throw new Error('Refusing to manage Claude hooks for a non-Claude target.');
}
assertClaudeSettingsPath(operation.destinationPath, trustedRoot);
}
function writeContainedFile(destinationPath, content, trustedRoot, action, mode) {
const preparedDestination = prepareContainedWriteDestination(destinationPath, trustedRoot, action);
const finalDestination = getManagedDestination(
preparedDestination,
trustedRoot,
action
).managedPath;
writeFileNoFollow(
finalDestination,
content,
mode,
trustedRoot,
action
);
return finalDestination;
}
function copyContainedFile(sourcePath, destinationPath, trustedRoot, action) {
const source = readFileWithMetadataNoFollow(sourcePath);
return writeContainedFile(
destinationPath,
source.content,
trustedRoot,
action,
source.mode & 0o777
);
}
function removeContainedPath(destinationPath, trustedRoot, action, options = {}) {
const existingDestination = getContainedExistingPath(
destinationPath,
trustedRoot,
action,
{ allowFinalSymlink: true }
);
if (!existingDestination) {
return null;
}
const managedDestination = getManagedDestination(
existingDestination,
trustedRoot,
action,
{ allowFinalSymlink: true }
);
const finalDestination = managedDestination.managedPath;
const expectedStat = fs.lstatSync(finalDestination, { bigint: true });
const quarantineDir = fs.mkdtempSync(path.join(
path.dirname(managedDestination.canonicalRoot),
'.ecc-remove-'
));
const quarantinePath = path.join(quarantineDir, path.basename(finalDestination));
try {
fs.renameSync(finalDestination, quarantinePath);
} catch (error) {
fs.rmdirSync(quarantineDir);
throw error;
}
const quarantinedStat = fs.lstatSync(quarantinePath, { bigint: true });
if (!hasSameFileIdentity(expectedStat, quarantinedStat)) {
try {
fs.renameSync(quarantinePath, finalDestination);
fs.rmdirSync(quarantineDir);
} catch (_restoreError) {
throw new Error(
`Refusing to ${action}: managed destination changed before removal; replacement preserved at ${quarantinePath}.`
);
}
throw createChangedDestinationError(action);
}
if (quarantinedStat.isDirectory() && !options.recursive) {
fs.rmdirSync(quarantinePath);
} else {
fs.rmSync(quarantinePath, options);
}
fs.rmdirSync(quarantineDir);
return finalDestination;
}
function deepMergeJson(baseValue, patchValue) {
if (!isPlainObject(baseValue) || !isPlainObject(patchValue)) {
return cloneJsonValue(patchValue);
}
const merged = { ...baseValue };
for (const [key, value] of Object.entries(patchValue)) {
if (isPlainObject(value) && isPlainObject(merged[key])) {
merged[key] = deepMergeJson(merged[key], value);
} else {
merged[key] = cloneJsonValue(value);
}
}
return merged;
}
function jsonContainsSubset(actualValue, expectedValue) {
if (isPlainObject(expectedValue)) {
if (!isPlainObject(actualValue)) {
return false;
}
return Object.entries(expectedValue).every(([key, value]) => Object.prototype.hasOwnProperty.call(actualValue, key) && jsonContainsSubset(actualValue[key], value));
}
if (Array.isArray(expectedValue)) {
if (!Array.isArray(actualValue) || actualValue.length !== expectedValue.length) {
return false;
}
return expectedValue.every((item, index) => jsonContainsSubset(actualValue[index], item));
}
return actualValue === expectedValue;
}
const JSON_REMOVE_SENTINEL = Symbol('json-remove');
function deepRemoveJsonSubset(currentValue, managedValue) {
if (isPlainObject(managedValue)) {
if (!isPlainObject(currentValue)) {
return currentValue;
}
const nextValue = { ...currentValue };
for (const [key, value] of Object.entries(managedValue)) {
if (!Object.prototype.hasOwnProperty.call(nextValue, key)) {
continue;
}
if (isPlainObject(value)) {
const nestedValue = deepRemoveJsonSubset(nextValue[key], value);
if (nestedValue === JSON_REMOVE_SENTINEL) {
delete nextValue[key];
} else {
nextValue[key] = nestedValue;
}
continue;
}
if (Array.isArray(value)) {
if (Array.isArray(nextValue[key]) && jsonContainsSubset(nextValue[key], value)) {
delete nextValue[key];
}
continue;
}
if (nextValue[key] === value) {
delete nextValue[key];
}
}
return Object.keys(nextValue).length === 0 ? JSON_REMOVE_SENTINEL : nextValue;
}
if (Array.isArray(managedValue)) {
return jsonContainsSubset(currentValue, managedValue) ? JSON_REMOVE_SENTINEL : currentValue;
}
return currentValue === managedValue ? JSON_REMOVE_SENTINEL : currentValue;
}
function hydrateRecordedOperations(repoRoot, operations, trustedRoot) {
return operations.map(operation => {
if (operation.kind === 'update-claude-settings') {
@@ -2264,43 +1805,6 @@ function repairInstalledStates(options = {}) {
};
}
function cleanupEmptyParentDirs(filePath, stopAt) {
const trustedStopAt = assertWithinTrustedRoot(stopAt, stopAt, 'clean up');
const trustedFilePath = assertWithinTrustedRoot(filePath, trustedStopAt, 'clean up');
let currentPath = path.dirname(trustedFilePath);
while (currentPath) {
const relativePath = path.relative(trustedStopAt, currentPath);
const isContained = relativePath !== '..'
&& !relativePath.startsWith(`..${path.sep}`)
&& !path.isAbsolute(relativePath);
if (!isContained || relativePath === '') {
break;
}
let validatedPath = assertWithinTrustedRoot(currentPath, trustedStopAt, 'clean up');
if (!fs.existsSync(validatedPath)) {
currentPath = path.dirname(validatedPath);
continue;
}
validatedPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up');
const stat = fs.lstatSync(validatedPath);
if (!stat.isDirectory() || stat.isSymbolicLink()) {
break;
}
validatedPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up');
if (fs.readdirSync(validatedPath).length > 0) {
break;
}
const finalPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up');
const removedPath = removeContainedPath(finalPath, trustedStopAt, 'clean up');
if (!removedPath) break;
currentPath = path.dirname(removedPath);
}
}
function uninstallInstalledStates(options = {}) {
const records = discoverInstalledStates({
+370
View File
@@ -0,0 +1,370 @@
const fs = require('fs');
const path = require('path');
const { assertWithinTrustedRoot } = require('../path-safety');
const { mergeHooksMetadata, metadataPathFor } = require('../hooks-config');
const { assertClaudeSettingsPath } = require('./claude-settings');
function getManagedDestination(
destinationPath,
trustedRoot,
action,
{ allowFinalSymlink = false } = {}
) {
if (!destinationPath || typeof destinationPath !== 'string') {
throw new Error(`Refusing to ${action}: missing destination path.`);
}
const canonicalRoot = assertWithinTrustedRoot(trustedRoot, trustedRoot, action);
const resolvedDestination = path.resolve(destinationPath);
const canonicalParent = assertWithinTrustedRoot(
path.dirname(resolvedDestination),
canonicalRoot,
action
);
const managedPath = path.join(canonicalParent, path.basename(resolvedDestination));
let stat = null;
try {
stat = fs.lstatSync(managedPath);
} catch (error) {
if (!error || (error.code !== 'ENOENT' && error.code !== 'ENOTDIR')) {
throw error;
}
}
if (stat && stat.isSymbolicLink() && !allowFinalSymlink) {
const error = new Error(
`Refusing to ${action}: managed destination is a final symlink.`
);
error.code = 'ECC_FINAL_DESTINATION_SYMLINK';
throw error;
}
return {
canonicalRoot,
exists: stat !== null,
isFinalSymlink: Boolean(stat && stat.isSymbolicLink()),
managedPath
};
}
function ensureContainedParentDir(destinationPath, trustedRoot, action) {
const initialDestination = getManagedDestination(
destinationPath,
trustedRoot,
action
);
const { canonicalRoot, managedPath } = initialDestination;
const canonicalParent = path.dirname(managedPath);
const relativeParent = path.relative(canonicalRoot, canonicalParent);
const pathSegments = relativeParent
? relativeParent.split(path.sep).filter(Boolean)
: [];
let currentPath = canonicalRoot;
for (const segment of pathSegments) {
const validatedParent = assertWithinTrustedRoot(currentPath, canonicalRoot, action);
const nextPath = path.join(validatedParent, segment);
try {
fs.mkdirSync(nextPath);
} catch (error) {
if (!error || error.code !== 'EEXIST') {
throw error;
}
}
const validatedNext = assertWithinTrustedRoot(nextPath, canonicalRoot, action);
const nextStat = fs.lstatSync(validatedNext);
if (!nextStat.isDirectory() || nextStat.isSymbolicLink()) {
throw new Error(`Refusing to ${action}: destination parent is not a trusted directory.`);
}
currentPath = validatedNext;
}
return getManagedDestination(managedPath, canonicalRoot, action).managedPath;
}
function prepareContainedWriteDestination(destinationPath, trustedRoot, action) {
return ensureContainedParentDir(destinationPath, trustedRoot, action);
}
function getContainedExistingPath(
destinationPath,
trustedRoot,
action,
{ allowFinalSymlink = false } = {}
) {
const initialDestination = getManagedDestination(
destinationPath,
trustedRoot,
action,
{ allowFinalSymlink }
);
const followsToExistingPath = fs.existsSync(initialDestination.managedPath);
if (!followsToExistingPath && !initialDestination.isFinalSymlink) {
return null;
}
const finalDestination = getManagedDestination(
initialDestination.managedPath,
trustedRoot,
action,
{ allowFinalSymlink }
);
return finalDestination.exists ? finalDestination.managedPath : null;
}
function hasSameFileIdentity(leftStat, rightStat) {
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
}
function createChangedDestinationError(action) {
return new Error(
`Refusing to ${action}: managed destination changed during the write.`
);
}
function getStableParentStat(filePath, action) {
const parentStat = fs.lstatSync(path.dirname(filePath));
if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) {
throw createChangedDestinationError(action);
}
return parentStat;
}
function assertPinnedWriteDestination(
filePath,
fileDescriptor,
expectedParentStat,
trustedRoot,
action
) {
const liveDestination = getManagedDestination(filePath, trustedRoot, action);
if (path.resolve(liveDestination.managedPath) !== path.resolve(filePath)) {
throw createChangedDestinationError(action);
}
const liveParentStat = getStableParentStat(filePath, action);
if (!hasSameFileIdentity(expectedParentStat, liveParentStat)) {
throw createChangedDestinationError(action);
}
const descriptorStat = fs.fstatSync(fileDescriptor);
const livePathStat = fs.lstatSync(liveDestination.managedPath);
if (
!descriptorStat.isFile()
|| !livePathStat.isFile()
|| livePathStat.isSymbolicLink()
|| !hasSameFileIdentity(descriptorStat, livePathStat)
) {
throw createChangedDestinationError(action);
}
}
function writeFileNoFollow(filePath, content, mode, trustedRoot, action) {
const expectedParentStat = getStableParentStat(filePath, action);
const flags = fs.constants.O_WRONLY
| fs.constants.O_CREAT
| (fs.constants.O_NOFOLLOW || 0);
const fileDescriptor = fs.openSync(filePath, flags, mode);
try {
assertPinnedWriteDestination(
filePath,
fileDescriptor,
expectedParentStat,
trustedRoot,
action
);
fs.ftruncateSync(fileDescriptor, 0);
fs.writeFileSync(fileDescriptor, content);
if (mode !== undefined) {
fs.fchmodSync(fileDescriptor, mode);
}
} finally {
fs.closeSync(fileDescriptor);
}
}
function readFileWithMetadataNoFollow(filePath, encoding) {
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
const fileDescriptor = fs.openSync(filePath, flags);
try {
const stat = fs.fstatSync(fileDescriptor);
if (!stat.isFile()) {
throw new Error(`Refusing to read non-file path: ${filePath}`);
}
return {
content: fs.readFileSync(fileDescriptor, encoding),
mode: stat.mode,
};
} finally {
fs.closeSync(fileDescriptor);
}
}
function readFileNoFollow(filePath, encoding) {
return readFileWithMetadataNoFollow(filePath, encoding).content;
}
function readJsonNoFollow(filePath) {
return JSON.parse(readFileNoFollow(filePath, 'utf8'));
}
function readHooksConfigNoFollow(hooksPath) {
const hooksConfig = readJsonNoFollow(hooksPath);
const metadataPath = metadataPathFor(hooksPath);
if (!fs.existsSync(metadataPath)) {
return hooksConfig;
}
return mergeHooksMetadata(hooksConfig, readJsonNoFollow(metadataPath), hooksPath);
}
function assertClaudeSettingsDestination(operation, trustedRoot, target = null) {
if (target && target !== 'claude' && target !== 'claude-project') {
throw new Error('Refusing to manage Claude hooks for a non-Claude target.');
}
assertClaudeSettingsPath(operation.destinationPath, trustedRoot);
}
function writeContainedFile(destinationPath, content, trustedRoot, action, mode) {
const preparedDestination = prepareContainedWriteDestination(destinationPath, trustedRoot, action);
const finalDestination = getManagedDestination(
preparedDestination,
trustedRoot,
action
).managedPath;
writeFileNoFollow(
finalDestination,
content,
mode,
trustedRoot,
action
);
return finalDestination;
}
function copyContainedFile(sourcePath, destinationPath, trustedRoot, action) {
const source = readFileWithMetadataNoFollow(sourcePath);
return writeContainedFile(
destinationPath,
source.content,
trustedRoot,
action,
source.mode & 0o777
);
}
function removeContainedPath(destinationPath, trustedRoot, action, options = {}) {
const existingDestination = getContainedExistingPath(
destinationPath,
trustedRoot,
action,
{ allowFinalSymlink: true }
);
if (!existingDestination) {
return null;
}
const managedDestination = getManagedDestination(
existingDestination,
trustedRoot,
action,
{ allowFinalSymlink: true }
);
const finalDestination = managedDestination.managedPath;
const expectedStat = fs.lstatSync(finalDestination, { bigint: true });
const quarantineDir = fs.mkdtempSync(path.join(
path.dirname(managedDestination.canonicalRoot),
'.ecc-remove-'
));
const quarantinePath = path.join(quarantineDir, path.basename(finalDestination));
try {
fs.renameSync(finalDestination, quarantinePath);
} catch (error) {
fs.rmdirSync(quarantineDir);
throw error;
}
const quarantinedStat = fs.lstatSync(quarantinePath, { bigint: true });
if (!hasSameFileIdentity(expectedStat, quarantinedStat)) {
try {
fs.renameSync(quarantinePath, finalDestination);
fs.rmdirSync(quarantineDir);
} catch (_restoreError) {
throw new Error(
`Refusing to ${action}: managed destination changed before removal; replacement preserved at ${quarantinePath}.`
);
}
throw createChangedDestinationError(action);
}
if (quarantinedStat.isDirectory() && !options.recursive) {
fs.rmdirSync(quarantinePath);
} else {
fs.rmSync(quarantinePath, options);
}
fs.rmdirSync(quarantineDir);
return finalDestination;
}
function cleanupEmptyParentDirs(filePath, stopAt) {
const trustedStopAt = assertWithinTrustedRoot(stopAt, stopAt, 'clean up');
const trustedFilePath = assertWithinTrustedRoot(filePath, trustedStopAt, 'clean up');
let currentPath = path.dirname(trustedFilePath);
while (currentPath) {
const relativePath = path.relative(trustedStopAt, currentPath);
const isContained = relativePath !== '..'
&& !relativePath.startsWith(`..${path.sep}`)
&& !path.isAbsolute(relativePath);
if (!isContained || relativePath === '') {
break;
}
let validatedPath = assertWithinTrustedRoot(currentPath, trustedStopAt, 'clean up');
if (!fs.existsSync(validatedPath)) {
currentPath = path.dirname(validatedPath);
continue;
}
validatedPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up');
const stat = fs.lstatSync(validatedPath);
if (!stat.isDirectory() || stat.isSymbolicLink()) {
break;
}
validatedPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up');
if (fs.readdirSync(validatedPath).length > 0) {
break;
}
const finalPath = assertWithinTrustedRoot(validatedPath, trustedStopAt, 'clean up');
const removedPath = removeContainedPath(finalPath, trustedStopAt, 'clean up');
if (!removedPath) break;
currentPath = path.dirname(removedPath);
}
}
module.exports = {
assertClaudeSettingsDestination,
assertPinnedWriteDestination,
cleanupEmptyParentDirs,
copyContainedFile,
createChangedDestinationError,
ensureContainedParentDir,
getContainedExistingPath,
getManagedDestination,
getStableParentStat,
hasSameFileIdentity,
prepareContainedWriteDestination,
readFileNoFollow,
readFileWithMetadataNoFollow,
readHooksConfigNoFollow,
readJsonNoFollow,
removeContainedPath,
writeContainedFile,
writeFileNoFollow,
};
+180
View File
@@ -0,0 +1,180 @@
function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function cloneJsonValue(value) {
if (value === undefined) {
return undefined;
}
return JSON.parse(JSON.stringify(value));
}
function parseJsonLikeValue(value, label) {
if (value === undefined) {
return undefined;
}
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch (error) {
throw new Error(`Invalid ${label}: ${error.message}`);
}
}
if (value === null || Array.isArray(value) || isPlainObject(value) || typeof value === 'number' || typeof value === 'boolean') {
return cloneJsonValue(value);
}
throw new Error(`Invalid ${label}: expected JSON-compatible data`);
}
function getOperationTextContent(operation) {
const candidateKeys = ['renderedContent', 'content', 'managedContent', 'expectedContent', 'templateOutput'];
for (const key of candidateKeys) {
if (typeof operation[key] === 'string') {
return operation[key];
}
}
return null;
}
function getOperationJsonPayload(operation) {
const candidateKeys = ['mergePayload', 'managedPayload', 'payload', 'value', 'expectedValue'];
for (const key of candidateKeys) {
if (operation[key] !== undefined) {
return parseJsonLikeValue(operation[key], `${operation.kind}.${key}`);
}
}
return undefined;
}
function getOperationPreviousContent(operation) {
const candidateKeys = ['previousContent', 'originalContent', 'backupContent'];
for (const key of candidateKeys) {
if (typeof operation[key] === 'string') {
return operation[key];
}
}
return null;
}
function getOperationPreviousJson(operation) {
const candidateKeys = ['previousValue', 'previousJson', 'originalValue'];
for (const key of candidateKeys) {
if (operation[key] !== undefined) {
return parseJsonLikeValue(operation[key], `${operation.kind}.${key}`);
}
}
return undefined;
}
function formatJson(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
function deepMergeJson(baseValue, patchValue) {
if (!isPlainObject(baseValue) || !isPlainObject(patchValue)) {
return cloneJsonValue(patchValue);
}
const merged = { ...baseValue };
for (const [key, value] of Object.entries(patchValue)) {
if (isPlainObject(value) && isPlainObject(merged[key])) {
merged[key] = deepMergeJson(merged[key], value);
} else {
merged[key] = cloneJsonValue(value);
}
}
return merged;
}
function jsonContainsSubset(actualValue, expectedValue) {
if (isPlainObject(expectedValue)) {
if (!isPlainObject(actualValue)) {
return false;
}
return Object.entries(expectedValue).every(([key, value]) => Object.prototype.hasOwnProperty.call(actualValue, key) && jsonContainsSubset(actualValue[key], value));
}
if (Array.isArray(expectedValue)) {
if (!Array.isArray(actualValue) || actualValue.length !== expectedValue.length) {
return false;
}
return expectedValue.every((item, index) => jsonContainsSubset(actualValue[index], item));
}
return actualValue === expectedValue;
}
const JSON_REMOVE_SENTINEL = Symbol('json-remove');
function deepRemoveJsonSubset(currentValue, managedValue) {
if (isPlainObject(managedValue)) {
if (!isPlainObject(currentValue)) {
return currentValue;
}
const nextValue = { ...currentValue };
for (const [key, value] of Object.entries(managedValue)) {
if (!Object.prototype.hasOwnProperty.call(nextValue, key)) {
continue;
}
if (isPlainObject(value)) {
const nestedValue = deepRemoveJsonSubset(nextValue[key], value);
if (nestedValue === JSON_REMOVE_SENTINEL) {
delete nextValue[key];
} else {
nextValue[key] = nestedValue;
}
continue;
}
if (Array.isArray(value)) {
if (Array.isArray(nextValue[key]) && jsonContainsSubset(nextValue[key], value)) {
delete nextValue[key];
}
continue;
}
if (nextValue[key] === value) {
delete nextValue[key];
}
}
return Object.keys(nextValue).length === 0 ? JSON_REMOVE_SENTINEL : nextValue;
}
if (Array.isArray(managedValue)) {
return jsonContainsSubset(currentValue, managedValue) ? JSON_REMOVE_SENTINEL : currentValue;
}
return currentValue === managedValue ? JSON_REMOVE_SENTINEL : currentValue;
}
module.exports = {
JSON_REMOVE_SENTINEL,
cloneJsonValue,
deepMergeJson,
deepRemoveJsonSubset,
formatJson,
getOperationJsonPayload,
getOperationPreviousContent,
getOperationPreviousJson,
getOperationTextContent,
isPlainObject,
jsonContainsSubset,
parseJsonLikeValue,
};
+68 -23
View File
@@ -1,9 +1,4 @@
#!/usr/bin/env node
/**
* Run all tests
*
* Usage: node tests/run-all.js
*/
const { spawnSync } = require('child_process');
const path = require('path');
@@ -12,9 +7,13 @@ const fs = require('fs');
const testsDir = __dirname;
const repoRoot = path.resolve(testsDir, '..');
const TEST_GLOB = 'tests/**/*.test.js';
const filter = (process.argv[2] || '').split(path.sep).join('/');
function matchesTestGlob(relativePath) {
const normalized = relativePath.split(path.sep).join('/');
if (filter && !normalized.includes(filter)) {
return false;
}
if (typeof path.matchesGlob === 'function') {
return path.matchesGlob(normalized, TEST_GLOB);
}
@@ -58,9 +57,38 @@ function annotateFailure(displayPath, reason, output) {
console.log(`::error file=${escapeAnnotation(`tests/${displayPath}`, true)}::${escapeAnnotation(message)}`);
}
function parseCounts(combined) {
const passedMatch = combined.match(/Passed:\s*(\d+)/);
const failedMatch = combined.match(/Failed:\s*(\d+)/);
if (passedMatch || failedMatch) {
return {
passed: passedMatch ? parseInt(passedMatch[1], 10) : 0,
failed: failedMatch ? parseInt(failedMatch[1], 10) : 0,
definite: true,
};
}
const lines = combined.split(/\r?\n/);
let tapPassed = 0;
let tapFailed = 0;
let tapSeen = false;
for (const line of lines) {
if (/^ok\b/.test(line.trim())) {
tapSeen = true;
tapPassed += 1;
} else if (/^not ok\b/.test(line.trim())) {
tapSeen = true;
tapFailed += 1;
}
}
if (tapSeen) {
return { passed: tapPassed, failed: tapFailed, definite: true };
}
return { passed: 0, failed: 0, definite: false };
}
const testFiles = discoverTestFiles();
const BOX_W = 58; // inner width between ║ delimiters
const BOX_W = 58;
const boxLine = s => `║${s.padEnd(BOX_W)}║`;
console.log('╔' + '═'.repeat(BOX_W) + '╗');
@@ -69,13 +97,15 @@ console.log('╚' + '═'.repeat(BOX_W) + '╝');
console.log();
if (testFiles.length === 0) {
console.log(`✗ No test files matched ${TEST_GLOB}`);
console.log(`✗ No test files matched ${TEST_GLOB}${filter ? ` with filter "${filter}"` : ''}`);
process.exit(1);
}
let totalPassed = 0;
let totalFailed = 0;
let totalTests = 0;
let filesPassed = 0;
let filesFailed = 0;
const failedFiles = [];
for (const testFile of testFiles) {
const testPath = path.join(testsDir, testFile);
@@ -88,10 +118,6 @@ for (const testFile of testFiles) {
console.log(`\n━━━ Running ${displayPath} ━━━`);
// Run each test hermetically: strip inherited git env vars. When the suite
// runs inside a git hook (e.g. pre-push), git sets GIT_DIR/GIT_WORK_TREE,
// which would hijack `git -C <dir>` calls in tests that exercise real git
// and make them operate on the host repo instead of their own fixtures.
const childEnv = { ...process.env };
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_PREFIX']) {
delete childEnv[key];
@@ -106,19 +132,12 @@ for (const testFile of testFiles) {
const stdout = result.stdout || '';
const stderr = result.stderr || '';
// Show both stdout and stderr so hook warnings are visible
if (stdout) console.log(stdout);
if (stderr) console.log(stderr);
// Parse results from combined output
const combined = `${stdout}\n${stderr}`;
const passedMatch = combined.match(/Passed:\s*(\d+)/);
const failedMatch = combined.match(/Failed:\s*(\d+)/);
if (passedMatch) totalPassed += parseInt(passedMatch[1], 10);
const reportedFailures = failedMatch ? parseInt(failedMatch[1], 10) : 0;
const counts = parseCounts(combined);
const processFailed = Boolean(result.error) || result.status !== 0;
totalFailed += processFailed ? Math.max(reportedFailures, 1) : reportedFailures;
let failureReason;
if (result.error) {
@@ -127,17 +146,32 @@ for (const testFile of testFiles) {
failureReason = result.signal
? `terminated by signal ${result.signal}`
: `exited with status ${result.status}`;
} else if (reportedFailures > 0) {
failureReason = `reported ${reportedFailures} failed tests`;
} else if (counts.definite && counts.failed > 0) {
failureReason = `reported ${counts.failed} failed tests`;
}
if (failureReason) {
filesFailed += 1;
failedFiles.push(displayPath);
totalPassed += counts.passed;
totalFailed += counts.definite ? counts.failed : Math.max(counts.failed, 1);
if (processFailed && !counts.definite) {
totalFailed += 1;
}
console.log(`✗ ${displayPath} ${failureReason}`);
annotateFailure(displayPath, failureReason, combined);
} else {
filesPassed += 1;
if (counts.definite) {
totalPassed += counts.passed;
totalFailed += counts.failed;
} else {
totalPassed += 1;
}
}
}
totalTests = totalPassed + totalFailed;
const totalTests = totalPassed + totalFailed;
console.log('\n╔' + '═'.repeat(BOX_W) + '╗');
console.log(boxLine(' Final Results'));
@@ -145,6 +179,17 @@ console.log('╠' + '═'.repeat(BOX_W) + '╣');
console.log(boxLine(` Total Tests: ${String(totalTests).padStart(4)}`));
console.log(boxLine(` Passed: ${String(totalPassed).padStart(4)} ✓`));
console.log(boxLine(` Failed: ${String(totalFailed).padStart(4)} ${totalFailed > 0 ? '✗' : ' '}`));
console.log(boxLine(` Files: ${filesPassed} passed, ${filesFailed} failed`));
if (failedFiles.length > 0) {
console.log('╠' + '═'.repeat(BOX_W) + '╣');
for (const name of failedFiles.slice(0, 10)) {
console.log(boxLine(` ✗ ${name}`.slice(0, BOX_W)));
}
if (failedFiles.length > 10) {
console.log(boxLine(` ... and ${failedFiles.length - 10} more`));
}
}
console.log('╚' + '═'.repeat(BOX_W) + '╝');
console.log(`\nPassed: ${totalPassed}, Failed: ${totalFailed}`);
process.exit(totalFailed > 0 ? 1 : 0);