feat: 增加NapCat上游审计采集器
This commit is contained in:
parent
d5e9bf5b01
commit
4b18c1bec8
@ -40,6 +40,10 @@ import {
|
|||||||
parseNapcatAutomationClassification,
|
parseNapcatAutomationClassification,
|
||||||
upstreamAuditOutputSchema,
|
upstreamAuditOutputSchema,
|
||||||
} from "./tools/napcatAutomation.types.js";
|
} from "./tools/napcatAutomation.types.js";
|
||||||
|
import {
|
||||||
|
buildNapcatUpstreamAudit,
|
||||||
|
classifyNapcatUpstreamAudit,
|
||||||
|
} from "./tools/napcatAutomation.js";
|
||||||
import {
|
import {
|
||||||
loadNapcatAutomationPrompt,
|
loadNapcatAutomationPrompt,
|
||||||
type NapcatAutomationPromptName,
|
type NapcatAutomationPromptName,
|
||||||
@ -94,6 +98,59 @@ export async function runSelfTest(): Promise<void> {
|
|||||||
if (parseNapcatAutomationClassification("manual-review") !== "manual-review") {
|
if (parseNapcatAutomationClassification("manual-review") !== "manual-review") {
|
||||||
throw new Error("NapCat classification parser self-check failed");
|
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) {
|
for (const promptName of napcatAutomationPromptNames) {
|
||||||
const promptText = loadNapcatAutomationPrompt(promptName);
|
const promptText = loadNapcatAutomationPrompt(promptName);
|
||||||
if (!promptText.includes("Do not edit files")) {
|
if (!promptText.includes("Do not edit files")) {
|
||||||
|
|||||||
295
src/tools/napcatAutomation.ts
Normal file
295
src/tools/napcatAutomation.ts
Normal file
@ -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<string>();
|
||||||
|
|
||||||
|
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],
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -16,20 +16,45 @@ export const napcatAutomationReasonCodes = [
|
|||||||
'CODEX_SCHEMA_INVALID',
|
'CODEX_SCHEMA_INVALID',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const upstreamAuditOutputSchema = z.object({
|
export const napcatAutomationRecommendedActions = [
|
||||||
classification: z.enum(napcatAutomationClassifications),
|
|
||||||
reasonCodes: z.array(z.enum(napcatAutomationReasonCodes)).min(1),
|
|
||||||
recommendedAction: z.enum([
|
|
||||||
'no-op',
|
'no-op',
|
||||||
'create-candidate-branch',
|
'create-candidate-branch',
|
||||||
'request-human-review',
|
'request-human-review',
|
||||||
'block',
|
'block',
|
||||||
]),
|
] as const;
|
||||||
|
|
||||||
|
export const upstreamAuditOutputSchema = z.object({
|
||||||
|
classification: z.enum(napcatAutomationClassifications),
|
||||||
|
reasonCodes: z.array(z.enum(napcatAutomationReasonCodes)).min(1),
|
||||||
|
recommendedAction: z.enum(napcatAutomationRecommendedActions),
|
||||||
summary: z.string().min(1),
|
summary: z.string().min(1),
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
export type NapcatAutomationClassification =
|
export type NapcatAutomationClassification =
|
||||||
(typeof napcatAutomationClassifications)[number];
|
(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 {
|
export interface NapcatUpstreamAuditInput {
|
||||||
artifactRoot?: string;
|
artifactRoot?: string;
|
||||||
@ -56,6 +81,14 @@ export interface NapcatAutomationResult {
|
|||||||
summary: string;
|
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.
|
* 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.
|
* @param value - Raw classification string from deterministic audit metadata or Codex JSON output; must match the shared NapCat automation taxonomy.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user