mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-20 15:01:46 +02:00
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:
+7
-1
@@ -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
@@ -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,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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user