import { createHash } from 'node:crypto'; import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import type { GateStage } from '../../core/gate/GateStage'; import type { RuleDefinition } from '../../core/rules/RuleDefinition'; import type { Severity } from '../../core/rules/Severity'; export type SkillsStage = Exclude; export type SkillsRuleConfidence = 'HIGH' | 'MEDIUM' | 'LOW'; export type SkillsRuleEvaluationMode = 'AUTO' | 'DECLARATIVE'; export type SkillsRuleOrigin = 'core' | 'custom'; export type SkillsAstNodeId = string; export type SkillsCompiledRule = { id: string; description: string; severity: Severity; platform: NonNullable; sourceSkill: string; sourcePath: string; stage?: SkillsStage; confidence?: SkillsRuleConfidence; locked?: boolean; evaluationMode?: SkillsRuleEvaluationMode; origin?: SkillsRuleOrigin; astNodeIds?: ReadonlyArray; }; export type SkillsLockBundle = { name: string; version: string; source: string; hash: string; rules: ReadonlyArray; }; export type SkillsLockV1 = { version: '1.0'; compilerVersion: string; generatedAt: string; bundles: ReadonlyArray; }; const SKILLS_LOCK_FILE = 'skills.lock.json'; const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; const SHA256_PATTERN = /^[A-Fa-f0-9]{64}$/; const AST_NODE_ID_PATTERN = /^heuristics\.[A-Za-z0-9._-]+\.ast$/; const severityValues = new Set(['INFO', 'WARN', 'ERROR', 'CRITICAL']); const stageValues = new Set(['PRE_WRITE', 'PRE_COMMIT', 'PRE_PUSH', 'CI']); const confidenceValues = new Set(['HIGH', 'MEDIUM', 'LOW']); const evaluationModeValues = new Set(['AUTO', 'DECLARATIVE']); const originValues = new Set(['core', 'custom']); const platformValues = new Set>([ 'ios', 'android', 'backend', 'frontend', 'text', 'generic', ]); const isObject = (value: unknown): value is Record => { return typeof value === 'object' && value !== null; }; const isNonEmptyString = (value: unknown): value is string => { return typeof value === 'string' && value.trim().length > 0; }; const isIsoTimestamp = (value: unknown): value is string => { return ( typeof value === 'string' && value.trim().length > 0 && Number.isFinite(Date.parse(value)) ); }; const isSeverity = (value: unknown): value is Severity => { return typeof value === 'string' && severityValues.has(value as Severity); }; const isSkillsStage = (value: unknown): value is SkillsStage => { return typeof value === 'string' && stageValues.has(value as SkillsStage); }; const isRuleConfidence = (value: unknown): value is SkillsRuleConfidence => { return typeof value === 'string' && confidenceValues.has(value as SkillsRuleConfidence); }; const isRuleEvaluationMode = (value: unknown): value is SkillsRuleEvaluationMode => { return typeof value === 'string' && evaluationModeValues.has(value as SkillsRuleEvaluationMode); }; const isAstNodeId = (value: unknown): value is SkillsAstNodeId => { return typeof value === 'string' && AST_NODE_ID_PATTERN.test(value.trim()); }; export const normalizeSkillsAstNodeIds = ( astNodeIds?: ReadonlyArray ): SkillsAstNodeId[] => { if (!astNodeIds || astNodeIds.length === 0) { return []; } const normalized = astNodeIds .map((value) => value.trim().toLowerCase()) .filter((value) => AST_NODE_ID_PATTERN.test(value)); return [...new Set(normalized)].sort(); }; const isRuleOrigin = (value: unknown): value is SkillsRuleOrigin => { return typeof value === 'string' && originValues.has(value as SkillsRuleOrigin); }; const isRulePlatform = ( value: unknown ): value is NonNullable => { return ( typeof value === 'string' && platformValues.has(value as NonNullable) ); }; const isSemver = (value: unknown): value is string => { return typeof value === 'string' && SEMVER_PATTERN.test(value); }; const isSha256Hex = (value: unknown): value is string => { return typeof value === 'string' && SHA256_PATTERN.test(value); }; const isSkillsCompiledRule = (value: unknown): value is SkillsCompiledRule => { if (!isObject(value)) { return false; } if (!isNonEmptyString(value.id) || !isNonEmptyString(value.description)) { return false; } if (!isSeverity(value.severity) || !isRulePlatform(value.platform)) { return false; } if (!isNonEmptyString(value.sourceSkill) || !isNonEmptyString(value.sourcePath)) { return false; } if (typeof value.locked !== 'undefined' && typeof value.locked !== 'boolean') { return false; } if (typeof value.stage !== 'undefined' && !isSkillsStage(value.stage)) { return false; } if (typeof value.confidence !== 'undefined' && !isRuleConfidence(value.confidence)) { return false; } if ( typeof value.evaluationMode !== 'undefined' && !isRuleEvaluationMode(value.evaluationMode) ) { return false; } if ( typeof value.astNodeIds !== 'undefined' && (!Array.isArray(value.astNodeIds) || value.astNodeIds.length === 0 || value.astNodeIds.some((item) => !isAstNodeId(item))) ) { return false; } if (typeof value.origin !== 'undefined' && !isRuleOrigin(value.origin)) { return false; } return true; }; const isSkillsLockBundle = (value: unknown): value is SkillsLockBundle => { if (!isObject(value)) { return false; } if (!isNonEmptyString(value.name) || !isSemver(value.version)) { return false; } if (!isNonEmptyString(value.source) || !isSha256Hex(value.hash)) { return false; } if (!Array.isArray(value.rules) || value.rules.some((rule) => !isSkillsCompiledRule(rule))) { return false; } return true; }; export const isSkillsLockV1 = (value: unknown): value is SkillsLockV1 => { if (!isObject(value)) { return false; } if (value.version !== '1.0' || !isSemver(value.compilerVersion)) { return false; } if (!isIsoTimestamp(value.generatedAt)) { return false; } if (!Array.isArray(value.bundles) || value.bundles.some((bundle) => !isSkillsLockBundle(bundle))) { return false; } return true; }; const stableStringify = (value: unknown): string => { if (Array.isArray(value)) { return `[${value.map((item) => stableStringify(item)).join(',')}]`; } if (isObject(value)) { const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right)); return `{${entries .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`) .join(',')}}`; } return JSON.stringify(value) ?? 'null'; }; const normalizedRuleForHash = (rule: SkillsCompiledRule): Record => { return { id: rule.id, description: rule.description, severity: rule.severity, platform: rule.platform, sourceSkill: rule.sourceSkill, sourcePath: rule.sourcePath, stage: rule.stage ?? null, confidence: rule.confidence ?? null, locked: rule.locked ?? false, evaluationMode: rule.evaluationMode ?? null, origin: rule.origin ?? null, astNodeIds: normalizeSkillsAstNodeIds(rule.astNodeIds), }; }; const normalizedBundleForHash = (bundle: SkillsLockBundle): Record => { const sortedRules = [...bundle.rules].sort((left, right) => left.id.localeCompare(right.id)); return { name: bundle.name, version: bundle.version, source: bundle.source, hash: bundle.hash, rules: sortedRules.map((rule) => normalizedRuleForHash(rule)), }; }; const normalizedLockForHash = (lock: SkillsLockV1): Record => { const sortedBundles = [...lock.bundles].sort((left, right) => { const byName = left.name.localeCompare(right.name); if (byName !== 0) { return byName; } return left.version.localeCompare(right.version); }); return { version: lock.version, compilerVersion: lock.compilerVersion, bundles: sortedBundles.map((bundle) => normalizedBundleForHash(bundle)), }; }; export const createSkillsLockDeterministicHash = (lock: SkillsLockV1): string => { const normalized = normalizedLockForHash(lock); return createHash('sha256').update(stableStringify(normalized)).digest('hex'); }; export const parseSkillsLock = (value: unknown): SkillsLockV1 | undefined => { return isSkillsLockV1(value) ? value : undefined; }; export const loadSkillsLock = (repoRoot: string = process.cwd()): SkillsLockV1 | undefined => { const filePath = resolve(repoRoot, SKILLS_LOCK_FILE); if (!existsSync(filePath)) { return undefined; } try { const parsed: unknown = JSON.parse(readFileSync(filePath, 'utf8')); return parseSkillsLock(parsed); } catch { return undefined; } };