diff --git a/.aiox-core/core/errors/aiox-error.js b/.aiox-core/core/errors/aiox-error.js new file mode 100644 index 0000000000..4923b51ed0 --- /dev/null +++ b/.aiox-core/core/errors/aiox-error.js @@ -0,0 +1,134 @@ +const { + DEFAULT_ERROR_CODE, +} = require('./constants'); +const { defaultErrorRegistry } = require('./error-registry'); +const { serializeError } = require('./serializer'); +const { deepMerge, hasOwn, isPlainObject, normalizeErrorCode } = require('./utils'); + +class AIOXError extends Error { + constructor(message, options = {}) { + const code = normalizeErrorCode(options.code) || DEFAULT_ERROR_CODE; + const registry = options.registry || defaultErrorRegistry; + const definition = registry.lookup(code); + const finalMessage = message || options.message || definition.userMessage || code; + + if (hasOwn(options, 'cause')) { + super(finalMessage, { cause: options.cause }); + } else { + super(finalMessage); + } + + this.name = 'AIOXError'; + this.code = code; + this.category = options.category || definition.category; + this.severity = options.severity || definition.severity; + this.retryable = hasOwn(options, 'retryable') ? Boolean(options.retryable) : Boolean(definition.retryable); + this.userMessage = options.userMessage || definition.userMessage; + this.recovery = Array.isArray(options.recovery) ? [...options.recovery] : [...(definition.recovery || [])]; + this.metadata = deepMerge(definition.metadata || {}, options.metadata || {}); + this.isAIOXError = true; + + if (hasOwn(options, 'exitCode')) { + this.exitCode = options.exitCode; + } else if (hasOwn(definition, 'exitCode')) { + this.exitCode = definition.exitCode; + } + + if (hasOwn(options, 'cause')) { + this.cause = options.cause; + } + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, AIOXError); + } + } + + toJSON(options = {}) { + return serializeError(this, options); + } +} + +function isAIOXError(value) { + return value instanceof AIOXError || Boolean(value && value.isAIOXError === true); +} + +function collectErrorOwnProperties(error) { + if (!(error instanceof Error)) { + return {}; + } + + return Object.getOwnPropertyNames(error).reduce((properties, key) => { + if (['name', 'message', 'stack', 'cause'].includes(key)) { + return properties; + } + + properties[key] = error[key]; + return properties; + }, {}); +} + +function normalizeError(error, overrides = {}) { + if (isAIOXError(error)) { + if (!overrides || Object.keys(overrides).length === 0) { + return error; + } + + return new AIOXError(overrides.message || error.message, { + code: overrides.code || error.code, + category: overrides.category || error.category, + severity: overrides.severity || error.severity, + retryable: hasOwn(overrides, 'retryable') ? overrides.retryable : error.retryable, + exitCode: hasOwn(overrides, 'exitCode') ? overrides.exitCode : error.exitCode, + userMessage: overrides.userMessage || error.userMessage, + recovery: overrides.recovery || error.recovery, + metadata: deepMerge(error.metadata || {}, overrides.metadata || {}), + cause: hasOwn(overrides, 'cause') ? overrides.cause : error.cause, + registry: overrides.registry, + }); + } + + if (error instanceof Error) { + const ownProperties = collectErrorOwnProperties(error); + const metadata = deepMerge( + { + originalError: { + name: error.name || 'Error', + }, + }, + Object.keys(ownProperties).length > 0 ? { originalError: { properties: ownProperties } } : {}, + isPlainObject(overrides.metadata) ? overrides.metadata : {}, + ); + + return new AIOXError(overrides.message || error.message, { + code: overrides.code || error.code || DEFAULT_ERROR_CODE, + category: overrides.category, + severity: overrides.severity, + retryable: overrides.retryable, + exitCode: overrides.exitCode, + userMessage: overrides.userMessage, + recovery: overrides.recovery, + metadata, + cause: hasOwn(overrides, 'cause') ? overrides.cause : error, + registry: overrides.registry, + }); + } + + return new AIOXError(overrides.message || String(error), { + code: overrides.code || DEFAULT_ERROR_CODE, + category: overrides.category, + severity: overrides.severity, + retryable: overrides.retryable, + exitCode: overrides.exitCode, + userMessage: overrides.userMessage, + recovery: overrides.recovery, + metadata: deepMerge({ originalValue: { type: typeof error } }, overrides.metadata || {}), + cause: overrides.cause, + registry: overrides.registry, + }); +} + +module.exports = { + AIOXError, + isAIOXError, + normalizeError, +}; diff --git a/.aiox-core/core/errors/constants.js b/.aiox-core/core/errors/constants.js new file mode 100644 index 0000000000..312d662318 --- /dev/null +++ b/.aiox-core/core/errors/constants.js @@ -0,0 +1,137 @@ +const ErrorCategory = Object.freeze({ + CONFIGURATION: 'configuration', + VALIDATION: 'validation', + FILESYSTEM: 'filesystem', + NETWORK: 'network', + REGISTRY: 'registry', + ORCHESTRATION: 'orchestration', + SYNAPSE: 'synapse', + EXECUTION: 'execution', + PERMISSION: 'permission', + EXTERNAL_EXECUTOR: 'external_executor', + UNKNOWN: 'unknown', +}); + +const ErrorSeverity = Object.freeze({ + CRITICAL: 'critical', + ERROR: 'error', + WARNING: 'warning', + INFO: 'info', +}); + +const DEFAULT_ERROR_CODE = 'AIOX_UNKNOWN_ERROR'; + +const CORE_ERROR_DEFINITIONS = Object.freeze([ + { + code: DEFAULT_ERROR_CODE, + category: ErrorCategory.UNKNOWN, + severity: ErrorSeverity.ERROR, + retryable: false, + userMessage: 'An unexpected AIOX core error occurred.', + recovery: ['Review the error metadata and retry if the operation is safe to repeat.'], + }, + { + code: 'AIOX_CONFIGURATION_INVALID', + category: ErrorCategory.CONFIGURATION, + severity: ErrorSeverity.ERROR, + retryable: false, + userMessage: 'AIOX configuration is invalid.', + recovery: ['Validate the active AIOX configuration and retry.'], + }, + { + code: 'AIOX_VALIDATION_FAILED', + category: ErrorCategory.VALIDATION, + severity: ErrorSeverity.ERROR, + retryable: false, + userMessage: 'AIOX validation failed.', + recovery: ['Review validation errors and correct the invalid input.'], + }, + { + code: 'AIOX_FILESYSTEM_ERROR', + category: ErrorCategory.FILESYSTEM, + severity: ErrorSeverity.ERROR, + retryable: true, + userMessage: 'A filesystem operation failed.', + recovery: ['Check path existence, permissions, and disk availability.'], + }, + { + code: 'AIOX_PERMISSION_DENIED', + category: ErrorCategory.PERMISSION, + severity: ErrorSeverity.ERROR, + retryable: false, + exitCode: 13, + userMessage: 'The operation does not have the required permissions.', + recovery: ['Grant the required permission or run the command in an authorized context.'], + }, + { + code: 'AIOX_NETWORK_ERROR', + category: ErrorCategory.NETWORK, + severity: ErrorSeverity.ERROR, + retryable: true, + userMessage: 'A network operation failed.', + recovery: ['Check connectivity and retry the operation.'], + }, + { + code: 'AIOX_REGISTRY_LOAD_FAILED', + category: ErrorCategory.REGISTRY, + severity: ErrorSeverity.ERROR, + retryable: false, + userMessage: 'AIOX could not load a registry.', + recovery: ['Validate registry file syntax and path configuration.'], + }, + { + code: 'AIOX_REGISTRY_WRITE_FAILED', + category: ErrorCategory.REGISTRY, + severity: ErrorSeverity.ERROR, + retryable: true, + userMessage: 'AIOX could not write a registry.', + recovery: ['Check registry path permissions and retry.'], + }, + { + code: 'AIOX_ORCHESTRATION_FAILED', + category: ErrorCategory.ORCHESTRATION, + severity: ErrorSeverity.ERROR, + retryable: false, + userMessage: 'AIOX orchestration failed.', + recovery: ['Review orchestration metadata and the active workflow state.'], + }, + { + code: 'AIOX_SYNAPSE_LAYER_FAILED', + category: ErrorCategory.SYNAPSE, + severity: ErrorSeverity.WARNING, + retryable: true, + userMessage: 'A Synapse layer failed while processing context.', + recovery: ['Review layer metadata and continue with graceful degradation when possible.'], + }, + { + code: 'AIOX_EXECUTION_FAILED', + category: ErrorCategory.EXECUTION, + severity: ErrorSeverity.ERROR, + retryable: false, + userMessage: 'AIOX execution failed.', + recovery: ['Review execution logs and retry after correcting the failure cause.'], + }, + { + code: 'AIOX_EXTERNAL_EXECUTOR_FAILED', + category: ErrorCategory.EXTERNAL_EXECUTOR, + severity: ErrorSeverity.ERROR, + retryable: true, + userMessage: 'An external executor failed.', + recovery: ['Review external executor logs and retry if the command is idempotent.'], + }, + { + code: 'AIOX_PERSISTENCE_DEGRADED', + category: ErrorCategory.FILESYSTEM, + severity: ErrorSeverity.WARNING, + retryable: true, + userMessage: 'AIOX persistence degraded and continued in memory.', + recovery: ['Check persistence path permissions and available disk space.'], + }, +]); + +module.exports = { + ErrorCategory, + ErrorSeverity, + DEFAULT_ERROR_CODE, + CORE_ERROR_DEFINITIONS, +}; diff --git a/.aiox-core/core/errors/error-registry.js b/.aiox-core/core/errors/error-registry.js new file mode 100644 index 0000000000..0a6d8ac537 --- /dev/null +++ b/.aiox-core/core/errors/error-registry.js @@ -0,0 +1,164 @@ +const { + ErrorCategory, + ErrorSeverity, + DEFAULT_ERROR_CODE, + CORE_ERROR_DEFINITIONS, +} = require('./constants'); +const { + cloneMetadataValue, + deepMerge, + hasOwn, + isPlainObject, + normalizeErrorCode, + normalizeRecovery, +} = require('./utils'); + +const VALID_CATEGORIES = new Set(Object.values(ErrorCategory)); +const VALID_SEVERITIES = new Set(Object.values(ErrorSeverity)); + +function freezeDefinition(definition) { + return Object.freeze({ + ...definition, + metadata: Object.freeze(cloneMetadataValue(definition.metadata || {})), + recovery: Object.freeze([...(definition.recovery || [])]), + }); +} + +function createUnknownDefinition(code = DEFAULT_ERROR_CODE) { + return freezeDefinition({ + code, + category: ErrorCategory.UNKNOWN, + severity: ErrorSeverity.ERROR, + retryable: false, + userMessage: 'An unexpected AIOX core error occurred.', + recovery: ['Review the error metadata and retry if the operation is safe to repeat.'], + metadata: code === DEFAULT_ERROR_CODE ? {} : { registry: { registered: false } }, + }); +} + +class ErrorRegistry { + constructor(definitions = CORE_ERROR_DEFINITIONS) { + this._entries = new Map(); + this.registerMany(definitions); + + if (!this.has(DEFAULT_ERROR_CODE)) { + this.register(createUnknownDefinition()); + } + } + + registerMany(definitions) { + if (Array.isArray(definitions)) { + definitions.forEach((definition) => this.register(definition)); + return this; + } + + if (isPlainObject(definitions)) { + Object.values(definitions).forEach((definition) => this.register(definition)); + return this; + } + + throw new TypeError('ErrorRegistry definitions must be an array or object'); + } + + register(definition) { + const normalized = this._normalizeDefinition(definition); + + if (this._entries.has(normalized.code)) { + throw new Error(`Duplicate AIOX error code: ${normalized.code}`); + } + + this._entries.set(normalized.code, freezeDefinition(normalized)); + return this; + } + + lookup(code) { + const normalizedCode = normalizeErrorCode(code) || DEFAULT_ERROR_CODE; + const found = this._entries.get(normalizedCode); + + if (found) { + return found; + } + + const fallback = this._entries.get(DEFAULT_ERROR_CODE) || createUnknownDefinition(); + return freezeDefinition({ + ...fallback, + code: normalizedCode, + metadata: deepMerge(fallback.metadata, { registry: { registered: false } }), + }); + } + + has(code) { + const normalizedCode = normalizeErrorCode(code); + return Boolean(normalizedCode && this._entries.has(normalizedCode)); + } + + list() { + return Array.from(this._entries.values()).sort((left, right) => left.code.localeCompare(right.code)); + } + + get size() { + return this._entries.size; + } + + assertUnique() { + const codes = this.list().map((definition) => definition.code); + const unique = new Set(codes); + + if (codes.length !== unique.size) { + throw new Error('ErrorRegistry contains duplicate error codes'); + } + + return true; + } + + _normalizeDefinition(definition) { + if (!isPlainObject(definition)) { + throw new TypeError('Error definition must be a plain object'); + } + + const code = normalizeErrorCode(definition.code); + if (!code) { + throw new Error('Error definition requires a non-empty code'); + } + + if (!/^[A-Z0-9_]+$/.test(code)) { + throw new Error(`Invalid AIOX error code: ${code}`); + } + + const category = definition.category || ErrorCategory.UNKNOWN; + if (!VALID_CATEGORIES.has(category)) { + throw new Error(`Invalid error category for ${code}: ${category}`); + } + + const severity = definition.severity || ErrorSeverity.ERROR; + if (!VALID_SEVERITIES.has(severity)) { + throw new Error(`Invalid error severity for ${code}: ${severity}`); + } + + const normalized = { + code, + category, + severity, + retryable: Boolean(definition.retryable), + userMessage: definition.userMessage || definition.message || code, + recovery: normalizeRecovery(definition.recovery), + metadata: isPlainObject(definition.metadata) ? cloneMetadataValue(definition.metadata) : {}, + }; + + if (hasOwn(definition, 'exitCode') && definition.exitCode !== undefined) { + if (!Number.isInteger(definition.exitCode) || definition.exitCode < 0) { + throw new Error(`Invalid exitCode for ${code}: ${definition.exitCode}`); + } + normalized.exitCode = definition.exitCode; + } + + return normalized; + } +} + +const defaultErrorRegistry = new ErrorRegistry(CORE_ERROR_DEFINITIONS); + +module.exports = { + ErrorRegistry, + defaultErrorRegistry, +}; diff --git a/.aiox-core/core/errors/index.js b/.aiox-core/core/errors/index.js new file mode 100644 index 0000000000..117e3dff0f --- /dev/null +++ b/.aiox-core/core/errors/index.js @@ -0,0 +1,28 @@ +const { + ErrorCategory, + ErrorSeverity, + DEFAULT_ERROR_CODE, + CORE_ERROR_DEFINITIONS, +} = require('./constants'); +const { ErrorRegistry, defaultErrorRegistry } = require('./error-registry'); +const { AIOXError, isAIOXError, normalizeError } = require('./aiox-error'); +const { shouldExposeErrorStack, sanitizeValue, serializeError } = require('./serializer'); +const { deepMerge, isPlainObject, normalizeErrorCode } = require('./utils'); + +module.exports = { + AIOXError, + ErrorRegistry, + ErrorCategory, + ErrorSeverity, + DEFAULT_ERROR_CODE, + CORE_ERROR_DEFINITIONS, + defaultErrorRegistry, + isAIOXError, + normalizeError, + serializeError, + sanitizeValue, + shouldExposeErrorStack, + deepMerge, + isPlainObject, + normalizeErrorCode, +}; diff --git a/.aiox-core/core/errors/serializer.js b/.aiox-core/core/errors/serializer.js new file mode 100644 index 0000000000..58a48d1fe5 --- /dev/null +++ b/.aiox-core/core/errors/serializer.js @@ -0,0 +1,170 @@ +const { DEFAULT_ERROR_CODE } = require('./constants'); +const { hasOwn } = require('./utils'); + +function shouldExposeErrorStack(options = {}) { + if (options.includeStack === true) { + return true; + } + + if (options.includeStack === false) { + return false; + } + + const stackFlag = process.env.DEBUG_ERROR_STACKS || process.env.DEBUG_STACKS || ''; + return ['1', 'true', 'yes', 'on'].includes(String(stackFlag).toLowerCase()); +} + +function sanitizeValue(value, seen = new WeakSet(), options = {}) { + if (value === undefined || value === null) { + return value; + } + + const valueType = typeof value; + + if (valueType === 'bigint') { + return value.toString(); + } + + if (valueType === 'function' || valueType === 'symbol') { + return String(value); + } + + if (valueType !== 'object') { + return value; + } + + if (seen.has(value)) { + return '[Circular]'; + } + + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? value.toString() : value.toISOString(); + } + + if (value instanceof RegExp) { + return value.toString(); + } + + if (value instanceof Error) { + return serializeError(value, options, seen); + } + + seen.add(value); + + try { + if (value instanceof Map) { + return Array.from(value.entries()).map(([key, entryValue]) => [ + sanitizeValue(key, seen, options), + sanitizeValue(entryValue, seen, options), + ]); + } + + if (value instanceof Set) { + return Array.from(value.values()).map((entryValue) => sanitizeValue(entryValue, seen, options)); + } + + if (Array.isArray(value)) { + return value.map((entryValue) => sanitizeValue(entryValue, seen, options)); + } + + return Object.keys(value).reduce((safeValue, key) => { + try { + safeValue[key] = sanitizeValue(value[key], seen, options); + } catch (error) { + safeValue[key] = `[Unserializable: ${error.message}]`; + } + return safeValue; + }, {}); + } catch (error) { + return `[Unserializable: ${error.message}]`; + } finally { + seen.delete(value); + } +} + +function serializeError(error, options = {}, seen = new WeakSet()) { + if (!(error instanceof Error)) { + return sanitizeValue(error, seen, options); + } + + if (seen.has(error)) { + return '[Circular]'; + } + + seen.add(error); + + try { + const serialized = { + name: error.name || 'Error', + message: error.message || '', + stack: shouldExposeErrorStack(options) ? error.stack : '[redacted]', + }; + + if (error.code) { + serialized.code = error.code; + } + + if (error.isAIOXError || error.name === 'AIOXError') { + serialized.code = error.code || DEFAULT_ERROR_CODE; + serialized.category = error.category; + serialized.severity = error.severity; + serialized.retryable = Boolean(error.retryable); + + if (hasOwn(error, 'exitCode')) { + serialized.exitCode = error.exitCode; + } + + if (error.userMessage) { + serialized.userMessage = error.userMessage; + } + + if (Array.isArray(error.recovery)) { + serialized.recovery = [...error.recovery]; + } + + serialized.metadata = sanitizeValue(error.metadata || {}, seen, options); + } + + if (error.cause !== undefined) { + serialized.cause = error.cause instanceof Error + ? serializeError(error.cause, options, seen) + : sanitizeValue(error.cause, seen, options); + } + + for (const key of Object.getOwnPropertyNames(error)) { + if ([ + 'name', + 'message', + 'stack', + 'code', + 'category', + 'severity', + 'retryable', + 'exitCode', + 'metadata', + 'cause', + 'userMessage', + 'recovery', + 'isAIOXError', + ].includes(key)) { + continue; + } + + try { + serialized[key] = sanitizeValue(error[key], seen, options); + } catch (serializationError) { + serialized[key] = `[Unserializable: ${serializationError.message}]`; + } + } + + return serialized; + } finally { + seen.delete(error); + } +} + +module.exports = { + shouldExposeErrorStack, + sanitizeValue, + serializeError, +}; diff --git a/.aiox-core/core/errors/utils.js b/.aiox-core/core/errors/utils.js new file mode 100644 index 0000000000..9d9f99e907 --- /dev/null +++ b/.aiox-core/core/errors/utils.js @@ -0,0 +1,95 @@ +function isPlainObject(value) { + if (value === null || typeof value !== 'object') { + return false; + } + + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function cloneMetadataValue(value, seen = new WeakSet()) { + if (Array.isArray(value)) { + if (seen.has(value)) { + return '[Circular]'; + } + + seen.add(value); + try { + return value.map((entry) => cloneMetadataValue(entry, seen)); + } finally { + seen.delete(value); + } + } + + if (isPlainObject(value)) { + if (seen.has(value)) { + return '[Circular]'; + } + + seen.add(value); + try { + return Object.keys(value).reduce((clone, key) => { + clone[key] = cloneMetadataValue(value[key], seen); + return clone; + }, {}); + } finally { + seen.delete(value); + } + } + + return value; +} + +function deepMerge(...sources) { + return sources.reduce((merged, source) => { + if (!isPlainObject(source)) { + return merged; + } + + const sourceSeen = new WeakSet(); + sourceSeen.add(source); + + for (const key of Object.keys(source)) { + const current = merged[key]; + const next = source[key]; + + if (isPlainObject(current) && isPlainObject(next)) { + merged[key] = deepMerge(current, next); + } else { + merged[key] = cloneMetadataValue(next, sourceSeen); + } + } + + return merged; + }, {}); +} + +function normalizeErrorCode(code) { + if (typeof code !== 'string') { + return null; + } + + const normalized = code.trim().toUpperCase(); + return normalized.length > 0 ? normalized : null; +} + +function normalizeRecovery(value) { + if (!Array.isArray(value)) { + return []; + } + + return value.filter((entry) => typeof entry === 'string' && entry.trim().length > 0); +} + +function hasOwn(value, key) { + return Object.prototype.hasOwnProperty.call(value, key); +} + +module.exports = { + isPlainObject, + cloneMetadataValue, + deepMerge, + normalizeErrorCode, + normalizeRecovery, + hasOwn, +}; diff --git a/.aiox-core/core/ids/registry-updater.js b/.aiox-core/core/ids/registry-updater.js index 7c63ca1324..ff1132358f 100644 --- a/.aiox-core/core/ids/registry-updater.js +++ b/.aiox-core/core/ids/registry-updater.js @@ -12,6 +12,7 @@ const { extractPurpose, detectDependencies, computeChecksum, + resolveEntityId, resolveUsedBy, SCAN_CONFIG, ADAPTABILITY_DEFAULTS, @@ -320,20 +321,20 @@ class RegistryUpdater { throw err; } - const entityId = extractEntityId(absPath); const category = config.category; if (!registry.entities[category]) { registry.entities[category] = {}; } + const resolvedEntityId = resolveEntityId(absPath, config, registry.entities[category], this._repoRoot); const keywords = extractKeywords(absPath, content); const purpose = extractPurpose(content, absPath); - const dependencies = detectDependencies(content, entityId); + const dependencies = detectDependencies(content, resolvedEntityId); const checksum = computeChecksum(absPath); const defaultScore = ADAPTABILITY_DEFAULTS[config.type] || 0.5; - registry.entities[category][entityId] = { + registry.entities[category][resolvedEntityId] = { path: relPath, layer: classifyLayer(relPath), type: config.type, @@ -352,7 +353,7 @@ class RegistryUpdater { // NOG-8: Enrich with code intelligence data (advisory, never blocks registration) this._pendingEnrichments = this._pendingEnrichments || []; - this._pendingEnrichments.push({ entityId, category, relPath }); + this._pendingEnrichments.push({ entityId: resolvedEntityId, category, relPath }); return true; } @@ -362,8 +363,8 @@ class RegistryUpdater { const config = this._detectCategory(relPath); if (!config) return false; - const entityId = extractEntityId(absPath); const category = config.category; + const entityId = resolveEntityId(absPath, config, registry.entities[category] || {}, this._repoRoot); const existing = registry.entities[category]?.[entityId]; if (!existing) { diff --git a/.aiox-core/data/entity-registry.yaml b/.aiox-core/data/entity-registry.yaml index 8a1a49b130..058a6ba2bf 100644 --- a/.aiox-core/data/entity-registry.yaml +++ b/.aiox-core/data/entity-registry.yaml @@ -1,7 +1,7 @@ metadata: version: 1.0.0 - lastUpdated: '2026-05-08T06:26:24.449Z' - entityCount: 746 + lastUpdated: '2026-05-08T10:11:41.100Z' + entityCount: 752 checksumAlgorithm: sha256 resolutionRate: 100 entities: @@ -7453,8 +7453,8 @@ entities: score: 0.7 constraints: [] extensionPoints: [] - checksum: sha256:3021b425f334c63d6462e64f6316f8b4036bd77b0b49ae419f20695fe3d1a89d - lastVerified: '2026-03-11T00:48:55.862Z' + checksum: sha256:31c29851e506246818f17564e7e67d82c1cc52e6108b40cd740e0415a96cdc77 + lastVerified: '2026-05-08T10:11:41.097Z' refactoring-suggester: path: .aiox-core/development/scripts/refactoring-suggester.js layer: L2 @@ -9181,8 +9181,8 @@ entities: score: 0.4 constraints: [] extensionPoints: [] - checksum: sha256:4873507324efab32ba59e1ade70aaa79f318f9c5d689bdb747d8721effba38d1 - lastVerified: '2026-03-11T00:48:55.880Z' + checksum: sha256:ed794ec39dc294567814693033dccf43669b177500c215fb70d6c3fc5b691527 + lastVerified: '2026-05-08T10:11:41.097Z' verification-gate: path: .aiox-core/core/ids/verification-gate.js layer: L1 @@ -12796,6 +12796,124 @@ entities: extensionPoints: [] checksum: sha256:8246c4c3997ddff6fcf51555fb840bbd4fcac3c294e5ed5fa2c8585a42d91029 lastVerified: '2026-05-07T07:53:00.000Z' + aiox-error: + path: .aiox-core/core/errors/aiox-error.js + layer: L1 + type: module + purpose: Entity at .aiox-core/core/errors/aiox-error.js + keywords: + - aiox + - error + usedBy: + - errors-index + dependencies: + - constants + - error-registry + - serializer + - utils + adaptability: + score: 0.4 + constraints: [] + extensionPoints: [] + checksum: sha256:6734641df389f921f1d139c129ec007d15b3a08114a15fe9faf792ed1036d0c8 + lastVerified: '2026-05-08T10:11:41.092Z' + constants: + path: .aiox-core/core/errors/constants.js + layer: L1 + type: module + purpose: Entity at .aiox-core/core/errors/constants.js + keywords: + - constants + usedBy: + - aiox-error + - error-registry + - errors-index + - serializer + dependencies: [] + adaptability: + score: 0.4 + constraints: [] + extensionPoints: [] + checksum: sha256:9f6fa0b1a9b8538d321674ac43628f42452ce2ac770d0433c1ab84b55b1e56dd + lastVerified: '2026-05-08T10:11:41.094Z' + error-registry: + path: .aiox-core/core/errors/error-registry.js + layer: L1 + type: module + purpose: Entity at .aiox-core/core/errors/error-registry.js + keywords: + - error + - registry + usedBy: + - aiox-error + - errors-index + dependencies: + - constants + - utils + adaptability: + score: 0.4 + constraints: [] + extensionPoints: [] + checksum: sha256:7ba8dffa725860a285618593b8ed47b05c4fa488fdedf1282c2e214881c370bc + lastVerified: '2026-05-08T10:11:41.094Z' + errors-index: + path: .aiox-core/core/errors/index.js + layer: L1 + type: module + purpose: Entity at .aiox-core/core/errors/index.js + keywords: + - index + usedBy: [] + dependencies: + - constants + - error-registry + - aiox-error + - serializer + - utils + adaptability: + score: 0.4 + constraints: [] + extensionPoints: [] + checksum: sha256:462d0aed6f57f2e4b9d51bcb20467451473c927e0d182b5c3ad43f352f44122c + lastVerified: '2026-05-08T10:11:41.095Z' + serializer: + path: .aiox-core/core/errors/serializer.js + layer: L1 + type: module + purpose: Entity at .aiox-core/core/errors/serializer.js + keywords: + - serializer + usedBy: + - aiox-error + - errors-index + dependencies: + - constants + - utils + adaptability: + score: 0.4 + constraints: [] + extensionPoints: [] + checksum: sha256:7144adaf3a245afd732aef16e2cdb61f08246badb6b90ac1a7f4b73705ff3ce8 + lastVerified: '2026-05-08T10:11:41.096Z' + utils: + path: .aiox-core/core/errors/utils.js + layer: L1 + type: module + purpose: Entity at .aiox-core/core/errors/utils.js + keywords: + - utils + usedBy: + - aiox-error + - error-registry + - errors-index + - serializer + dependencies: [] + adaptability: + score: 0.4 + constraints: [] + extensionPoints: [] + checksum: sha256:24b8ff80e98efc2b562843262490b7ac537ee77565715b644f212652192ced62 + lastVerified: '2026-05-08T10:11:41.096Z' agents: aiox-master: path: .aiox-core/development/agents/aiox-master.md diff --git a/.aiox-core/development/scripts/populate-entity-registry.js b/.aiox-core/development/scripts/populate-entity-registry.js index f08546470c..6ddc7b1b06 100644 --- a/.aiox-core/development/scripts/populate-entity-registry.js +++ b/.aiox-core/development/scripts/populate-entity-registry.js @@ -25,7 +25,7 @@ const SCAN_CONFIG = [ { category: 'infra-scripts', basePath: '.aiox-core/infrastructure/scripts', glob: '**/*.js', type: 'script' }, { category: 'infra-tools', basePath: '.aiox-core/infrastructure/tools', glob: '**/*.{yaml,yml,md}', type: 'tool' }, { category: 'product-checklists', basePath: '.aiox-core/product/checklists', glob: '**/*.md', type: 'checklist' }, - { category: 'product-data', basePath: '.aiox-core/product/data', glob: '**/*.{yaml,yml,md}', type: 'data' } + { category: 'product-data', basePath: '.aiox-core/product/data', glob: '**/*.{yaml,yml,md}', type: 'data' }, ]; const ADAPTABILITY_DEFAULTS = { @@ -38,14 +38,14 @@ const ADAPTABILITY_DEFAULTS = { task: 0.8, workflow: 0.4, util: 0.6, - tool: 0.7 + tool: 0.7, }; const EXTERNAL_TOOLS = new Set([ 'coderabbit', 'git', 'github-cli', 'docker', 'supabase', 'browser', 'ffmpeg', 'n8n', 'context7', 'playwright', 'apify', 'clickup', 'jira', 'slack', 'exa', 'eslint', 'jest', 'npm', 'node', - 'docker-gateway', 'desktop-commander', 'railway' + 'docker-gateway', 'desktop-commander', 'railway', ]); const DEPRECATED_PATTERNS = [/^old[-_]/, /^backup[-_]/, /deprecated/i, /^legacy[-_]/]; @@ -76,6 +76,41 @@ function extractEntityId(filePath) { return path.basename(filePath, path.extname(filePath)); } +function toScopedEntityId(filePath, config, repoRoot = REPO_ROOT) { + const baseRoot = path.resolve(repoRoot, config.basePath); + const relativeToBase = path.relative(baseRoot, filePath).replace(/\\/g, '/'); + const withoutExt = relativeToBase.replace(/\.[^.]+$/, ''); + const scopedId = withoutExt + .split('/') + .filter(Boolean) + .join('-') + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, ''); + + return scopedId || extractEntityId(filePath); +} + +function resolveEntityId(filePath, config, existingEntities = {}, repoRoot = REPO_ROOT) { + const baseId = extractEntityId(filePath); + const relPath = path.relative(repoRoot, filePath).replace(/\\/g, '/'); + const existing = existingEntities[baseId]; + + if (!existing || existing.path === relPath) { + return baseId; + } + + const scopedId = toScopedEntityId(filePath, config, repoRoot); + let candidate = scopedId; + let suffix = 2; + + while (existingEntities[candidate] && existingEntities[candidate].path !== relPath) { + candidate = `${scopedId}-${suffix}`; + suffix += 1; + } + + return candidate; +} + function extractKeywords(filePath, content) { const name = path.basename(filePath, path.extname(filePath)); const parts = name.split(/[-_.]/g).filter((p) => p.length > 1); @@ -132,7 +167,7 @@ const YAML_DEP_FIELDS = { const KNOWN_AGENTS = [ 'dev', 'qa', 'pm', 'po', 'sm', 'architect', 'devops', - 'analyst', 'data-engineer', 'ux-design-expert', 'aiox-master' + 'analyst', 'data-engineer', 'ux-design-expert', 'aiox-master', ]; // Pattern A: YAML dependency block items (- name.md) @@ -321,16 +356,9 @@ function scanCategory(config, verbose = false) { const files = fg.sync(globPattern, { onlyFiles: true, absolute: true }); const entities = {}; - const seenIds = new Set(); for (const filePath of files) { - const entityId = extractEntityId(filePath); - - if (seenIds.has(entityId)) { - console.warn(`[IDS] Duplicate entity ID "${entityId}" at ${path.relative(REPO_ROOT, filePath)} — skipping`); - continue; - } - seenIds.add(entityId); + const entityId = resolveEntityId(filePath, config, entities); let content = ''; try { @@ -396,10 +424,10 @@ function scanCategory(config, verbose = false) { adaptability: { score: defaultScore, constraints: [], - extensionPoints: [] + extensionPoints: [], }, checksum, - lastVerified: new Date().toISOString() + lastVerified: new Date().toISOString(), }; if (lifecycleOverride) { @@ -457,7 +485,7 @@ function resolveUsedBy(allEntities) { } // Build reverse references - for (const [category, entities] of Object.entries(allEntities)) { + for (const entities of Object.values(allEntities)) { for (const [entityId, entity] of Object.entries(entities)) { for (const depRef of entity.dependencies) { const target = nameIndex.get(depRef); @@ -580,7 +608,7 @@ function populate(options = {}) { const categories = SCAN_CONFIG.map((c) => ({ id: c.category, description: getCategoryDescription(c.category), - basePath: c.basePath + basePath: c.basePath, })); const registry = { @@ -589,16 +617,16 @@ function populate(options = {}) { lastUpdated: new Date().toISOString(), entityCount: totalCount, checksumAlgorithm: 'sha256', - resolutionRate: rate + resolutionRate: rate, }, entities: allEntities, - categories + categories, }; const yamlContent = yaml.dump(registry, { lineWidth: 120, noRefs: true, - sortKeys: false + sortKeys: false, }); try { @@ -627,14 +655,14 @@ function getCategoryDescription(category) { 'infra-scripts': 'Infrastructure automation and utility scripts', 'infra-tools': 'Infrastructure tool definitions and configurations', 'product-checklists': 'Product validation and review checklists', - 'product-data': 'Product reference data and configuration files' + 'product-data': 'Product reference data and configuration files', }; return descriptions[category] || category; } if (require.main === module) { try { - const registry = populate(); + populate(); console.log('[IDS] Population complete.'); process.exit(0); } catch (err) { @@ -647,6 +675,8 @@ module.exports = { populate, scanCategory, extractEntityId, + toScopedEntityId, + resolveEntityId, extractKeywords, extractPurpose, detectDependencies, @@ -669,5 +699,5 @@ module.exports = { EXTERNAL_TOOLS, DEPRECATED_PATTERNS, REPO_ROOT, - REGISTRY_PATH + REGISTRY_PATH, }; diff --git a/.aiox-core/install-manifest.yaml b/.aiox-core/install-manifest.yaml index 6221488749..73ac8136be 100644 --- a/.aiox-core/install-manifest.yaml +++ b/.aiox-core/install-manifest.yaml @@ -8,9 +8,9 @@ # - File types for categorization # version: 5.1.15 -generated_at: "2026-05-08T06:49:15.800Z" +generated_at: "2026-05-08T10:11:41.844Z" generator: scripts/generate-install-manifest.js -file_count: 1111 +file_count: 1117 files: - path: cli/commands/config/index.js hash: sha256:25c4b9bf4e0241abf7754b55153f49f1a214f1fb5fe904a576675634cb7b3da9 @@ -388,6 +388,30 @@ files: hash: sha256:722d9b28d2485e3b5b89af6ea136e6d3907b805b9870f6e07e943d0264dc7fff type: elicitation size: 10935 + - path: core/errors/aiox-error.js + hash: sha256:6734641df389f921f1d139c129ec007d15b3a08114a15fe9faf792ed1036d0c8 + type: core + size: 4487 + - path: core/errors/constants.js + hash: sha256:9f6fa0b1a9b8538d321674ac43628f42452ce2ac770d0433c1ab84b55b1e56dd + type: core + size: 4305 + - path: core/errors/error-registry.js + hash: sha256:7ba8dffa725860a285618593b8ed47b05c4fa488fdedf1282c2e214881c370bc + type: core + size: 4643 + - path: core/errors/index.js + hash: sha256:462d0aed6f57f2e4b9d51bcb20467451473c927e0d182b5c3ad43f352f44122c + type: core + size: 744 + - path: core/errors/serializer.js + hash: sha256:7144adaf3a245afd732aef16e2cdb61f08246badb6b90ac1a7f4b73705ff3ce8 + type: core + size: 4093 + - path: core/errors/utils.js + hash: sha256:24b8ff80e98efc2b562843262490b7ac537ee77565715b644f212652192ced62 + type: core + size: 2014 - path: core/events/dashboard-emitter.js hash: sha256:6c694e4fc5765d6c396b25617cc34e3447068af780fdbb202b060a31cd357549 type: core @@ -753,9 +777,9 @@ files: type: core size: 8096 - path: core/ids/registry-updater.js - hash: sha256:194ecce0795fbf8654a7c69799252765d738d50274dd406f575d796c21c3b754 + hash: sha256:ed794ec39dc294567814693033dccf43669b177500c215fb70d6c3fc5b691527 type: core - size: 24513 + size: 24686 - path: core/ids/verification-gate.js hash: sha256:96050661c90fa52bfc755911d02c9194ec35c00e71fc6bbc92a13686dd53bb91 type: core @@ -1241,9 +1265,9 @@ files: type: data size: 9590 - path: data/entity-registry.yaml - hash: sha256:1c8e010516647898377715d160234094bc262b0812cf17c96d7d1d4d36c7b4e8 + hash: sha256:749b0ef51090a7787772dc9b07e604e6ff3403e32d1d59cf04a2229d68801543 type: data - size: 522368 + size: 525678 - path: data/learned-patterns.yaml hash: sha256:24ac0b160615583a0ff783d3da8af80b7f94191575d6db2054ec8e10a3f945dc type: data @@ -1609,9 +1633,9 @@ files: type: script size: 23410 - path: development/scripts/populate-entity-registry.js - hash: sha256:3021b425f334c63d6462e64f6316f8b4036bd77b0b49ae419f20695fe3d1a89d + hash: sha256:31c29851e506246818f17564e7e67d82c1cc52e6108b40cd740e0415a96cdc77 type: script - size: 22558 + size: 23451 - path: development/scripts/refactoring-suggester.js hash: sha256:d50ea6b609c9cf8385979386fee4b4385d11ebcde15460260f66d04c705f6cd9 type: script diff --git a/docs/architecture/adr/ADR-ERROR-GOVERNANCE-CONTRACT.md b/docs/architecture/adr/ADR-ERROR-GOVERNANCE-CONTRACT.md new file mode 100644 index 0000000000..3318e319ea --- /dev/null +++ b/docs/architecture/adr/ADR-ERROR-GOVERNANCE-CONTRACT.md @@ -0,0 +1,73 @@ +# ADR: Error Governance Contract + +## Status + +Accepted for initial implementation. + +## Context + +Issue #701 formalizes the remaining error governance work from #621. The +runtime robustness slices already landed, but `main` did not have a canonical +`AIOXError` or `ErrorRegistry` surface. + +Existing behavior must be preserved: + +- Installer errors already have user-safe formatting in `bin/utils/install-errors.js`. +- Build attempt logs already sanitize JSON-unsafe values and redact stacks by default. +- Synapse layer failures already flow through metrics without interrupting hooks. +- Hook paths intentionally preserve silent failure policies in several contexts. + +## Decision + +Introduce `.aiox-core/core/errors/` as the canonical core error-governance +surface. + +The initial contract contains: + +- `AIOXError`: an `Error` subclass with stable `code`, `category`, `severity`, + `retryable`, optional `exitCode`, structured `metadata`, and preserved `cause`. +- `ErrorRegistry`: a static taxonomy registry for known error codes. It is not + a runtime event log. +- Serialization helpers that convert typed and generic errors into JSON-safe + structures, preserve own error properties, tolerate circular references, and + redact stack traces unless debug stack flags are explicitly enabled. +- Normalization helpers that wrap generic errors without breaking generic + `Error` compatibility. + +## Categories + +The initial categories are: + +- `configuration` +- `validation` +- `filesystem` +- `network` +- `registry` +- `orchestration` +- `synapse` +- `execution` +- `permission` +- `external_executor` +- `unknown` + +## Compatibility Rules + +- Generic `Error` consumers continue to work. +- No broad migration of existing `throw new Error(...)` call-sites happens in + this first implementation. +- CLI output, exit codes, persisted logs, and silent hook behavior must remain + unchanged unless a later story explicitly approves a bounded migration. +- Stack traces remain redacted by default in serialized contracts. + +## Consequences + +Future migrations can target bounded domains first: build-state persistence, +Synapse layer metrics, IDS registry failures, external executor failures, and +installer taxonomy bridging. Each migration must keep its own compatibility +tests. + +## Related + +- GitHub issue: #701 +- Parent epic: #621 +- Runtime remediation PRs: #691, #694, #695, #696, #697, #698, #699, #700 diff --git a/docs/stories/epic-error-governance/EPIC-AIOX-ERROR-GOVERNANCE.md b/docs/stories/epic-error-governance/EPIC-AIOX-ERROR-GOVERNANCE.md new file mode 100644 index 0000000000..0fd789156d --- /dev/null +++ b/docs/stories/epic-error-governance/EPIC-AIOX-ERROR-GOVERNANCE.md @@ -0,0 +1,38 @@ +# Epic AIOX-ERROR-GOVERNANCE: Error Governance Contract + +## Metadata + +| Campo | Valor | +|-------|-------| +| Epic ID | AIOX-ERROR-GOVERNANCE | +| Status | In Progress | +| Source Issue | #701 | +| Parent | #621 | +| Priority | P1 | + +## Objetivo + +Formalizar e implementar a superfície canônica de erros do `aiox-core` sem +quebrar consumidores existentes, saída de CLI, logs persistidos ou políticas +silenciosas de hooks. + +## Escopo Inicial + +- ADR do contrato de Error Governance. +- Módulo `.aiox-core/core/errors/`. +- Testes unitários para `AIOXError`, `ErrorRegistry`, serialização, + normalização e metadata JSON-safe. + +## Fora de Escopo Inicial + +- Migração ampla de `throw new Error(...)`. +- Alteração de mensagens CLI existentes. +- Alteração de exit codes existentes. +- Alteração de políticas silenciosas de hooks. +- Transformar `ErrorRegistry` em log/event store de runtime. + +## Stories + +| Story | Título | Status | +|-------|--------|--------| +| AIOX-ERROR.1 | Core Error Contract | Ready for Review | diff --git a/docs/stories/epic-error-governance/STORY-AIOX-ERROR.1-CORE-CONTRACT.md b/docs/stories/epic-error-governance/STORY-AIOX-ERROR.1-CORE-CONTRACT.md new file mode 100644 index 0000000000..2b928de530 --- /dev/null +++ b/docs/stories/epic-error-governance/STORY-AIOX-ERROR.1-CORE-CONTRACT.md @@ -0,0 +1,113 @@ +# Story AIOX-ERROR.1: Core Error Contract + +## Metadata + +| Campo | Valor | +|-------|-------| +| Story ID | AIOX-ERROR.1 | +| Epic | AIOX-ERROR-GOVERNANCE | +| Status | Ready for Review | +| Executor | @dev | +| Quality Gate | @qa | +| Points | 5 | +| Prioridade | P1 | +| Source Issue | #701 | + +## Objetivo + +Implementar a primeira superfície canônica de Error Governance do `aiox-core`, +com contrato tipado e serialização segura, sem migrar call-sites existentes em +massa. + +## Acceptance Criteria + +- [x] AC1: ADR define `AIOXError`, `ErrorRegistry`, categorias, metadata, + serialização e regras de compatibilidade. +- [x] AC2: `.aiox-core/core/errors/` exporta `AIOXError`, `ErrorRegistry`, + registry padrão, constantes de categoria/severidade, normalização e + serialização. +- [x] AC3: `AIOXError` preserva compatibilidade com `Error`, `message`, `name`, + `cause`, `code`, `category`, `severity`, `retryable`, `exitCode` e + `metadata`. +- [x] AC4: `ErrorRegistry` valida códigos únicos, expõe lookup seguro e mantém + fallback para códigos desconhecidos. +- [x] AC5: Serialização é JSON-safe, preserva metadata e propriedades próprias + de erros, trata circular refs, BigInt, function, symbol, Date, RegExp, Map e + Set, e redige stack por padrão. +- [x] AC6: Normalização encapsula erros genéricos sem perder `cause`, + propriedades próprias ou overrides de metadata. +- [x] AC7: Nenhuma política silenciosa de hook, saída CLI ou exit code existente + é alterada nesta story. +- [x] AC8: Testes focados passam. + +## Tasks + +- [x] T1: Criar ADR e story/epic locais. +- [x] T2: Implementar constantes, registry, classe de erro, serialização e + normalização. +- [x] T3: Criar testes unitários focados. +- [x] T4: Rodar suíte focada e gates disponíveis. +- [x] T5: Atualizar Dev Agent Record e preparar PR. + +## Dev Agent Record + +### Debug Log + +- Branch: `feature/issue-701-error-governance-contract`. +- Base: `origin/main` em `be760026`. +- Issue fonte: #701. +- `npm test -- --runTestsByPath tests/core/errors/error-registry.test.js tests/core/errors/aiox-error.test.js tests/core/errors/serializer.test.js` passou com 14 testes. +- `npx eslint .aiox-core/core/errors tests/core/errors` passou sem erros. +- `npm run typecheck` passou. +- `npm run lint` passou com 0 erros e 114 warnings preexistentes de trailing comma fora do escopo. +- `npm test -- --runInBand` executou a suíte completa: 328 suites passaram, 6 falharam, 11 foram skipped; 8357 testes passaram, 9 falharam, 149 foram skipped. Falhas observadas fora do novo módulo de errors: timeouts em testes de installer/wizard/squad generator, threshold de performance `105ms < 100ms`, e `entity-registry.yaml updatedAt` com mtime antigo. +- Após emitir o resumo, o Jest ficou preso por handles abertos; o processo foi encerrado manualmente para não deixar sessão ativa. +- O hook de commit regenerou `.aiox-core/install-manifest.yaml` e incluiu os seis novos arquivos de core no manifest. +- O hook IDS também gerou uma alteração local em `.aiox-core/data/entity-registry.yaml`, mas ela foi descartada por colidir a chave `index` do registro existente com o novo `core/errors/index.js`; essa correção do gerador deve ser tratada separadamente antes de registrar o novo módulo no entity registry. +- O gerador IDS foi corrigido para preservar IDs de basename existentes e criar IDs escopados por path apenas em colisões; `.aiox-core/core/errors/index.js` passou a registrar como `errors-index`, preservando `.aiox-core/core/index.js` como `index`. +- `node .aiox-core/core/ids/registry-updater.js --files ...` processou 8 updates e registrou o módulo de errors no entity registry incrementalmente. +- `npm test -- --runTestsByPath tests/core/ids/populate-entity-registry.test.js tests/core/ids/registry-updater.test.js tests/core/errors/error-registry.test.js tests/core/errors/aiox-error.test.js tests/core/errors/serializer.test.js --runInBand` passou com 128 testes. +- `npm run validate:manifest` passou. +- Os failures anteriores foram reexecutados isoladamente e passaram: `entity-registry-bootstrap`, `squad-generator`, `aiox-install integration`, `wizard-ide-flow`, `squad-generator-integration` e `ide-config-generator`. +- `npm test -- --runInBand` passou: 334 suites passaram, 11 skipped; 8372 testes passaram, 149 skipped. Após o resumo verde, o Jest ainda manteve handles abertos e o processo foi encerrado manualmente para não deixar sessão ativa. +- O primeiro comando `npm test -- tests/core/errors` não encontrou testes por interpretação de pattern do Jest; a validação correta foi refeita com `--runTestsByPath`. +- O primeiro patch foi aplicado no cwd da sessão (`sinkra-hub`) por engano; os arquivos foram movidos para `aiox-core` e os leftovers criados no `sinkra-hub` foram removidos. + +### Completion Notes + +- Implementação inicial concluiu o contrato canônico sem migrar call-sites existentes. +- `AIOXError` e `ErrorRegistry` nasceram em `.aiox-core/core/errors/` com helpers de serialização, normalização e metadata JSON-safe. +- Não houve alteração de hooks, CLI output ou exit codes existentes. +- `entity-registry.yaml` agora referencia o novo barrel como `errors-index` sem sobrescrever o registro existente de `.aiox-core/core/index.js`. +- Gates locais estão verdes; publicação/merge ainda não foi executada nesta etapa. + +### File List + +| File | Action | +|------|--------| +| `docs/architecture/adr/ADR-ERROR-GOVERNANCE-CONTRACT.md` | Created | +| `docs/stories/epic-error-governance/EPIC-AIOX-ERROR-GOVERNANCE.md` | Created | +| `docs/stories/epic-error-governance/STORY-AIOX-ERROR.1-CORE-CONTRACT.md` | Created | +| `.aiox-core/core/errors/constants.js` | Created | +| `.aiox-core/core/errors/utils.js` | Created | +| `.aiox-core/core/errors/error-registry.js` | Created | +| `.aiox-core/core/errors/serializer.js` | Created | +| `.aiox-core/core/errors/aiox-error.js` | Created | +| `.aiox-core/core/errors/index.js` | Created | +| `.aiox-core/core/ids/registry-updater.js` | Updated | +| `.aiox-core/development/scripts/populate-entity-registry.js` | Updated | +| `.aiox-core/data/entity-registry.yaml` | Updated | +| `.aiox-core/install-manifest.yaml` | Updated | +| `tests/core/errors/error-registry.test.js` | Created | +| `tests/core/errors/aiox-error.test.js` | Created | +| `tests/core/errors/serializer.test.js` | Created | +| `tests/core/ids/populate-entity-registry.test.js` | Updated | +| `tests/core/ids/registry-updater.test.js` | Updated | + +### Change Log + +| Data | Agente | Mudança | +|------|--------|---------| +| 2026-05-08 | Codex | Story criada para implementação inicial do contrato de Error Governance de #701. | +| 2026-05-08 | Codex | Contrato `AIOXError`/`ErrorRegistry` implementado com testes focados e lint de escopo verde. | +| 2026-05-08 | Codex | Corrigida colisão de ID do entity registry e suíte completa validada verde. | diff --git a/tests/core/errors/aiox-error.test.js b/tests/core/errors/aiox-error.test.js new file mode 100644 index 0000000000..da865e103e --- /dev/null +++ b/tests/core/errors/aiox-error.test.js @@ -0,0 +1,121 @@ +const { + AIOXError, + DEFAULT_ERROR_CODE, + ErrorCategory, + ErrorSeverity, + isAIOXError, + normalizeError, +} = require('../../../.aiox-core/core/errors'); + +describe('AIOXError', () => { + test('preserves Error compatibility and registry defaults', () => { + const cause = new Error('root cause'); + const error = new AIOXError('Could not load registry', { + code: 'AIOX_REGISTRY_LOAD_FAILED', + metadata: { + registry: { + path: '.aiox-core/data/entity-registry.yaml', + }, + }, + cause, + }); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('AIOXError'); + expect(error.message).toBe('Could not load registry'); + expect(error.code).toBe('AIOX_REGISTRY_LOAD_FAILED'); + expect(error.category).toBe(ErrorCategory.REGISTRY); + expect(error.severity).toBe(ErrorSeverity.ERROR); + expect(error.retryable).toBe(false); + expect(error.cause).toBe(cause); + expect(error.metadata).toEqual({ + registry: { + path: '.aiox-core/data/entity-registry.yaml', + }, + }); + expect(isAIOXError(error)).toBe(true); + }); + + test('supports overrides and deep metadata merge', () => { + const error = new AIOXError('Filesystem degraded', { + code: 'AIOX_PERSISTENCE_DEGRADED', + severity: ErrorSeverity.INFO, + retryable: false, + exitCode: 0, + metadata: { + persistence: { + path: 'plan/build-state.json', + mode: 'memory', + }, + }, + }); + + const normalized = normalizeError(error, { + metadata: { + persistence: { + operation: 'saveState', + }, + }, + }); + + expect(normalized).not.toBe(error); + expect(normalized.code).toBe('AIOX_PERSISTENCE_DEGRADED'); + expect(normalized.severity).toBe(ErrorSeverity.INFO); + expect(normalized.retryable).toBe(false); + expect(normalized.exitCode).toBe(0); + expect(normalized.metadata).toEqual({ + persistence: { + path: 'plan/build-state.json', + mode: 'memory', + operation: 'saveState', + }, + }); + }); + + test('normalizes generic errors while preserving cause and own properties', () => { + const generic = new Error('generic failure'); + generic.code = 'AIOX_EXECUTION_FAILED'; + generic.detail = { + phase: 'run', + }; + + const normalized = normalizeError(generic, { + metadata: { + execution: { + storyId: 'AIOX-ERROR.1', + }, + }, + }); + + expect(normalized).toBeInstanceOf(AIOXError); + expect(normalized.code).toBe('AIOX_EXECUTION_FAILED'); + expect(normalized.cause).toBe(generic); + expect(normalized.metadata).toEqual({ + originalError: { + name: 'Error', + properties: { + code: 'AIOX_EXECUTION_FAILED', + detail: { + phase: 'run', + }, + }, + }, + execution: { + storyId: 'AIOX-ERROR.1', + }, + }); + }); + + test('normalizes non-error thrown values', () => { + const normalized = normalizeError('boom'); + + expect(normalized).toBeInstanceOf(AIOXError); + expect(normalized.code).toBe(DEFAULT_ERROR_CODE); + expect(normalized.message).toBe('boom'); + expect(normalized.metadata).toEqual({ + originalValue: { + type: 'string', + }, + }); + }); +}); diff --git a/tests/core/errors/error-registry.test.js b/tests/core/errors/error-registry.test.js new file mode 100644 index 0000000000..80b5f9049e --- /dev/null +++ b/tests/core/errors/error-registry.test.js @@ -0,0 +1,92 @@ +const { + CORE_ERROR_DEFINITIONS, + DEFAULT_ERROR_CODE, + ErrorCategory, + ErrorRegistry, + ErrorSeverity, + defaultErrorRegistry, +} = require('../../../.aiox-core/core/errors'); + +describe('ErrorRegistry', () => { + test('default registry contains unique core definitions', () => { + expect(defaultErrorRegistry.assertUnique()).toBe(true); + expect(defaultErrorRegistry.size).toBe(CORE_ERROR_DEFINITIONS.length); + expect(defaultErrorRegistry.has(DEFAULT_ERROR_CODE)).toBe(true); + }); + + test('lookup returns registered definitions', () => { + const definition = defaultErrorRegistry.lookup('AIOX_REGISTRY_LOAD_FAILED'); + + expect(definition).toMatchObject({ + code: 'AIOX_REGISTRY_LOAD_FAILED', + category: ErrorCategory.REGISTRY, + severity: ErrorSeverity.ERROR, + retryable: false, + }); + }); + + test('lookup preserves unknown code with safe fallback metadata', () => { + const definition = defaultErrorRegistry.lookup('AIOX_NOT_REGISTERED'); + + expect(definition).toMatchObject({ + code: 'AIOX_NOT_REGISTERED', + category: ErrorCategory.UNKNOWN, + severity: ErrorSeverity.ERROR, + metadata: { + registry: { + registered: false, + }, + }, + }); + }); + + test('constructor rejects duplicate codes', () => { + expect(() => new ErrorRegistry([ + { + code: 'AIOX_DUPLICATE', + category: ErrorCategory.EXECUTION, + severity: ErrorSeverity.ERROR, + }, + { + code: 'AIOX_DUPLICATE', + category: ErrorCategory.EXECUTION, + severity: ErrorSeverity.ERROR, + }, + ])).toThrow(/Duplicate AIOX error code/); + }); + + test('register validates category, severity, code, and exitCode', () => { + expect(() => new ErrorRegistry([ + { + code: 'invalid-code', + category: ErrorCategory.EXECUTION, + severity: ErrorSeverity.ERROR, + }, + ])).toThrow(/Invalid AIOX error code/); + + expect(() => new ErrorRegistry([ + { + code: 'AIOX_BAD_CATEGORY', + category: 'bad', + severity: ErrorSeverity.ERROR, + }, + ])).toThrow(/Invalid error category/); + + expect(() => new ErrorRegistry([ + { + code: 'AIOX_BAD_SEVERITY', + category: ErrorCategory.EXECUTION, + severity: 'bad', + }, + ])).toThrow(/Invalid error severity/); + + expect(() => new ErrorRegistry([ + { + code: 'AIOX_BAD_EXIT_CODE', + category: ErrorCategory.EXECUTION, + severity: ErrorSeverity.ERROR, + exitCode: -1, + }, + ])).toThrow(/Invalid exitCode/); + }); +}); diff --git a/tests/core/errors/serializer.test.js b/tests/core/errors/serializer.test.js new file mode 100644 index 0000000000..51cf191df3 --- /dev/null +++ b/tests/core/errors/serializer.test.js @@ -0,0 +1,136 @@ +const { + AIOXError, + ErrorCategory, + ErrorSeverity, + sanitizeValue, + serializeError, +} = require('../../../.aiox-core/core/errors'); + +describe('error serializer', () => { + const originalDebugStacks = process.env.DEBUG_ERROR_STACKS; + const originalStacks = process.env.DEBUG_STACKS; + + afterEach(() => { + if (originalDebugStacks === undefined) { + delete process.env.DEBUG_ERROR_STACKS; + } else { + process.env.DEBUG_ERROR_STACKS = originalDebugStacks; + } + + if (originalStacks === undefined) { + delete process.env.DEBUG_STACKS; + } else { + process.env.DEBUG_STACKS = originalStacks; + } + }); + + test('serializes AIOXError with redacted stack by default', () => { + const error = new AIOXError('Layer failed', { + code: 'AIOX_SYNAPSE_LAYER_FAILED', + metadata: { + layer: { + name: 'agent', + }, + }, + }); + + const serialized = serializeError(error); + + expect(serialized).toMatchObject({ + name: 'AIOXError', + message: 'Layer failed', + code: 'AIOX_SYNAPSE_LAYER_FAILED', + category: ErrorCategory.SYNAPSE, + severity: ErrorSeverity.WARNING, + retryable: true, + stack: '[redacted]', + metadata: { + layer: { + name: 'agent', + }, + }, + }); + }); + + test('can expose stacks only when explicitly enabled', () => { + const error = new Error('debug me'); + + expect(serializeError(error).stack).toBe('[redacted]'); + expect(serializeError(error, { includeStack: true }).stack).toContain('Error: debug me'); + + process.env.DEBUG_ERROR_STACKS = 'true'; + expect(serializeError(error).stack).toContain('Error: debug me'); + }); + + test('preserves generic error own properties and cause', () => { + const cause = new Error('root'); + const error = new Error('outer', { cause }); + error.code = 'AIOX_EXECUTION_FAILED'; + error.detail = { + attempt: 2, + }; + + const serialized = serializeError(error); + + expect(serialized).toMatchObject({ + name: 'Error', + message: 'outer', + code: 'AIOX_EXECUTION_FAILED', + stack: '[redacted]', + detail: { + attempt: 2, + }, + cause: { + name: 'Error', + message: 'root', + stack: '[redacted]', + }, + }); + }); + + test('sanitizes circular and non-JSON metadata values', () => { + const metadata = { + now: new Date('2026-05-08T12:00:00.000Z'), + expression: /aiox/gi, + count: BigInt(3), + fn: function namedFunction() {}, + symbol: Symbol('token'), + map: new Map([['key', BigInt(1)]]), + set: new Set(['a', BigInt(2)]), + }; + metadata.self = metadata; + + const error = new AIOXError('json safe', { + code: 'AIOX_EXECUTION_FAILED', + metadata, + }); + + const serialized = serializeError(error); + + expect(serialized.metadata).toMatchObject({ + now: '2026-05-08T12:00:00.000Z', + expression: '/aiox/gi', + count: '3', + fn: expect.stringContaining('function namedFunction'), + symbol: 'Symbol(token)', + map: [['key', '1']], + set: ['a', '2'], + self: '[Circular]', + }); + expect(() => JSON.stringify(serialized)).not.toThrow(); + }); + + test('sanitizeValue handles thrown getters without failing serialization', () => { + const value = {}; + Object.defineProperty(value, 'broken', { + enumerable: true, + get() { + throw new Error('getter exploded'); + }, + }); + + expect(sanitizeValue(value)).toEqual({ + broken: '[Unserializable: getter exploded]', + }); + }); +}); diff --git a/tests/core/ids/populate-entity-registry.test.js b/tests/core/ids/populate-entity-registry.test.js index 1ba316471d..1ace748669 100644 --- a/tests/core/ids/populate-entity-registry.test.js +++ b/tests/core/ids/populate-entity-registry.test.js @@ -9,6 +9,8 @@ const { detectDependencies, extractYamlDependencies, extractMarkdownCrossReferences, + resolveEntityId, + toScopedEntityId, computeChecksum, scanCategory, resolveUsedBy, @@ -36,6 +38,74 @@ describe('populate-entity-registry (AC: 3, 4, 12)', () => { }); }); + describe('resolveEntityId()', () => { + const modulesConfig = { + category: 'modules', + basePath: '.aiox-core/core', + glob: '**/*.{js,mjs}', + type: 'module', + }; + const repoRoot = '/repo'; + + it('keeps basename ID when there is no collision', () => { + const filePath = '/repo/.aiox-core/core/errors/constants.js'; + + expect(resolveEntityId(filePath, modulesConfig, {}, repoRoot)).toBe('constants'); + }); + + it('keeps basename ID when the existing entity points to the same path', () => { + const filePath = '/repo/.aiox-core/core/index.js'; + const existing = { + index: { + path: '.aiox-core/core/index.js', + }, + }; + + expect(resolveEntityId(filePath, modulesConfig, existing, repoRoot)).toBe('index'); + }); + + it('uses a path-scoped ID when basename belongs to a different path', () => { + const filePath = '/repo/.aiox-core/core/errors/index.js'; + const existing = { + index: { + path: '.aiox-core/core/index.js', + }, + }; + + expect(resolveEntityId(filePath, modulesConfig, existing, repoRoot)).toBe('errors-index'); + }); + + it('adds a numeric suffix when the scoped ID is also occupied by another path', () => { + const filePath = '/repo/.aiox-core/core/errors/index.js'; + const existing = { + index: { + path: '.aiox-core/core/index.js', + }, + 'errors-index': { + path: '.aiox-core/core/other/index.js', + }, + }; + + expect(resolveEntityId(filePath, modulesConfig, existing, repoRoot)).toBe('errors-index-2'); + }); + }); + + describe('toScopedEntityId()', () => { + it('derives deterministic path-scoped IDs from the category base path', () => { + const config = { + category: 'modules', + basePath: '.aiox-core/core', + glob: '**/*.{js,mjs}', + type: 'module', + }; + + expect(toScopedEntityId('/repo/.aiox-core/core/errors/index.js', config, '/repo')).toBe('errors-index'); + expect(toScopedEntityId('/repo/.aiox-core/core/code-intel/helpers/creation-helper.js', config, '/repo')).toBe( + 'code-intel-helpers-creation-helper', + ); + }); + }); + describe('extractKeywords()', () => { it('extracts keywords from filename', () => { const kws = extractKeywords('create-doc-template.md', ''); @@ -805,8 +875,8 @@ describe('populate-entity-registry (AC: 3, 4, 12)', () => { }); }); - describe('duplicate detection (AC: 12)', () => { - it('scanCategory skips duplicate entity IDs with warning', () => { + describe('duplicate-safe IDs (AC: 12)', () => { + it('scanCategory returns unique entity IDs', () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); const result = scanCategory({ @@ -823,11 +893,10 @@ describe('populate-entity-registry (AC: 3, 4, 12)', () => { const uniqueIds = new Set(ids); expect(ids.length).toBe(uniqueIds.size); - // Verify that duplicates are logged (if any were found) + // Collision-safe IDs keep returned keys unique even when filenames repeat. const dupWarnings = warnSpy.mock.calls.filter( (call) => typeof call[0] === 'string' && call[0].includes('Duplicate entity ID'), ); - // All returned IDs are unique — any duplicates found would have been warned about expect(dupWarnings.length + ids.length).toBeGreaterThanOrEqual(ids.length); warnSpy.mockRestore(); diff --git a/tests/core/ids/registry-updater.test.js b/tests/core/ids/registry-updater.test.js index 1205d06f94..eb9c5bf5bf 100644 --- a/tests/core/ids/registry-updater.test.js +++ b/tests/core/ids/registry-updater.test.js @@ -221,6 +221,40 @@ describe('RegistryUpdater', () => { expect(registry.entities.modules).toBeDefined(); expect(registry.entities.modules['new-module']).toBeDefined(); }); + + it('does not overwrite an existing basename entity when adding a same-name file in another directory', async () => { + const baseReg = getBaseRegistry(); + baseReg.entities.modules = { + index: { + path: '.aiox-core/core/index.js', + type: 'module', + purpose: 'Root core barrel', + keywords: ['index'], + usedBy: [], + dependencies: ['config-loader'], + adaptability: { score: 0.4, constraints: [], extensionPoints: [] }, + checksum: 'sha256:3333333333333333333333333333333333333333333333333333333333333333', + lastVerified: '2026-02-08T00:00:00Z', + }, + }; + createTempRegistry(baseReg); + + const updater = createUpdater(); + const filePath = createTempFile( + '.aiox-core/core/errors/index.js', + "const constants = require('./constants');\nmodule.exports = { constants };\n", + ); + + const result = await updater.processChanges([{ action: 'add', filePath }]); + + expect(result.updated).toBe(1); + + const registry = readRegistry(); + expect(registry.entities.modules.index.path).toBe('.aiox-core/core/index.js'); + expect(registry.entities.modules.index.purpose).toBe('Root core barrel'); + expect(registry.entities.modules['errors-index']).toBeDefined(); + expect(registry.entities.modules['errors-index'].path).toBe('.aiox-core/core/errors/index.js'); + }); }); describe('File modification handling (AC: 3)', () => {