feat(commander): add GPT prompt boundary lint
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export type CommanderPromptLintKind = "gpt55-pr";
|
||||
export type CommanderPromptLintRiskLevel = "low" | "medium" | "high";
|
||||
|
||||
interface ClauseCheck {
|
||||
id: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
matched: boolean;
|
||||
severity: "medium" | "high";
|
||||
suggestion: string;
|
||||
}
|
||||
|
||||
export interface CommanderPromptLintResult {
|
||||
ok: boolean;
|
||||
kind: CommanderPromptLintKind;
|
||||
missingClauses: string[];
|
||||
riskLevel: CommanderPromptLintRiskLevel;
|
||||
suggestedPatchSnippet: string;
|
||||
promptShape: {
|
||||
chars: number;
|
||||
lines: number;
|
||||
textEchoed: false;
|
||||
};
|
||||
policy: {
|
||||
advisoryOnly: true;
|
||||
mutatesScheduler: false;
|
||||
changesCodexSubmitDefault: false;
|
||||
printsPromptText: false;
|
||||
supportedInputs: Array<"--stdin" | "--prompt-file">;
|
||||
reference: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ParsedPromptLintArgs {
|
||||
kind: CommanderPromptLintKind;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
const gpt55PrSnippet = [
|
||||
"GPT-5.5 runner boundary:",
|
||||
"- You may create/update the branch and PR, resolve conflicts, rebase/update from the target branch, and self-merge/close an ordinary PR when checks pass and the task boundary is satisfied.",
|
||||
"- You may use repo-owned CI/CD, publish, or equivalent controlled build paths to build/publish DEV images or artifacts, and must report commit, image tag, digest, artifact report, and validation evidence.",
|
||||
"- DEV deploy apply, rollout, and live health verification are owned by the host commander unless this prompt explicitly contains ROLLOUT_OK.",
|
||||
"- Without explicit ROLLOUT_OK, do not acquire the DEV CD lock, run deploy apply, perform rollout/restart, or compete with host commander live verification.",
|
||||
"- Forbidden: PROD mutation, reading or printing secrets, manual database writes, destructive rollback, Code Queue backend/scheduler restart, or interrupt/cancel running tasks unless explicitly authorized.",
|
||||
].join("\n");
|
||||
|
||||
function hasFlag(args: string[], flag: string): boolean {
|
||||
return args.includes(flag);
|
||||
}
|
||||
|
||||
function optionValue(args: string[], names: string[]): string | undefined {
|
||||
for (const name of names) {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) continue;
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function assertKnownOptions(args: string[]): void {
|
||||
const flags = new Set(["--stdin"]);
|
||||
const valueOptions = new Set(["--kind", "--prompt-file", "--file"]);
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index] ?? "";
|
||||
if (!arg.startsWith("--")) throw new Error(`commander prompt-lint does not accept positional prompt text; use --stdin or --prompt-file`);
|
||||
if (flags.has(arg)) continue;
|
||||
if (valueOptions.has(arg)) {
|
||||
index += 1;
|
||||
if (index >= args.length) throw new Error(`${arg} requires a value`);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`unknown commander prompt-lint option: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseKind(raw: string | undefined): CommanderPromptLintKind {
|
||||
const kind = raw ?? "gpt55-pr";
|
||||
if (kind !== "gpt55-pr") throw new Error(`unsupported commander prompt-lint kind: ${kind}`);
|
||||
return kind;
|
||||
}
|
||||
|
||||
function readPromptFromArgs(args: string[]): string {
|
||||
const promptFile = optionValue(args, ["--prompt-file", "--file"]);
|
||||
const promptStdin = hasFlag(args, "--stdin");
|
||||
const sources = [promptFile !== undefined, promptStdin].filter(Boolean).length;
|
||||
if (sources !== 1) throw new Error("commander prompt-lint requires exactly one prompt source: --prompt-file or --stdin");
|
||||
const prompt = promptFile !== undefined
|
||||
? (promptFile === "-" ? readFileSync(0, "utf8") : readFileSync(promptFile, "utf8"))
|
||||
: readFileSync(0, "utf8");
|
||||
if (prompt.trim().length === 0) throw new Error("commander prompt-lint prompt must not be empty");
|
||||
return prompt;
|
||||
}
|
||||
|
||||
export function parseCommanderPromptLintArgs(args: string[]): ParsedPromptLintArgs {
|
||||
assertKnownOptions(args);
|
||||
return {
|
||||
kind: parseKind(optionValue(args, ["--kind"])),
|
||||
prompt: readPromptFromArgs(args),
|
||||
};
|
||||
}
|
||||
|
||||
function any(patterns: RegExp[], prompt: string): boolean {
|
||||
return patterns.some((pattern) => pattern.test(prompt));
|
||||
}
|
||||
|
||||
function rolloutOkPresent(prompt: string): boolean {
|
||||
return /\bROLLOUT_OK\b/u.test(prompt);
|
||||
}
|
||||
|
||||
function gpt55PrClauseChecks(prompt: string): ClauseCheck[] {
|
||||
const prAuthorization = any([
|
||||
/\bPR\b[^\n。]{0,120}(?:create|update|push|branch|merge|close|rebase|conflict)/iu,
|
||||
/(?:创建|更新|push|提交|合并|关闭|rebase|解决冲突)[^\n。]{0,80}\bPR\b/iu,
|
||||
], prompt) && any([
|
||||
/self[- ]?(?:merge|close)|自行(?:合并|关闭|收口)|自合并|自收口/iu,
|
||||
/merge\/close|合并\/关闭/iu,
|
||||
], prompt) && any([
|
||||
/\brebase\b|\bupdate\b|解决冲突|更新(?:目标|base|master|分支)/iu,
|
||||
], prompt);
|
||||
|
||||
const artifactAuthorization = any([
|
||||
/\b(?:build|publish)\b[^\n。]{0,80}\b(?:artifact|image|DEV image|镜像|制品)\b/iu,
|
||||
/\b(?:artifact|image|镜像|制品)\b[^\n。]{0,80}\b(?:build|publish|tag|digest|构建|发布)\b/iu,
|
||||
/(?:构建|发布)[^\n。]{0,80}(?:镜像|制品|artifact|image)/iu,
|
||||
], prompt) && any([
|
||||
/\b(?:tag|digest|artifact report|image tag)\b/iu,
|
||||
/(?:镜像\s*tag|digest|制品报告|artifact report)/iu,
|
||||
], prompt);
|
||||
|
||||
const hostOwnsDevRollout = any([
|
||||
/DEV[^\n。]{0,120}(?:deploy apply|rollout|live health|live verification)[^\n。]{0,120}(?:host commander|commander|host|统一执行|统一处理|统一复验)/iu,
|
||||
/(?:host commander|commander|host|指挥官)[^\n。]{0,120}DEV[^\n。]{0,120}(?:deploy apply|rollout|live health|live verification|发布|上线|复验)/iu,
|
||||
/DEV[^\n。]{0,80}(?:发布|上线|rollout|复验)[^\n。]{0,80}(?:host|commander|指挥官|统一)/iu,
|
||||
], prompt);
|
||||
|
||||
const forbidsRunnerRolloutUnlessOk = any([
|
||||
/unless[^\n。]{0,80}\bROLLOUT_OK\b/iu,
|
||||
/未显式[^\n。]{0,60}\bROLLOUT_OK\b[^\n。]{0,80}(?:不要|不得|禁止|do not|don't|must not)/iu,
|
||||
/\bROLLOUT_OK\b[^\n。]{0,80}(?:才|unless|only if|explicit)/iu,
|
||||
], prompt) && any([
|
||||
/(?:do not|don't|must not|禁止|不要|不得)[^\n。]{0,100}(?:DEV CD lock|deploy apply|rollout|live health|rollout restart|竞争|抢)/iu,
|
||||
/(?:DEV CD lock|deploy apply|rollout|live health|rollout restart|竞争|抢)[^\n。]{0,100}(?:do not|don't|must not|禁止|不要|不得)/iu,
|
||||
], prompt);
|
||||
|
||||
const prodForbidden = any([
|
||||
/(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden)[^\n。]{0,80}(?:PROD|prod|production|生产)/iu,
|
||||
/(?:PROD|prod|production|生产)[^\n。]{0,80}(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden|not allowed)/iu,
|
||||
], prompt);
|
||||
const secretForbidden = any([
|
||||
/(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden)[^\n。]{0,80}(?:secret|token|credential|密钥|凭证)/iu,
|
||||
/(?:secret|token|credential|密钥|凭证)[^\n。]{0,80}(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden|not allowed|读取|打印)/iu,
|
||||
], prompt);
|
||||
const dbForbidden = any([
|
||||
/(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden)[^\n。]{0,80}(?:database|DB|数据库)/iu,
|
||||
/(?:database|DB|数据库)[^\n。]{0,80}(?:manual|手工|patch|write|写入|禁止|不要|不得|must not|do not|don't|\bno\b)/iu,
|
||||
], prompt);
|
||||
const rollbackForbidden = any([
|
||||
/(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden)[^\n。]{0,80}(?:destructive rollback|破坏性回滚|回滚)/iu,
|
||||
/(?:destructive rollback|破坏性回滚)[^\n。]{0,80}(?:禁止|不要|不得|must not|do not|don't|\bno\b|forbid|forbidden|not allowed)/iu,
|
||||
], prompt);
|
||||
|
||||
return [
|
||||
{
|
||||
id: "pr-self-merge-rebase-authorization",
|
||||
label: "PR/自合并/rebase/update 授权文本",
|
||||
required: true,
|
||||
matched: prAuthorization,
|
||||
severity: "high",
|
||||
suggestion: "明确授权 runner 创建/更新 PR、rebase/update/解决冲突,并在普通 PR 满足任务边界和检查通过时自合并/关闭。",
|
||||
},
|
||||
{
|
||||
id: "artifact-build-publish-authorization",
|
||||
label: "build/publish artifact 授权文本",
|
||||
required: true,
|
||||
matched: artifactAuthorization,
|
||||
severity: "high",
|
||||
suggestion: "明确授权使用 repo-owned CI/CD 或受控构建路径发布 DEV image/artifact,并要求回报 tag、digest 和 artifact report。",
|
||||
},
|
||||
{
|
||||
id: "host-owned-dev-rollout",
|
||||
label: "DEV deploy apply/rollout/live verification 由 host 统一执行文本",
|
||||
required: true,
|
||||
matched: hostOwnsDevRollout,
|
||||
severity: "high",
|
||||
suggestion: "写明 DEV deploy apply、rollout 和 live health verification 默认由 host commander 统一执行。",
|
||||
},
|
||||
{
|
||||
id: "runner-rollout-forbidden-without-rollout-ok",
|
||||
label: "未显式 ROLLOUT_OK 时禁止 runner rollout",
|
||||
required: true,
|
||||
matched: forbidsRunnerRolloutUnlessOk,
|
||||
severity: "high",
|
||||
suggestion: "写明未显式包含 ROLLOUT_OK 时,runner 不得抢 DEV CD lock、deploy apply、rollout 或 live verification。",
|
||||
},
|
||||
{
|
||||
id: "prod-secret-db-rollback-boundary",
|
||||
label: "PROD/secret/DB/破坏性回滚边界",
|
||||
required: true,
|
||||
matched: prodForbidden && secretForbidden && dbForbidden && rollbackForbidden,
|
||||
severity: "high",
|
||||
suggestion: "同时禁止 PROD mutation、密钥读取/打印、数据库手工写入和破坏性回滚。",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function lintCommanderPrompt(prompt: string, kind: CommanderPromptLintKind = "gpt55-pr"): CommanderPromptLintResult {
|
||||
const clauses = gpt55PrClauseChecks(prompt);
|
||||
const missingClauses = clauses.filter((clause) => clause.required && !clause.matched).map((clause) => clause.id);
|
||||
const highMissing = clauses.some((clause) => clause.severity === "high" && !clause.matched);
|
||||
const rolloutOk = rolloutOkPresent(prompt);
|
||||
const riskLevel: CommanderPromptLintRiskLevel = missingClauses.length === 0
|
||||
? rolloutOk ? "medium" : "low"
|
||||
: highMissing ? "high" : "medium";
|
||||
|
||||
return {
|
||||
ok: missingClauses.length === 0,
|
||||
kind,
|
||||
missingClauses,
|
||||
riskLevel,
|
||||
suggestedPatchSnippet: missingClauses.length === 0 ? "" : gpt55PrSnippet,
|
||||
promptShape: {
|
||||
chars: prompt.length,
|
||||
lines: prompt.split(/\r\n|\r|\n/u).length,
|
||||
textEchoed: false,
|
||||
},
|
||||
policy: {
|
||||
advisoryOnly: true,
|
||||
mutatesScheduler: false,
|
||||
changesCodexSubmitDefault: false,
|
||||
printsPromptText: false,
|
||||
supportedInputs: ["--stdin", "--prompt-file"],
|
||||
reference: "docs/reference/host-codex-commander.md",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function runCommanderPromptLintCommand(args: string[]): CommanderPromptLintResult {
|
||||
const options = parseCommanderPromptLintArgs(args);
|
||||
return lintCommanderPrompt(options.prompt, options.kind);
|
||||
}
|
||||
Reference in New Issue
Block a user