567 lines
19 KiB
JavaScript
567 lines
19 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import {
|
|
lstatSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
realpathSync,
|
|
} from 'node:fs';
|
|
import { dirname, isAbsolute, join, posix, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
const BASELINE_PATH = join(SCRIPT_DIR, 'oss-drift-baseline.json');
|
|
const MAPPING = Object.freeze({
|
|
canonical: 'packages/hive-mind-core/src',
|
|
oss: 'packages/core/src',
|
|
ignoredDirectories: ['dist', 'node_modules'],
|
|
ignoredFileSuffixes: ['.test.ts', '.tsbuildinfo'],
|
|
});
|
|
const FORBIDDEN_EXPORTS = Object.freeze({
|
|
paths: [
|
|
'mind/evolution-runs.ts',
|
|
'mind/execution-traces.ts',
|
|
'mind/improvement-signals.ts',
|
|
],
|
|
pathPrefixes: ['vault.ts', 'compliance/'],
|
|
markers: [
|
|
{ path: 'mind/db.ts', token: 'install_audit' },
|
|
{ path: 'mind/schema.ts', token: 'install_audit' },
|
|
],
|
|
});
|
|
const VALID_ADAPTATION_KINDS = new Set(['branding', 'import', 'layout', 'logger']);
|
|
const VALID_STATES = new Set(['different', 'only-canonical', 'only-oss']);
|
|
const VALID_DISPOSITIONS = new Set([
|
|
'forward-port',
|
|
'product-curation',
|
|
'reconcile',
|
|
'reverse-port',
|
|
]);
|
|
const HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
|
|
class ConfigurationError extends Error {}
|
|
|
|
function compareOrdinal(left, right) {
|
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
}
|
|
|
|
function assertRecord(value, label) {
|
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new ConfigurationError(`${label} must be an object`);
|
|
}
|
|
}
|
|
|
|
function assertExactKeys(value, expected, label) {
|
|
assertRecord(value, label);
|
|
const actual = Object.keys(value).sort(compareOrdinal);
|
|
const wanted = [...expected].sort(compareOrdinal);
|
|
if (JSON.stringify(actual) !== JSON.stringify(wanted)) {
|
|
throw new ConfigurationError(
|
|
`${label} has unexpected keys: expected ${wanted.join(', ')}, got ${actual.join(', ')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertExactArray(actual, expected, label) {
|
|
if (!Array.isArray(actual) || JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
throw new ConfigurationError(`${label} does not match the hardcoded publication policy`);
|
|
}
|
|
}
|
|
|
|
function assertCanonicalPath(value, label) {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
value.length === 0 ||
|
|
value.includes('\\') ||
|
|
isAbsolute(value) ||
|
|
value.startsWith('/') ||
|
|
value.split('/').some((segment) => segment === '' || segment === '.' || segment === '..') ||
|
|
posix.normalize(value) !== value
|
|
) {
|
|
throw new ConfigurationError(`${label} must be a canonical repository-relative path`);
|
|
}
|
|
}
|
|
|
|
function assertSortedUnique(values, label, pathSelector = (value) => value) {
|
|
if (!Array.isArray(values)) {
|
|
throw new ConfigurationError(`${label} must be an array`);
|
|
}
|
|
const paths = values.map(pathSelector);
|
|
const expected = [...new Set(paths)].sort(compareOrdinal);
|
|
if (JSON.stringify(paths) !== JSON.stringify(expected)) {
|
|
throw new ConfigurationError(`${label} must be sorted by path and contain no duplicates`);
|
|
}
|
|
}
|
|
|
|
function loadBaseline() {
|
|
let baseline;
|
|
try {
|
|
baseline = JSON.parse(readFileSync(BASELINE_PATH, 'utf-8'));
|
|
} catch (error) {
|
|
throw new ConfigurationError(`cannot read baseline ${BASELINE_PATH}: ${error.message}`);
|
|
}
|
|
|
|
assertExactKeys(
|
|
baseline,
|
|
[
|
|
'schemaVersion',
|
|
'mapping',
|
|
'parityPaths',
|
|
'intentionalAdaptations',
|
|
'knownReviewedBlockers',
|
|
'unreviewedDifferences',
|
|
'forbiddenExports',
|
|
],
|
|
'baseline',
|
|
);
|
|
if (baseline.schemaVersion !== 1) {
|
|
throw new ConfigurationError('baseline.schemaVersion must be 1');
|
|
}
|
|
|
|
assertExactKeys(
|
|
baseline.mapping,
|
|
['canonical', 'oss', 'ignoredDirectories', 'ignoredFileSuffixes'],
|
|
'baseline.mapping',
|
|
);
|
|
for (const key of Object.keys(MAPPING)) {
|
|
const expected = MAPPING[key];
|
|
if (Array.isArray(expected)) {
|
|
assertExactArray(baseline.mapping[key], expected, `baseline.mapping.${key}`);
|
|
} else if (baseline.mapping[key] !== expected) {
|
|
throw new ConfigurationError(`baseline.mapping.${key} does not match the hardcoded mapping`);
|
|
}
|
|
}
|
|
|
|
assertExactKeys(
|
|
baseline.forbiddenExports,
|
|
['paths', 'pathPrefixes', 'markers'],
|
|
'baseline.forbiddenExports',
|
|
);
|
|
assertExactArray(
|
|
baseline.forbiddenExports.paths,
|
|
FORBIDDEN_EXPORTS.paths,
|
|
'baseline.forbiddenExports.paths',
|
|
);
|
|
assertExactArray(
|
|
baseline.forbiddenExports.pathPrefixes,
|
|
FORBIDDEN_EXPORTS.pathPrefixes,
|
|
'baseline.forbiddenExports.pathPrefixes',
|
|
);
|
|
assertExactArray(
|
|
baseline.forbiddenExports.markers,
|
|
FORBIDDEN_EXPORTS.markers,
|
|
'baseline.forbiddenExports.markers',
|
|
);
|
|
|
|
assertSortedUnique(baseline.parityPaths, 'baseline.parityPaths', (entry) => entry?.path);
|
|
for (const [index, entry] of baseline.parityPaths.entries()) {
|
|
const label = `baseline.parityPaths[${index}]`;
|
|
assertExactKeys(entry, ['path', 'sha256'], label);
|
|
assertCanonicalPath(entry.path, `${label}.path`);
|
|
if (!HASH_PATTERN.test(entry.sha256)) {
|
|
throw new ConfigurationError(`${label}.sha256 must be a lowercase SHA-256 hash`);
|
|
}
|
|
}
|
|
|
|
assertSortedUnique(
|
|
baseline.intentionalAdaptations,
|
|
'baseline.intentionalAdaptations',
|
|
(entry) => entry?.path,
|
|
);
|
|
for (const [index, entry] of baseline.intentionalAdaptations.entries()) {
|
|
const label = `baseline.intentionalAdaptations[${index}]`;
|
|
assertExactKeys(entry, ['path', 'kinds', 'canonicalSha256', 'ossSha256'], label);
|
|
assertCanonicalPath(entry.path, `${label}.path`);
|
|
assertSortedUnique(entry.kinds, `${label}.kinds`);
|
|
if (entry.kinds.length === 0 || entry.kinds.some((kind) => !VALID_ADAPTATION_KINDS.has(kind))) {
|
|
throw new ConfigurationError(`${label}.kinds contains an unsupported adaptation kind`);
|
|
}
|
|
if (!HASH_PATTERN.test(entry.canonicalSha256) || !HASH_PATTERN.test(entry.ossSha256)) {
|
|
throw new ConfigurationError(`${label} must contain lowercase SHA-256 hashes`);
|
|
}
|
|
if (entry.canonicalSha256 === entry.ossSha256) {
|
|
throw new ConfigurationError(`${label} must describe an actual adaptation, not parity`);
|
|
}
|
|
}
|
|
|
|
assertSortedUnique(
|
|
baseline.knownReviewedBlockers,
|
|
'baseline.knownReviewedBlockers',
|
|
(entry) => entry?.path,
|
|
);
|
|
for (const [index, entry] of baseline.knownReviewedBlockers.entries()) {
|
|
const label = `baseline.knownReviewedBlockers[${index}]`;
|
|
assertExactKeys(entry, ['path', 'state', 'disposition'], label);
|
|
assertCanonicalPath(entry.path, `${label}.path`);
|
|
if (!VALID_STATES.has(entry.state)) {
|
|
throw new ConfigurationError(`${label}.state is unsupported`);
|
|
}
|
|
if (!VALID_DISPOSITIONS.has(entry.disposition)) {
|
|
throw new ConfigurationError(`${label}.disposition is unsupported`);
|
|
}
|
|
}
|
|
|
|
assertSortedUnique(
|
|
baseline.unreviewedDifferences,
|
|
'baseline.unreviewedDifferences',
|
|
(entry) => entry?.path,
|
|
);
|
|
for (const [index, entry] of baseline.unreviewedDifferences.entries()) {
|
|
const label = `baseline.unreviewedDifferences[${index}]`;
|
|
assertExactKeys(entry, ['path', 'state'], label);
|
|
assertCanonicalPath(entry.path, `${label}.path`);
|
|
if (!VALID_STATES.has(entry.state)) {
|
|
throw new ConfigurationError(`${label}.state is unsupported`);
|
|
}
|
|
}
|
|
|
|
const classified = [
|
|
...baseline.parityPaths.map((entry) => entry.path),
|
|
...baseline.intentionalAdaptations.map((entry) => entry.path),
|
|
...baseline.knownReviewedBlockers.map((entry) => entry.path),
|
|
...baseline.unreviewedDifferences.map((entry) => entry.path),
|
|
];
|
|
const duplicate = classified.find((path, index) => classified.indexOf(path) !== index);
|
|
if (duplicate) {
|
|
throw new ConfigurationError(`baseline classifies ${duplicate} more than once`);
|
|
}
|
|
const privateCategory = classified.find((path) => isPrivateExclusion(path));
|
|
if (privateCategory) {
|
|
throw new ConfigurationError(`baseline must not reclassify private exclusion ${privateCategory}`);
|
|
}
|
|
|
|
return baseline;
|
|
}
|
|
|
|
function resolveGitRoot(candidate, label) {
|
|
let resolved;
|
|
try {
|
|
resolved = realpathSync(candidate);
|
|
const root = execFileSync('git', ['-C', resolved, 'rev-parse', '--show-toplevel'], {
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
}).trim();
|
|
return realpathSync(root);
|
|
} catch (error) {
|
|
throw new ConfigurationError(`${label} is not a readable Git worktree: ${candidate}`);
|
|
}
|
|
}
|
|
|
|
function readUtf8Normalized(filePath) {
|
|
let text;
|
|
try {
|
|
text = decoder.decode(readFileSync(filePath));
|
|
} catch (error) {
|
|
throw new ConfigurationError(`cannot read UTF-8 source file ${filePath}: ${error.message}`);
|
|
}
|
|
return text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
|
|
}
|
|
|
|
function inventory(root, label) {
|
|
let rootStat;
|
|
try {
|
|
rootStat = lstatSync(root);
|
|
} catch {
|
|
throw new ConfigurationError(`${label} mapped source directory is missing: ${root}`);
|
|
}
|
|
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
throw new ConfigurationError(`${label} mapped source must be a real directory: ${root}`);
|
|
}
|
|
|
|
const files = new Map();
|
|
const casing = new Map();
|
|
const walk = (directory, prefix = '') => {
|
|
let entries;
|
|
try {
|
|
entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) =>
|
|
compareOrdinal(left.name, right.name),
|
|
);
|
|
} catch (error) {
|
|
throw new ConfigurationError(`cannot inventory ${label} directory ${directory}: ${error.message}`);
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
const absolutePath = join(directory, entry.name);
|
|
const stat = lstatSync(absolutePath);
|
|
if (stat.isSymbolicLink()) {
|
|
throw new ConfigurationError(`${label} mapped inventory contains symlink: ${relativePath}`);
|
|
}
|
|
if (stat.isDirectory()) {
|
|
if (!MAPPING.ignoredDirectories.includes(entry.name)) {
|
|
walk(absolutePath, relativePath);
|
|
}
|
|
continue;
|
|
}
|
|
if (!stat.isFile()) {
|
|
throw new ConfigurationError(`${label} mapped inventory contains special node: ${relativePath}`);
|
|
}
|
|
if (MAPPING.ignoredFileSuffixes.some((suffix) => relativePath.endsWith(suffix))) {
|
|
continue;
|
|
}
|
|
|
|
const folded = relativePath.toLowerCase();
|
|
const previous = casing.get(folded);
|
|
if (previous && previous !== relativePath) {
|
|
throw new ConfigurationError(
|
|
`${label} mapped inventory has case collision: ${previous} / ${relativePath}`,
|
|
);
|
|
}
|
|
casing.set(folded, relativePath);
|
|
const text = readUtf8Normalized(absolutePath);
|
|
files.set(relativePath, {
|
|
hash: createHash('sha256').update(text, 'utf-8').digest('hex'),
|
|
text,
|
|
});
|
|
}
|
|
};
|
|
|
|
walk(root);
|
|
if (files.size === 0) {
|
|
throw new ConfigurationError(`${label} mapped inventory is empty: ${root}`);
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function resolveMappedRoot(repositoryRoot, relativePath, label) {
|
|
let current = repositoryRoot;
|
|
for (const segment of relativePath.split('/')) {
|
|
current = join(current, segment);
|
|
let stat;
|
|
try {
|
|
stat = lstatSync(current);
|
|
} catch {
|
|
throw new ConfigurationError(`${label} mapped path component is missing: ${current}`);
|
|
}
|
|
if (stat.isSymbolicLink()) {
|
|
throw new ConfigurationError(`${label} mapped path contains ancestor symlink: ${current}`);
|
|
}
|
|
if (!stat.isDirectory()) {
|
|
throw new ConfigurationError(`${label} mapped path component is not a directory: ${current}`);
|
|
}
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function isPrivateExclusion(path) {
|
|
if (FORBIDDEN_EXPORTS.paths.includes(path)) return true;
|
|
return FORBIDDEN_EXPORTS.pathPrefixes.some((prefix) =>
|
|
prefix.endsWith('/') ? path.startsWith(prefix) : path === prefix || path.startsWith(`${prefix}/`),
|
|
);
|
|
}
|
|
|
|
function observedState(canonical, oss) {
|
|
if (canonical && oss) return canonical.hash === oss.hash ? 'equal' : 'different';
|
|
if (canonical) return 'only-canonical';
|
|
if (oss) return 'only-oss';
|
|
return 'absent';
|
|
}
|
|
|
|
function hasForbiddenMarker(text, token) {
|
|
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
return new RegExp(`\\b${escaped}\\b`, 'i').test(text);
|
|
}
|
|
|
|
function gitReceipt(root, scopedPaths) {
|
|
const head = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], {
|
|
encoding: 'utf-8',
|
|
windowsHide: true,
|
|
}).trim();
|
|
const status = execFileSync(
|
|
'git',
|
|
['-C', root, 'status', '--porcelain=v1', '--untracked-files=all'],
|
|
{ encoding: 'utf-8', windowsHide: true },
|
|
).trim();
|
|
const scopedStatus = execFileSync(
|
|
'git',
|
|
['-C', root, 'status', '--porcelain=v1', '--untracked-files=all', '--', ...scopedPaths],
|
|
{ encoding: 'utf-8', windowsHide: true },
|
|
).trim();
|
|
return { head, dirty: status.length > 0, scopedStatus };
|
|
}
|
|
|
|
function showSection(title, entries) {
|
|
console.log(`${title}: ${entries.length}`);
|
|
for (const entry of [...entries].sort(compareOrdinal)) {
|
|
console.log(` ${entry}`);
|
|
}
|
|
}
|
|
|
|
function run() {
|
|
if (process.argv.length > 3) {
|
|
throw new ConfigurationError('usage: node scripts/oss-drift-check.mjs [path-to-oss-checkout]');
|
|
}
|
|
const baseline = loadBaseline();
|
|
const canonicalRoot = resolveGitRoot(join(SCRIPT_DIR, '..'), 'canonical repository');
|
|
const requestedOss =
|
|
process.argv[2] ??
|
|
(process.env.OSS_HIVE_MIND_DIR?.trim() || resolve(canonicalRoot, '..', 'hive-mind'));
|
|
const ossRoot = resolveGitRoot(resolve(process.cwd(), requestedOss), 'OSS repository');
|
|
if (canonicalRoot.toLowerCase() === ossRoot.toLowerCase()) {
|
|
throw new ConfigurationError('canonical and OSS repositories must be different Git worktrees');
|
|
}
|
|
|
|
const canonicalFiles = inventory(
|
|
resolveMappedRoot(canonicalRoot, MAPPING.canonical, 'canonical'),
|
|
'canonical',
|
|
);
|
|
const ossFiles = inventory(resolveMappedRoot(ossRoot, MAPPING.oss, 'OSS'), 'OSS');
|
|
const crossRepoCasing = new Map();
|
|
for (const path of [...canonicalFiles.keys(), ...ossFiles.keys()]) {
|
|
const folded = path.toLowerCase();
|
|
const previous = crossRepoCasing.get(folded);
|
|
if (previous && previous !== path) {
|
|
throw new ConfigurationError(`mapped inventories have case collision: ${previous} / ${path}`);
|
|
}
|
|
crossRepoCasing.set(folded, path);
|
|
}
|
|
|
|
const parity = new Map(baseline.parityPaths.map((entry) => [entry.path, entry]));
|
|
const adaptations = new Map(baseline.intentionalAdaptations.map((entry) => [entry.path, entry]));
|
|
const blockers = new Map(baseline.knownReviewedBlockers.map((entry) => [entry.path, entry]));
|
|
const knownUnreviewed = new Map(
|
|
baseline.unreviewedDifferences.map((entry) => [entry.path, entry]),
|
|
);
|
|
const reviewedAdaptations = [];
|
|
const intentionalExclusions = [];
|
|
const knownBlockers = [];
|
|
const unreviewed = [];
|
|
const forbidden = [];
|
|
const observedPaths = new Set();
|
|
const union = [...new Set([...canonicalFiles.keys(), ...ossFiles.keys()])].sort(compareOrdinal);
|
|
|
|
for (const path of union) {
|
|
observedPaths.add(path);
|
|
const canonical = canonicalFiles.get(path);
|
|
const oss = ossFiles.get(path);
|
|
const state = observedState(canonical, oss);
|
|
if (isPrivateExclusion(path)) {
|
|
if (oss) {
|
|
forbidden.push(`FORBIDDEN-OSS-CONTENT ${path}`);
|
|
} else if (canonical) {
|
|
intentionalExclusions.push(path);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const parityEntry = parity.get(path);
|
|
if (parityEntry) {
|
|
if (
|
|
state === 'equal' &&
|
|
canonical.hash === parityEntry.sha256 &&
|
|
oss.hash === parityEntry.sha256
|
|
) {
|
|
continue;
|
|
}
|
|
unreviewed.push(`BASELINE-DRIFT ${path} expected equal, observed ${state}`);
|
|
continue;
|
|
}
|
|
|
|
const adaptation = adaptations.get(path);
|
|
if (adaptation) {
|
|
if (
|
|
state === 'different' &&
|
|
canonical.hash === adaptation.canonicalSha256 &&
|
|
oss.hash === adaptation.ossSha256
|
|
) {
|
|
reviewedAdaptations.push(`${path} [${adaptation.kinds.join(',')}]`);
|
|
} else {
|
|
unreviewed.push(`BASELINE-DRIFT ${path} reviewed adaptation bytes changed (${state})`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const blocker = blockers.get(path);
|
|
if (blocker) {
|
|
if (state === blocker.state) {
|
|
knownBlockers.push(`${path} (${state}; ${blocker.disposition})`);
|
|
} else {
|
|
unreviewed.push(
|
|
`BASELINE-DRIFT ${path} expected ${blocker.state}, observed ${state}`,
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const unreviewedEntry = knownUnreviewed.get(path);
|
|
if (unreviewedEntry) {
|
|
if (state === unreviewedEntry.state) {
|
|
unreviewed.push(`${path} (${state})`);
|
|
} else {
|
|
unreviewed.push(
|
|
`BASELINE-DRIFT ${path} expected ${unreviewedEntry.state}, observed ${state}`,
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
unreviewed.push(`NEW ${path} (${state})`);
|
|
}
|
|
|
|
for (const path of [
|
|
...parity.keys(),
|
|
...adaptations.keys(),
|
|
...blockers.keys(),
|
|
...knownUnreviewed.keys(),
|
|
]) {
|
|
if (!observedPaths.has(path)) {
|
|
unreviewed.push(`STALE-BASELINE ${path} (absent)`);
|
|
}
|
|
}
|
|
|
|
for (const marker of FORBIDDEN_EXPORTS.markers) {
|
|
const file = ossFiles.get(marker.path);
|
|
if (!file) continue;
|
|
if (hasForbiddenMarker(file.text, marker.token)) {
|
|
forbidden.push(`FORBIDDEN-OSS-MARKER ${marker.path}:${marker.token}`);
|
|
}
|
|
}
|
|
|
|
const canonicalReceipt = gitReceipt(canonicalRoot, [
|
|
MAPPING.canonical,
|
|
'scripts/oss-drift-check.mjs',
|
|
'scripts/oss-drift-baseline.json',
|
|
'scripts/oss-drift-check.sh',
|
|
]);
|
|
const ossReceipt = gitReceipt(ossRoot, [MAPPING.oss]);
|
|
if (canonicalReceipt.scopedStatus) {
|
|
unreviewed.push('SCOPED-DIRTY canonical checker, baseline, wrapper, or mapped source');
|
|
}
|
|
if (ossReceipt.scopedStatus) {
|
|
unreviewed.push('SCOPED-DIRTY OSS mapped source');
|
|
}
|
|
console.log(
|
|
`CANONICAL ${canonicalReceipt.head} ${canonicalReceipt.dirty ? 'dirty' : 'clean'} ${canonicalRoot}`,
|
|
);
|
|
console.log(`OSS ${ossReceipt.head} ${ossReceipt.dirty ? 'dirty' : 'clean'} ${ossRoot}`);
|
|
console.log(`PARITY: ${parity.size}`);
|
|
showSection('REVIEWED ADAPTATIONS', reviewedAdaptations);
|
|
showSection('INTENTIONAL OSS EXCLUSIONS', intentionalExclusions);
|
|
showSection('KNOWN REVIEWED BLOCKERS', knownBlockers);
|
|
showSection('UNREVIEWED DIFFERENCES', unreviewed);
|
|
showSection('FORBIDDEN EXPORTS', forbidden);
|
|
|
|
const blocked = knownBlockers.length || unreviewed.length || forbidden.length;
|
|
if (blocked) {
|
|
console.log(
|
|
'[oss-drift-check] release blocked: reconcile in the canonical monorepo, then use a curated forward-port; never publish a raw split.',
|
|
);
|
|
}
|
|
return blocked ? 1 : 0;
|
|
}
|
|
|
|
try {
|
|
process.exitCode = run();
|
|
} catch (error) {
|
|
if (error instanceof ConfigurationError) {
|
|
console.error(`[oss-drift-check] CONFIGURATION ERROR: ${error.message}`);
|
|
} else {
|
|
console.error(`[oss-drift-check] UNEXPECTED ERROR: ${error?.stack ?? error}`);
|
|
}
|
|
process.exitCode = 2;
|
|
}
|