150 lines
8.3 KiB
TypeScript
150 lines
8.3 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { lintCommanderPrompt } from "./src/commander-prompt-lint";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
|
|
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
|
|
}
|
|
|
|
function asRecord(value: unknown, label: string): JsonRecord {
|
|
assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value);
|
|
return value as JsonRecord;
|
|
}
|
|
|
|
function asStringArray(value: unknown, label: string): string[] {
|
|
assertCondition(Array.isArray(value) && value.every((item) => typeof item === "string"), `${label} must be string array`, value);
|
|
return value as string[];
|
|
}
|
|
|
|
function runCli(args: string[], stdin?: string): { status: number | null; stdout: string; stderr: string; envelope: JsonRecord } {
|
|
const result = spawnSync("bun", ["scripts/cli.ts", ...args], {
|
|
cwd: process.cwd(),
|
|
input: stdin,
|
|
encoding: "utf8",
|
|
maxBuffer: 4 * 1024 * 1024,
|
|
});
|
|
assertCondition(String(result.stdout || "").trim().length > 0, `command produced no stdout: ${args.join(" ")}`, {
|
|
status: result.status,
|
|
stderr: String(result.stderr || ""),
|
|
});
|
|
return {
|
|
status: result.status,
|
|
stdout: String(result.stdout || ""),
|
|
stderr: String(result.stderr || ""),
|
|
envelope: asRecord(JSON.parse(String(result.stdout || "")) as unknown, "cli envelope"),
|
|
};
|
|
}
|
|
|
|
function dataOf(envelope: JsonRecord): JsonRecord {
|
|
return asRecord(envelope.data, "data");
|
|
}
|
|
|
|
const completePrompt = `
|
|
UniDesk#20 #118 / commander prompt boundary lint
|
|
|
|
You are a D601 Code Queue GPT-5.5 runner. Work in UniDesk only.
|
|
|
|
PR and Git authorization:
|
|
- You may create and update a head branch and PR, rebase/update from origin/master, resolve conflicts, and self-merge/close the ordinary PR when checks pass and the task boundary is satisfied.
|
|
|
|
Artifact authorization:
|
|
- 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.
|
|
|
|
Rollout boundary:
|
|
- 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, run rollout restart, or compete with host commander live verification.
|
|
|
|
Forbidden:
|
|
- No PROD mutation, no reading or printing secrets/tokens/credentials, no manual database/DB writes, and no destructive rollback.
|
|
`;
|
|
|
|
const incompletePromptWithSecret = `
|
|
UniDesk#20 #118
|
|
Task: implement the lint. token=ghp_prompt_lint_contract_secret
|
|
Please make code changes and tests.
|
|
`;
|
|
|
|
const lint = lintCommanderPrompt(completePrompt);
|
|
assertCondition(lint.ok === true, "complete GPT-5.5 PR prompt should pass lint", lint);
|
|
assertCondition(lint.missingClauses.length === 0, "complete prompt should have no missing clauses", lint);
|
|
assertCondition(lint.suggestedPatchSnippet === "", "passing prompt should not include patch snippet", lint);
|
|
assertCondition(lint.policy.advisoryOnly === true, "lint must be advisory only", lint);
|
|
assertCondition(lint.policy.changesCodexSubmitDefault === false, "lint must not change codex submit default", lint);
|
|
assertCondition(JSON.stringify(lint).includes("promptShape"), "lint should expose prompt shape metadata", lint);
|
|
assertCondition(!JSON.stringify(lint).includes("self-merge/close the ordinary PR"), "direct lint result must not echo full prompt", lint);
|
|
|
|
const failingLint = lintCommanderPrompt(incompletePromptWithSecret);
|
|
assertCondition(failingLint.ok === false, "incomplete prompt should fail lint", failingLint);
|
|
assertCondition(failingLint.riskLevel === "high", "incomplete prompt should be high risk", failingLint);
|
|
for (const expected of [
|
|
"pr-self-merge-rebase-authorization",
|
|
"artifact-build-publish-authorization",
|
|
"host-owned-dev-rollout",
|
|
"runner-rollout-forbidden-without-rollout-ok",
|
|
"prod-secret-db-rollback-boundary",
|
|
]) {
|
|
assertCondition(failingLint.missingClauses.includes(expected), `missing expected clause id ${expected}`, failingLint);
|
|
}
|
|
assertCondition(failingLint.suggestedPatchSnippet.includes("ROLLOUT_OK"), "snippet should mention ROLLOUT_OK", failingLint);
|
|
assertCondition(!JSON.stringify(failingLint).includes("ghp_prompt_lint_contract_secret"), "lint output must not echo secret-like prompt text", failingLint);
|
|
|
|
const tmp = mkdtempSync(join(tmpdir(), "host-codex-commander-prompt-lint-"));
|
|
try {
|
|
const promptFile = join(tmp, "prompt.md");
|
|
writeFileSync(promptFile, incompletePromptWithSecret, "utf8");
|
|
const fileRun = runCli(["commander", "prompt-lint", "--kind", "gpt55-pr", "--prompt-file", promptFile]);
|
|
assertCondition(fileRun.status === 0, "commander prompt-lint is advisory and should exit 0 even when lint ok=false", fileRun);
|
|
assertCondition(fileRun.envelope.ok === true, "CLI envelope should remain ok for advisory lint result", fileRun.envelope);
|
|
const fileData = dataOf(fileRun.envelope);
|
|
assertCondition(fileData.ok === false, "lint data ok should reflect missing clauses", fileData);
|
|
assertCondition(asStringArray(fileData.missingClauses, "missingClauses").includes("host-owned-dev-rollout"), "file lint should report rollout clause", fileData);
|
|
assertCondition(String(fileData.suggestedPatchSnippet || "").includes("DEV deploy apply"), "file lint should include bounded patch snippet", fileData);
|
|
assertCondition(!fileRun.stdout.includes("ghp_prompt_lint_contract_secret"), "file lint stdout must not echo prompt secret", fileRun.stdout);
|
|
|
|
const stdinRun = runCli(["commander", "prompt-lint", "--kind", "gpt55-pr", "--stdin"], completePrompt);
|
|
assertCondition(stdinRun.status === 0 && stdinRun.envelope.ok === true, "stdin lint should exit successfully", stdinRun.envelope);
|
|
const stdinData = dataOf(stdinRun.envelope);
|
|
assertCondition(stdinData.ok === true, "stdin lint data should pass for complete prompt", stdinData);
|
|
assertCondition(asStringArray(stdinData.missingClauses, "missingClauses").length === 0, "stdin lint should have no missing clauses", stdinData);
|
|
assertCondition(!stdinRun.stdout.includes("Artifact authorization:"), "stdin lint must not echo full prompt", stdinRun.stdout);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
|
|
const submitDryRun = runCli(["codex", "submit", "--prompt-stdin", "--queue", "prompt-lint-contract", "--dry-run"], incompletePromptWithSecret);
|
|
assertCondition(submitDryRun.status === 0 && submitDryRun.envelope.ok === true, "codex submit --dry-run should not be gated by commander prompt-lint", submitDryRun.envelope);
|
|
const submitData = dataOf(submitDryRun.envelope);
|
|
assertCondition(asRecord(submitData.request, "submit request").prompt !== undefined, "submit dry-run should keep its existing prompt review behavior", submitData);
|
|
|
|
const helpRun = runCli(["commander", "--help"]);
|
|
assertCondition(helpRun.status === 0 && helpRun.envelope.ok === true, "commander help should succeed", helpRun.envelope);
|
|
const helpData = dataOf(helpRun.envelope);
|
|
assertCondition(asStringArray(helpData.usage, "help usage").some((line) => line.includes("commander prompt-lint")), "commander help should list prompt-lint", helpData);
|
|
assertCondition(asRecord(helpData.promptLint, "promptLint").gate === "advisory-only; not a business PR gate and not a Code Queue submit admission change", "help should document advisory-only gate", helpData);
|
|
|
|
const doc = readFileSync("docs/reference/host-codex-commander.md", "utf8");
|
|
for (const snippet of [
|
|
"commander prompt-lint --kind gpt55-pr",
|
|
"missingClauses",
|
|
"suggestedPatchSnippet",
|
|
"不是业务 PR 门禁",
|
|
]) {
|
|
assertCondition(doc.includes(snippet), `reference doc missing snippet: ${snippet}`);
|
|
}
|
|
|
|
process.stdout.write(`${JSON.stringify({
|
|
ok: true,
|
|
checks: [
|
|
"complete GPT-5.5 PR prompt passes commander prompt-lint",
|
|
"missing PR/artifact/DEV rollout/ROLLOUT_OK/PROD-secret-DB-rollback clauses are reported as high risk",
|
|
"prompt-lint supports --prompt-file and --stdin",
|
|
"prompt-lint output does not echo full prompt or secret-like prompt text",
|
|
"commander prompt-lint remains advisory and does not gate codex submit --dry-run",
|
|
"commander help and host commander reference document the advisory lint entry",
|
|
],
|
|
}, null, 2)}\n`);
|