import type { NapcatAuditClassificationInput, NapcatAuditClassificationResult, NapcatAuditCommand, NapcatAutomationReasonCode, NapcatUpstreamAuditResult, } from './napcatAutomation.types.js'; export const napcatUpstreamHotZonePatterns = [ 'packages/napcat-core/**login*', 'packages/napcat-core/**qrcode*', 'packages/napcat-shell/**', 'packages/napcat-framework/**', 'packages/napcat-webui-backend/**QQLogin*', 'packages/napcat-webui-backend/**Data*', 'packages/napcat-webui-backend/**auth*', 'packages/napcat-webui-frontend/**', 'packages/napcat-adapter/**', 'packages/napcat-onebot/**', 'packages/napcat-vite/**', 'package.json', 'pnpm-lock.yaml', 'tsconfig*.json', 'vite*.ts', ] as const; const hotZoneMatchers: Array<{ pattern: string; regex: RegExp }> = [ { pattern: 'packages/napcat-core/**login*', regex: /^packages\/napcat-core\/.*login.*$/i, }, { pattern: 'packages/napcat-core/**qrcode*', regex: /^packages\/napcat-core\/.*qrcode.*$/i, }, { pattern: 'packages/napcat-shell/**', regex: /^packages\/napcat-shell(?:\/|$)/i, }, { pattern: 'packages/napcat-framework/**', regex: /^packages\/napcat-framework(?:\/|$)/i, }, { pattern: 'packages/napcat-webui-backend/**QQLogin*', regex: /^packages\/napcat-webui-backend\/.*qqlogin.*$/i, }, { pattern: 'packages/napcat-webui-backend/**Data*', regex: /^packages\/napcat-webui-backend\/.*data.*$/i, }, { pattern: 'packages/napcat-webui-backend/**auth*', regex: /^packages\/napcat-webui-backend\/.*auth.*$/i, }, { pattern: 'packages/napcat-webui-frontend/**', regex: /^packages\/napcat-webui-frontend(?:\/|$)/i, }, { pattern: 'packages/napcat-adapter/**', regex: /^packages\/napcat-adapter(?:\/|$)/i, }, { pattern: 'packages/napcat-onebot/**', regex: /^packages\/napcat-onebot(?:\/|$)/i, }, { pattern: 'packages/napcat-vite/**', regex: /^packages\/napcat-vite(?:\/|$)/i, }, { pattern: 'package.json', regex: /^package\.json$/i }, { pattern: 'pnpm-lock.yaml', regex: /^pnpm-lock\.yaml$/i }, { pattern: 'tsconfig*.json', regex: /(?:^|\/)tsconfig[^/]*\.json$/i }, { pattern: 'vite*.ts', regex: /(?:^|\/)vite[^/]*\.ts$/i }, ]; /** * Checks whether Git audit metadata contains a repo-relative path that the classifier can trust. * @param value - Raw path string from fork/upstream Git diff collectors; empty entries are allowed because collectors may emit blank lines. * @returns True when a non-empty path is absolute, UNC-based, or contains a parent-directory segment. */ function hasInvalidAuditPath(value: string): boolean { const trimmed = value.trim(); if (!trimmed) return false; if (/^[a-zA-Z]:/.test(trimmed)) return true; if (trimmed.startsWith('\\\\') || trimmed.startsWith('//')) return true; if (trimmed.startsWith('\\') || trimmed.startsWith('/')) return true; const normalized = trimmed .replace(/\\/g, '/') .replace(/^\.\/+/, '') .replace(/\/+/g, '/'); return normalized.split('/').includes('..'); } /** * Checks both file lists for invalid non repo-relative metadata. * @param input - Collector result carrying fork patch and upstream changed file paths; both arrays must contain repo-relative Git paths when non-empty. * @returns True when any path entry would make deterministic hot-zone and overlap classification untrustworthy. */ function hasInvalidAuditMetadata( input: NapcatAuditClassificationInput, ): boolean { for (const file of input.forkPatchFiles) { if (hasInvalidAuditPath(file)) { return true; } } for (const file of input.upstreamChangedFiles) { if (hasInvalidAuditPath(file)) { return true; } } return false; } /** * Normalizes Git path output into a stable comparison key for audit classifiers. * @param value - File path from Git diff/status collectors; may contain Windows separators, leading `./`, or surrounding whitespace, but must represent a repo-relative path. * @returns A lowercase POSIX-style path key used only for deterministic classification. */ function normalizeAuditPath(value: string): string { return value .trim() .replace(/\\/g, '/') .replace(/^\.\/+/, '') .replace(/\/+/g, '/') .toLowerCase(); } /** * Normalizes and de-duplicates path lists while preserving first-seen order. * @param files - File paths reported by fork patch and upstream release diff collectors; empty or whitespace-only entries are ignored. * @returns Stable repo-relative path keys for hot-zone and overlap checks. */ function normalizeAuditPathList(files: string[]): string[] { const normalized: string[] = []; const seen = new Set(); for (const file of files) { const pathKey = normalizeAuditPath(file); if (!pathKey || seen.has(pathKey)) continue; seen.add(pathKey); normalized.push(pathKey); } return normalized; } /** * Checks whether a normalized path falls inside the NapCat upstream manual-review hot zones. * @param pathKey - Lowercase POSIX path from `normalizeAuditPathList`; must be repo-relative and not an absolute filesystem path. * @returns True when the path is covered by one of the fixed hot-zone patterns from the release plan. */ function isNapcatHotZonePath(pathKey: string): boolean { for (const matcher of hotZoneMatchers) { if (matcher.regex.test(pathKey)) { return true; } } return false; } /** * Collects upstream hot-zone file hits in deterministic first-seen order. * @param upstreamChangedFiles - Upstream release diff files from the last accepted base to the candidate release; values must be repo-relative Git paths. * @returns The subset of upstream paths that require manual human review. */ function collectHotZoneFiles(upstreamChangedFiles: string[]): string[] { const hotZoneFiles: string[] = []; for (const pathKey of normalizeAuditPathList(upstreamChangedFiles)) { if (isNapcatHotZonePath(pathKey)) { hotZoneFiles.push(pathKey); } } return hotZoneFiles; } /** * Collects exact file overlaps between KT fork patches and upstream release changes. * @param forkPatchFiles - Fork-only patch files collected from the KT fork comparison range; values must be repo-relative Git paths. * @param upstreamChangedFiles - Upstream release diff files collected from the upstream comparison range; values must be repo-relative Git paths. * @returns Stable exact path overlaps that require human review before candidate creation. */ function collectOverlapFiles( forkPatchFiles: string[], upstreamChangedFiles: string[], ): string[] { const upstreamChangedPathSet = new Set( normalizeAuditPathList(upstreamChangedFiles), ); const overlapFiles: string[] = []; for (const pathKey of normalizeAuditPathList(forkPatchFiles)) { if (upstreamChangedPathSet.has(pathKey)) { overlapFiles.push(pathKey); } } return overlapFiles; } /** * Builds the public classification summary without leaking long file lists into callers. * @param reasonCodes - Reason codes selected by the classifier; blocked and manual-review states carry operational meaning for the next pipeline gate. * @param hotZoneCount - Number of upstream files matching fixed NapCat hot-zone patterns. * @param overlapCount - Number of exact files changed by both the KT fork patch range and upstream release range. * @returns A short deterministic summary suitable for MCP responses and JSON artifacts. */ function buildClassificationSummary( reasonCodes: NapcatAutomationReasonCode[], hotZoneCount: number, overlapCount: number, ): string { if (reasonCodes.includes('DRY_MERGE_CONFLICT')) { return 'Dry merge conflict detected; block automation until a human resolves the upstream sync.'; } if ( reasonCodes.includes('HOT_ZONE_CHANGED') || reasonCodes.includes('FORK_PATCH_OVERLAP') ) { return `Manual review required: hotZoneFiles=${hotZoneCount}, forkPatchOverlaps=${overlapCount}.`; } return 'Safe candidate: no dry-merge conflict, hot-zone change, or fork patch overlap was detected.'; } /** * Classifies a NapCat upstream sync audit from deterministic collector metadata. * @param input - Collector result from dry-merge probing plus fork/upstream Git diff file lists; file arrays must contain repo-relative paths from the NapCatQQ fork. * @returns Classification, reason codes, recommended action, and summary for the next release-pipeline gate. */ export function classifyNapcatUpstreamAudit( input: NapcatAuditClassificationInput, ): NapcatAuditClassificationResult { if (hasInvalidAuditMetadata(input)) { return { classification: 'blocked', reasonCodes: ['METADATA_UNAVAILABLE'], recommendedAction: 'block', summary: 'Invalid audit metadata: fork and upstream file paths must be repo-relative Git paths without absolute roots, UNC prefixes, or parent-directory segments.', }; } if (input.dryMergeConflict) { return { classification: 'blocked', reasonCodes: ['DRY_MERGE_CONFLICT'], recommendedAction: 'block', summary: buildClassificationSummary(['DRY_MERGE_CONFLICT'], 0, 0), }; } const hotZoneFiles = collectHotZoneFiles(input.upstreamChangedFiles); const overlapFiles = collectOverlapFiles( input.forkPatchFiles, input.upstreamChangedFiles, ); const reasonCodes: NapcatAutomationReasonCode[] = []; if (hotZoneFiles.length > 0) { reasonCodes.push('HOT_ZONE_CHANGED'); } if (overlapFiles.length > 0) { reasonCodes.push('FORK_PATCH_OVERLAP'); } if (reasonCodes.length > 0) { return { classification: 'manual-review', reasonCodes, recommendedAction: 'request-human-review', summary: buildClassificationSummary( reasonCodes, hotZoneFiles.length, overlapFiles.length, ), }; } return { classification: 'safe-candidate', reasonCodes: ['NO_UPSTREAM_CHANGE'], recommendedAction: 'create-candidate-branch', summary: buildClassificationSummary(['NO_UPSTREAM_CHANGE'], 0, 0), }; } /** * Builds a read-only upstream audit plan for NapCatQQ release sync automation. * @param input - Same deterministic classifier input supplied by the dry-run collector; values are embedded only in the returned classification, never executed. * @returns Dry-run commands and artifact paths for a future collector, with no Git merge/push, Docker, Kubernetes, or release mutations. */ export function buildNapcatUpstreamAudit( input: NapcatAuditClassificationInput, ): NapcatUpstreamAuditResult { const classification = classifyNapcatUpstreamAudit(input); const commands: NapcatAuditCommand[] = [ { command: 'git status --short --branch', description: 'Confirm the fork worktree state before reading audit ranges.', readOnly: true, }, { command: 'git fetch --dry-run upstream --tags --prune', description: 'Probe upstream release ref availability without updating local refs.', readOnly: true, }, { command: 'git fetch --dry-run origin --prune', description: 'Probe fork ref availability without updating remote-tracking refs.', readOnly: true, }, { command: 'git diff --name-only "${LAST_ACCEPTED_UPSTREAM_BASE:-origin/main}..${UPSTREAM_RELEASE_REF:-upstream/main}"', description: 'List upstream release files changed since the last accepted base.', readOnly: true, }, { command: 'git diff --name-only "${LAST_ACCEPTED_UPSTREAM_BASE:-origin/main}..HEAD"', description: 'List KT fork patch files for deterministic overlap checks.', readOnly: true, }, { command: 'git merge-tree "${FORK_BRANCH:-HEAD}" "${UPSTREAM_RELEASE_REF:-upstream/main}"', description: 'Probe the dry merge shape without updating the worktree or refs.', readOnly: true, }, ]; return { ...classification, artifacts: [ { path: '.kt-workspace/test-artifacts/napcat-upstream-audit/context.md', type: 'markdown', }, { path: '.kt-workspace/test-artifacts/napcat-upstream-audit/classification.json', type: 'json', }, { path: '.kt-workspace/test-artifacts/napcat-upstream-audit/files.json', type: 'json', }, ], commands, execute: false, hotZonePatterns: [...napcatUpstreamHotZonePatterns], }; }