import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import type { GateStage } from '../../core/gate/GateStage'; import type { Condition } from '../../core/rules/Condition'; import type { RuleDefinition } from '../../core/rules/RuleDefinition'; import type { RuleSet } from '../../core/rules/RuleSet'; import { isSeverityAtLeast, type Severity } from '../../core/rules/Severity'; import { type SkillsRuleEvaluationMode, type SkillsCompiledRule, type SkillsLockBundle, } from './skillsLock'; import { loadSkillsPolicy, type SkillsBundlePolicy } from './skillsPolicy'; import type { DetectedPlatforms } from '../platform/detectPlatforms'; import { loadEffectiveSkillsLock } from './skillsEffectiveLock'; import { resolveMappedHeuristicRuleIdsForCompiledRule } from './skillsDetectorRegistry'; export type SkillsRuleSetLoadResult = { rules: RuleSet; activeBundles: ReadonlyArray; mappedHeuristicRuleIds: ReadonlySet; requiresHeuristicFacts: boolean; unsupportedAutoRuleIds?: ReadonlyArray; registryCoverage?: { contract: 'AUTO_RUNTIME_RULES_FOR_STAGE'; stage: Exclude; registryTotals: { total: number; auto: number; declarative: number; }; stageApplicableAutoRuleIds: ReadonlyArray; declarativeRuleIds: ReadonlyArray; excludedDeclarativeReason: string; }; }; const STAGE_RANK: Record, number> = { PRE_WRITE: 10, PRE_COMMIT: 20, PRE_PUSH: 30, CI: 40, }; const PLATFORM_KEYS: ReadonlyArray = [ 'ios', 'android', 'backend', 'frontend', ]; const PLATFORM_HEURISTIC_FILE_PREFIXES: Record< NonNullable, ReadonlyArray > = { ios: ['apps/ios/', 'ios/'], backend: ['apps/backend/'], frontend: ['apps/frontend/', 'apps/web/'], android: ['apps/android/'], text: [], generic: [], }; const normalizeObservedPath = (path: string): string => { return path.replace(/\\/g, '/').replace(/^\.\/+/, ''); }; const hasTypeScriptOrJavaScriptExtension = (path: string): boolean => { const normalized = path.toLowerCase(); return ( normalized.endsWith('.ts') || normalized.endsWith('.js') || normalized.endsWith('.mts') || normalized.endsWith('.cts') || normalized.endsWith('.mjs') || normalized.endsWith('.cjs') ); }; const hasReactExtension = (path: string): boolean => { const normalized = path.toLowerCase(); return normalized.endsWith('.tsx') || normalized.endsWith('.jsx'); }; const isObservedPathForPlatform = (params: { platform: NonNullable; path: string; }): boolean => { const normalized = normalizeObservedPath(params.path).toLowerCase(); if (params.platform === 'ios') { return normalized.endsWith('.swift'); } if (params.platform === 'android') { return normalized.endsWith('.kt') || normalized.endsWith('.kts'); } if (params.platform === 'backend') { if (!hasTypeScriptOrJavaScriptExtension(normalized)) { return false; } if (normalized.startsWith('apps/backend/')) { return true; } return /(^|\/)(backend|server|api)(\/|$)/.test(normalized); } if (params.platform === 'frontend') { if (hasReactExtension(normalized)) { return true; } if (!hasTypeScriptOrJavaScriptExtension(normalized)) { return false; } if ( normalized.startsWith('apps/frontend/') || normalized.startsWith('apps/web/') ) { return true; } return /(^|\/)(frontend|web|client)(\/|$)/.test(normalized); } return false; }; const toScopedPrefixFromObservedPath = (params: { platform: NonNullable; path: string; }): string | null => { const normalized = normalizeObservedPath(params.path); const segments = normalized.split('/').filter((segment) => segment.length > 0); if (segments.length === 0) { return null; } const keywordByPlatform: Record< NonNullable, ReadonlyArray > = { ios: ['ios'], backend: ['backend', 'server', 'api'], frontend: ['frontend', 'web', 'client'], android: ['android'], text: [], generic: [], }; const keywords = keywordByPlatform[params.platform] ?? []; const keywordIndex = segments.findIndex((segment) => keywords.includes(segment.toLowerCase()) ); if (keywordIndex >= 0) { return `${segments.slice(0, keywordIndex + 1).join('/')}/`; } return null; }; const resolveObservedPlatformPrefixes = (params: { platform: NonNullable; observedFilePaths?: ReadonlyArray; }): ReadonlyArray => { if (!params.observedFilePaths || params.observedFilePaths.length === 0) { return []; } const prefixes = params.observedFilePaths .filter((path) => isObservedPathForPlatform({ platform: params.platform, path, }) ) .map((path) => toScopedPrefixFromObservedPath({ platform: params.platform, path, }) ) .filter((prefix): prefix is string => typeof prefix === 'string' && prefix.length > 0); return [...new Set(prefixes)].sort(); }; const resolvePlatformHeuristicPrefixes = (params: { platform: NonNullable; repoRoot: string; observedFilePaths?: ReadonlyArray; }): ReadonlyArray => { const prefixes = PLATFORM_HEURISTIC_FILE_PREFIXES[params.platform] ?? []; if (prefixes.length === 0) { return []; } const hasPlatformTree = prefixes.some((prefix) => { return existsSync(resolve(params.repoRoot, prefix)); }); if (!hasPlatformTree) { return resolveObservedPlatformPrefixes({ platform: params.platform, observedFilePaths: params.observedFilePaths, }); } if (!params.observedFilePaths || params.observedFilePaths.length === 0) { return prefixes; } const normalizedObservedPaths = params.observedFilePaths.map((path) => normalizeObservedPath(path) ); const hasObservedFilesUnderDefaultPrefixes = normalizedObservedPaths.some((path) => prefixes.some((prefix) => path.startsWith(prefix)) ); if (hasObservedFilesUnderDefaultPrefixes) { return prefixes; } const observedPrefixes = resolveObservedPlatformPrefixes({ platform: params.platform, observedFilePaths: params.observedFilePaths, }); if (observedPrefixes.length > 0) { return observedPrefixes; } return []; }; const resolveScopeForPlatform = ( platform: NonNullable, repoRoot: string, observedFilePaths?: ReadonlyArray ): RuleDefinition['scope'] | undefined => { const prefixes = resolvePlatformHeuristicPrefixes({ platform, repoRoot, observedFilePaths, }); if (prefixes.length === 0) { return undefined; } return { include: [...prefixes], }; }; const toCode = (ruleId: string): string => { return `SKILLS_${ruleId.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}`; }; const toSkillsRuntimeIrSource = (params: { rule: SkillsCompiledRule; mappedHeuristicRuleIds: ReadonlyArray; }): string => { const astNodeIds = [...params.mappedHeuristicRuleIds].sort(); const astNodeToken = astNodeIds.length > 0 ? astNodeIds.join(',') : 'none'; const evaluationMode = resolveRuleEvaluationMode(params.rule); return ( `skills-ir:rule=${params.rule.id};` + `source_skill=${params.rule.sourceSkill};` + `source_path=${params.rule.sourcePath};` + `evaluation_mode=${evaluationMode};` + `ast_nodes=[${astNodeToken}]` ); }; const buildRuleFindingMessage = (params: { rule: SkillsCompiledRule; mappedHeuristicRuleIds: ReadonlyArray; observedFilePaths?: ReadonlyArray; }): string => { if (!params.rule.id.endsWith('.no-solid-violations')) { return params.rule.description; } const relevantObservedPaths = (params.observedFilePaths ?? []) .map((path) => normalizeObservedPath(path)) .filter((path) => isObservedPathForPlatform({ platform: params.rule.platform, path, }) ); const samplePaths = [...new Set(relevantObservedPaths)].slice(0, 3); const astNodes = [...params.mappedHeuristicRuleIds].sort(); const astNodesToken = astNodes.length > 0 ? astNodes.join(',') : 'none'; const sampleToken = samplePaths.length > 0 ? ` sample_paths=[${samplePaths.join(', ')}].` : ''; return ( `${params.rule.description} ` + `Criteria: ast_nodes=[${astNodesToken}], observed_paths=${relevantObservedPaths.length}.${sampleToken}` ); }; const stageApplies = ( currentStage: Exclude, ruleStage?: Exclude ): boolean => { if (!ruleStage) { return true; } return STAGE_RANK[currentStage] >= STAGE_RANK[ruleStage]; }; const buildHeuristicConditionForPlatform = (params: { ruleId: string; platform: NonNullable; repoRoot: string; observedFilePaths?: ReadonlyArray; }): Condition => { const prefixes = resolvePlatformHeuristicPrefixes({ platform: params.platform, repoRoot: params.repoRoot, observedFilePaths: params.observedFilePaths, }); if (prefixes.length === 0) { return { kind: 'Heuristic', where: { ruleId: params.ruleId, }, }; } if (prefixes.length === 1) { return { kind: 'Heuristic', where: { ruleId: params.ruleId, filePathPrefix: prefixes[0], }, }; } return { kind: 'Any', conditions: prefixes.map((prefix) => ({ kind: 'Heuristic' as const, where: { ruleId: params.ruleId, filePathPrefix: prefix, }, })), }; }; const resolveBundleEnabled = (params: { bundleName: string; defaultBundleEnabled: boolean; bundlePolicy?: SkillsBundlePolicy; }): boolean => { if (!params.bundlePolicy) { return params.defaultBundleEnabled; } return params.bundlePolicy.enabled; }; const resolveRuleSeverity = (params: { rule: SkillsCompiledRule; bundlePolicy?: SkillsBundlePolicy; stage: Exclude; }): Severity => { const promotedRuleIds = params.bundlePolicy?.promoteToErrorRuleIds ?? []; const shouldPromote = promotedRuleIds.includes(params.rule.id); if ( params.rule.id.endsWith('.no-solid-violations') && (params.stage === 'PRE_PUSH' || params.stage === 'CI') && !shouldPromote ) { return 'WARN'; } if (!shouldPromote) { return params.rule.severity; } return isSeverityAtLeast(params.rule.severity, 'ERROR') ? params.rule.severity : 'ERROR'; }; const resolveRuleEvaluationMode = ( rule: SkillsCompiledRule ): SkillsRuleEvaluationMode => { return rule.evaluationMode ?? 'AUTO'; }; const toRuleDefinition = (params: { rule: SkillsCompiledRule; stage: Exclude; bundlePolicy?: SkillsBundlePolicy; repoRoot: string; observedFilePaths?: ReadonlyArray; }): RuleDefinition | undefined => { const mappedHeuristicRuleIds = resolveMappedHeuristicRuleIdsForCompiledRule(params.rule); if (!stageApplies(params.stage, params.rule.stage)) { return undefined; } const evaluationMode = resolveRuleEvaluationMode(params.rule); const severity = resolveRuleSeverity({ rule: params.rule, bundlePolicy: params.bundlePolicy, stage: params.stage, }); const runtimeIrSource = toSkillsRuntimeIrSource({ rule: params.rule, mappedHeuristicRuleIds, }); const findingMessage = buildRuleFindingMessage({ rule: params.rule, mappedHeuristicRuleIds, observedFilePaths: params.observedFilePaths, }); if (evaluationMode === 'AUTO') { if (mappedHeuristicRuleIds.length === 0) { return undefined; } const heuristicConditions = mappedHeuristicRuleIds.map((ruleId) => buildHeuristicConditionForPlatform({ ruleId, platform: params.rule.platform, repoRoot: params.repoRoot, observedFilePaths: params.observedFilePaths, }) ); const when: Condition = heuristicConditions.length === 1 ? heuristicConditions[0] : { kind: 'Any', conditions: heuristicConditions, }; return { id: params.rule.id, description: params.rule.description, severity, platform: params.rule.platform, locked: params.rule.locked ?? true, confidence: params.rule.confidence, when, then: { kind: 'Finding', message: findingMessage, code: toCode(params.rule.id), source: runtimeIrSource, }, scope: resolveScopeForPlatform( params.rule.platform, params.repoRoot, params.observedFilePaths ), }; } return undefined; }; const hasDetectedPlatforms = (detectedPlatforms?: DetectedPlatforms): boolean => { if (!detectedPlatforms) { return false; } return PLATFORM_KEYS.some((platform) => detectedPlatforms[platform]?.detected === true); }; const isRulePlatformActive = (params: { rule: SkillsCompiledRule; detectedPlatforms?: DetectedPlatforms; }): boolean => { if (!params.detectedPlatforms) { return true; } if (params.rule.platform === 'generic' || params.rule.platform === 'text') { return true; } if (!hasDetectedPlatforms(params.detectedPlatforms)) { return false; } if (params.rule.platform === 'ios') { return params.detectedPlatforms?.ios?.detected === true; } if (params.rule.platform === 'android') { return params.detectedPlatforms?.android?.detected === true; } if (params.rule.platform === 'backend') { return params.detectedPlatforms?.backend?.detected === true; } if (params.rule.platform === 'frontend') { return params.detectedPlatforms?.frontend?.detected === true; } return true; }; const emptyResult = (): SkillsRuleSetLoadResult => { return { rules: [], activeBundles: [], mappedHeuristicRuleIds: new Set(), requiresHeuristicFacts: false, unsupportedAutoRuleIds: [], }; }; export const loadSkillsRuleSetForStage = ( stage: Exclude, repoRoot: string = process.cwd(), detectedPlatforms?: DetectedPlatforms, observedFilePaths?: ReadonlyArray ): SkillsRuleSetLoadResult => { const lock = loadEffectiveSkillsLock(repoRoot); if (!lock || lock.bundles.length === 0) { return emptyResult(); } const policy = loadSkillsPolicy(repoRoot); const defaultBundleEnabled = policy?.defaultBundleEnabled ?? true; const activeBundles = lock.bundles.filter((bundle) => { return resolveBundleEnabled({ bundleName: bundle.name, defaultBundleEnabled, bundlePolicy: policy?.bundles[bundle.name], }); }); if (activeBundles.length === 0) { return emptyResult(); } const rulesById = new Map(); const mappedHeuristicRuleIds = new Set(); const unsupportedAutoRuleIds = new Set(); const registryRuleIds = new Set(); const registryAutoRuleIds = new Set(); const registryDeclarativeRuleIds = new Set(); const stageApplicableAutoRuleIds = new Set(); for (const bundle of activeBundles) { const bundlePolicy = policy?.bundles[bundle.name]; for (const compiledRule of bundle.rules) { registryRuleIds.add(compiledRule.id); const evaluationMode = resolveRuleEvaluationMode(compiledRule); if (evaluationMode === 'AUTO') { registryAutoRuleIds.add(compiledRule.id); } else { registryDeclarativeRuleIds.add(compiledRule.id); } if ( !isRulePlatformActive({ rule: compiledRule, detectedPlatforms, }) ) { continue; } const mappedRuleIds = resolveMappedHeuristicRuleIdsForCompiledRule(compiledRule); if (!stageApplies(stage, compiledRule.stage)) { continue; } if (evaluationMode !== 'AUTO') { continue; } stageApplicableAutoRuleIds.add(compiledRule.id); if (evaluationMode === 'AUTO' && mappedRuleIds.length === 0) { unsupportedAutoRuleIds.add(compiledRule.id); continue; } const convertedRule = toRuleDefinition({ rule: compiledRule, stage, bundlePolicy, repoRoot, observedFilePaths, }); if (!convertedRule) { continue; } rulesById.set(convertedRule.id, convertedRule); for (const mappedId of mappedRuleIds) { mappedHeuristicRuleIds.add(mappedId); } } } const rules = [...rulesById.values()].sort((left, right) => left.id.localeCompare(right.id) ); return { rules, activeBundles, mappedHeuristicRuleIds, requiresHeuristicFacts: mappedHeuristicRuleIds.size > 0, unsupportedAutoRuleIds: [...unsupportedAutoRuleIds].sort(), registryCoverage: { contract: 'AUTO_RUNTIME_RULES_FOR_STAGE', stage, registryTotals: { total: registryRuleIds.size, auto: registryAutoRuleIds.size, declarative: registryDeclarativeRuleIds.size, }, stageApplicableAutoRuleIds: [...stageApplicableAutoRuleIds].sort(), declarativeRuleIds: [...registryDeclarativeRuleIds].sort(), excludedDeclarativeReason: 'DECLARATIVE skills are registry contract/policy rules. They are not executed as PRE_COMMIT runtime detectors; only AUTO rules applicable to the current stage are evaluated.', }, }; };