From 4b18c1bec8662b57b72dd66d6eec64ee52da4e81 Mon Sep 17 00:00:00 2001 From: sunlei Date: Thu, 25 Jun 2026 00:00:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0NapCat=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=E5=AE=A1=E8=AE=A1=E9=87=87=E9=9B=86=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/selfTest.ts | 57 ++++++ src/tools/napcatAutomation.ts | 295 ++++++++++++++++++++++++++++ src/tools/napcatAutomation.types.ts | 45 ++++- 3 files changed, 391 insertions(+), 6 deletions(-) create mode 100644 src/tools/napcatAutomation.ts diff --git a/src/selfTest.ts b/src/selfTest.ts index 915048f..ecc474c 100644 --- a/src/selfTest.ts +++ b/src/selfTest.ts @@ -40,6 +40,10 @@ import { parseNapcatAutomationClassification, upstreamAuditOutputSchema, } from "./tools/napcatAutomation.types.js"; +import { + buildNapcatUpstreamAudit, + classifyNapcatUpstreamAudit, +} from "./tools/napcatAutomation.js"; import { loadNapcatAutomationPrompt, type NapcatAutomationPromptName, @@ -94,6 +98,59 @@ export async function runSelfTest(): Promise { if (parseNapcatAutomationClassification("manual-review") !== "manual-review") { throw new Error("NapCat classification parser self-check failed"); } + const hotZoneAudit = classifyNapcatUpstreamAudit({ + dryMergeConflict: false, + forkPatchFiles: ["packages/napcat-core/login/runtime.ts"], + upstreamChangedFiles: ["packages/napcat-core/login/runtime.ts"], + }); + if (hotZoneAudit.classification !== "manual-review") { + throw new Error("NapCat hot-zone audit classification self-check failed"); + } + if (!hotZoneAudit.reasonCodes.includes("HOT_ZONE_CHANGED")) { + throw new Error("NapCat hot-zone reason-code self-check failed"); + } + const dryRunAudit = buildNapcatUpstreamAudit({ + dryMergeConflict: false, + forkPatchFiles: ["packages/napcat-core/runtime/base.ts"], + upstreamChangedFiles: ["packages/napcat-core/runtime/upstream.ts"], + }); + if (dryRunAudit.execute !== false || dryRunAudit.artifacts.length === 0) { + throw new Error( + "NapCat upstream audit dry-run artifact plan self-check failed", + ); + } + const dryRunAuditCommands: string[] = []; + for (const command of dryRunAudit.commands) { + if (!command.readOnly) { + throw new Error("NapCat upstream audit dry-run read-only flag failed"); + } + dryRunAuditCommands.push(command.command); + } + const dryRunAuditCommandText = dryRunAuditCommands.join("\n"); + for (const expected of [ + "git status --short --branch", + "git fetch --dry-run", + "git diff --name-only", + ]) { + if (!dryRunAuditCommandText.includes(expected)) { + throw new Error( + `NapCat upstream audit dry-run command missing: ${expected}`, + ); + } + } + for (const forbidden of [ + "git merge", + "git push", + "docker build", + "kubectl", + "deploy", + ]) { + if (dryRunAuditCommandText.includes(forbidden)) { + throw new Error( + `NapCat upstream audit dry-run command is not read-only: ${forbidden}`, + ); + } + } for (const promptName of napcatAutomationPromptNames) { const promptText = loadNapcatAutomationPrompt(promptName); if (!promptText.includes("Do not edit files")) { diff --git a/src/tools/napcatAutomation.ts b/src/tools/napcatAutomation.ts new file mode 100644 index 0000000..9325242 --- /dev/null +++ b/src/tools/napcatAutomation.ts @@ -0,0 +1,295 @@ +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 }, +]; + +/** + * 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 (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', + description: 'Check whether upstream release refs are reachable without updating 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, + }, + ]; + + 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], + }; +} diff --git a/src/tools/napcatAutomation.types.ts b/src/tools/napcatAutomation.types.ts index 1a513df..815ff60 100644 --- a/src/tools/napcatAutomation.types.ts +++ b/src/tools/napcatAutomation.types.ts @@ -16,20 +16,45 @@ export const napcatAutomationReasonCodes = [ 'CODEX_SCHEMA_INVALID', ] as const; +export const napcatAutomationRecommendedActions = [ + 'no-op', + 'create-candidate-branch', + 'request-human-review', + 'block', +] as const; + export const upstreamAuditOutputSchema = z.object({ classification: z.enum(napcatAutomationClassifications), reasonCodes: z.array(z.enum(napcatAutomationReasonCodes)).min(1), - recommendedAction: z.enum([ - 'no-op', - 'create-candidate-branch', - 'request-human-review', - 'block', - ]), + recommendedAction: z.enum(napcatAutomationRecommendedActions), summary: z.string().min(1), }).strict(); export type NapcatAutomationClassification = (typeof napcatAutomationClassifications)[number]; +export type NapcatAutomationReasonCode = + (typeof napcatAutomationReasonCodes)[number]; +export type NapcatAutomationRecommendedAction = + (typeof napcatAutomationRecommendedActions)[number]; + +export interface NapcatAuditClassificationInput { + dryMergeConflict: boolean; + forkPatchFiles: string[]; + upstreamChangedFiles: string[]; +} + +export interface NapcatAuditClassificationResult { + classification: NapcatAutomationClassification; + reasonCodes: NapcatAutomationReasonCode[]; + recommendedAction: NapcatAutomationRecommendedAction; + summary: string; +} + +export interface NapcatAuditCommand { + command: string; + description: string; + readOnly: boolean; +} export interface NapcatUpstreamAuditInput { artifactRoot?: string; @@ -56,6 +81,14 @@ export interface NapcatAutomationResult { summary: string; } +export interface NapcatUpstreamAuditResult + extends NapcatAuditClassificationResult { + artifacts: NapcatAutomationArtifact[]; + commands: NapcatAuditCommand[]; + execute: false; + hotZonePatterns: string[]; +} + /** * Parses the upstream audit classification emitted by deterministic collectors or Codex. * @param value - Raw classification string from deterministic audit metadata or Codex JSON output; must match the shared NapCat automation taxonomy.