From c8ffb9cc6f64fcaceef0dbfafb111b522f4cb7bd Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Mon, 19 Oct 2026 02:30:54 +0900 Subject: [PATCH 01/20] Replace NodeJS dict aliases with maps and named field reads --- src/agents/auth.ts | 17 +++--- src/agents/opencode.ts | 20 ++++---- src/common/index.ts | 3 +- src/common/json.test.ts | 23 +++++++++ src/common/json.ts | 17 ++++-- src/post/post.ts | 59 +++++++++++---------- src/processes/isolation.test.ts | 13 +++-- src/profiles/profiles.test.ts | 76 +++++++++++++++------------ src/profiles/profiles.ts | 22 ++++---- src/run/index.ts | 83 ++++++++++++++++-------------- src/runtime/credentials.ts | 9 ++-- src/runtime/runtime.ts | 12 ++--- src/testing/index.ts | 20 ++++++-- tests/entrypoints.test.ts | 6 ++- tests/workflow.test.ts | 91 +++++++++++++++++---------------- 15 files changed, 275 insertions(+), 196 deletions(-) create mode 100644 src/common/json.test.ts diff --git a/src/agents/auth.ts b/src/agents/auth.ts index 9d6a2f7..8170a2a 100644 --- a/src/agents/auth.ts +++ b/src/agents/auth.ts @@ -12,7 +12,7 @@ import { } from "node:fs/promises"; import { dirname, join } from "node:path"; -import { isNotFound, isObject } from "#common"; +import { field, isNotFound, isObject } from "#common"; interface CodexApiKey { kind: "api-key"; @@ -89,15 +89,16 @@ async function readCodexAuth(path: string): Promise { } catch { throw new Error("invalid Codex login"); } - const tokens = isObject(value) ? value["tokens"] : undefined; + const authMode = field(value, "auth_mode"); + const tokens = field(value, "tokens"); if ( - !isObject(value) || - typeof value["auth_mode"] !== "string" || - value["auth_mode"] === "" || + typeof authMode !== "string" || + authMode === "" || !isObject(tokens) || - !["access_token", "refresh_token", "id_token", "account_id"].every( - (name) => typeof tokens[name] === "string" && tokens[name] !== "", - ) + !["access_token", "refresh_token", "id_token", "account_id"].every((name) => { + const token = field(tokens, name); + return typeof token === "string" && token !== ""; + }) ) { throw new Error("invalid Codex login"); } diff --git a/src/agents/opencode.ts b/src/agents/opencode.ts index 56778ab..0cc5489 100644 --- a/src/agents/opencode.ts +++ b/src/agents/opencode.ts @@ -1,4 +1,4 @@ -import { isObject } from "#common"; +import { field, isObject } from "#common"; import { AgentAuthError } from "#profiles"; export function openCodeArgs(model: string, prompt: string): string[] { @@ -56,13 +56,14 @@ export function openCodeError(output: string): Error | null { } catch { continue; } - if (!isObject(event)) continue; - const { type, error } = event; - if (type !== "error" || !isObject(error)) { + const error = field(event, "error"); + if (field(event, "type") !== "error" || !isObject(error)) { continue; } - const { name, data } = error; - const { message, statusCode } = isObject(data) ? data : {}; + const name = field(error, "name"); + const data = field(error, "data"); + const message = field(data, "message"); + const statusCode = field(data, "statusCode"); const detail = `opencode ${typeof name === "string" ? name : "error"}${ typeof statusCode === "number" ? ` (API error ${statusCode})` : "" }: ${typeof message === "string" ? message : "unknown error"}`; @@ -81,10 +82,9 @@ export function parseOpenCodeEvents(output: string): string { for (const line of output.split("\n")) { if (line.trim() === "") continue; const event: unknown = JSON.parse(line); - if (!isObject(event)) continue; - const { type, part } = event; - if (type !== "text" || !isObject(part)) continue; - const { text } = part; + const part = field(event, "part"); + if (field(event, "type") !== "text" || !isObject(part)) continue; + const text = field(part, "text"); if (typeof text === "string" && text.trim() !== "") result = text; } if (result === null) { diff --git a/src/common/index.ts b/src/common/index.ts index 71a7e51..77199dc 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -1,4 +1,3 @@ // Helpers every phase shares: error and JSON value checks. export { isNotFound } from "./errors.ts"; -export { isMapping, isObject } from "./json.ts"; -export type { JsonObject } from "./json.ts"; +export { field, isMapping, isObject } from "./json.ts"; diff --git a/src/common/json.test.ts b/src/common/json.test.ts new file mode 100644 index 0000000..ed7880d --- /dev/null +++ b/src/common/json.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "vite-plus/test"; + +import { field, isMapping, isObject } from "./json.ts"; + +test("reads a named field of a parsed object", () => { + const value: unknown = JSON.parse('{"login":"bot","user":{"login":"nested"}}'); + expect(field(value, "login")).toBe("bot"); + expect(field(field(value, "user"), "login")).toBe("nested"); + expect(field(value, "missing")).toBeUndefined(); +}); + +test("reads no field of a value that is not an object", () => { + for (const value of [null, undefined, "text", 1, true]) { + expect(field(value, "login")).toBeUndefined(); + } +}); + +test("counts arrays as objects but not as mappings", () => { + expect(isObject([])).toBe(true); + expect(isMapping([])).toBe(false); + expect(isMapping({})).toBe(true); + expect(field(["first"], "0")).toBe("first"); +}); diff --git a/src/common/json.ts b/src/common/json.ts index a75f091..be94440 100644 --- a/src/common/json.ts +++ b/src/common/json.ts @@ -1,12 +1,19 @@ -// Type guards for values read from JSON, which arrive as `unknown`. -export type JsonObject = NodeJS.Dict; +// Checks and field reads for values parsed from JSON, which arrive as +// `unknown`. Callers read the fields they know by name into their own +// declared interfaces. -// Any non-null object, arrays included: reading a missing key yields undefined. -export function isObject(value: unknown): value is JsonObject { +// Any non-null object, arrays included. +export function isObject(value: unknown): value is object { return typeof value === "object" && value !== null; } // A plain mapping, which an array is not. -export function isMapping(value: unknown): value is JsonObject { +export function isMapping(value: unknown): value is object { return isObject(value) && !Array.isArray(value); } + +// The named field of an object, or undefined for a missing field or a value +// that is not an object. +export function field(value: unknown, name: string): unknown { + return isObject(value) ? Reflect.get(value, name) : undefined; +} diff --git a/src/post/post.ts b/src/post/post.ts index 324b351..6d667d0 100644 --- a/src/post/post.ts +++ b/src/post/post.ts @@ -2,7 +2,7 @@ import type { Stats } from "node:fs"; import { lstat, readFile, rm } from "node:fs/promises"; import { join } from "node:path"; -import { isNotFound, isObject } from "#common"; +import { field, isNotFound, isObject } from "#common"; import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "#profiles"; import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "#runtime"; @@ -22,10 +22,10 @@ interface Anchor { const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu"); const stripAnsi = (text: string): string => text.replace(ANSI, ""); -const VERDICTS: NodeJS.Dict<[event: string, mark: string]> = { - Approved: ["APPROVED", "โœ…"], - "Changes requested": ["REQUEST_CHANGES", "๐Ÿ›‘"], -}; +const VERDICTS: Map = new Map([ + ["Approved", ["APPROVED", "โœ…"]], + ["Changes requested", ["REQUEST_CHANGES", "๐Ÿ›‘"]], +]); const PROFILE_TYPES: Set = new Set(["claude", "codex", "opencode"]); const FAILURE_REASONS: Set = new Set(["sign-in failed", "usage exhausted", "failed"]); const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value); @@ -46,7 +46,11 @@ function parseAnchors(text: string, sourcePath: string): Anchor[] { if (!isObject(entry)) { throw new TypeError(`${at}: not an object`); } - const { path, line, side = "new", body } = entry; + const path = field(entry, "path"); + const line = field(entry, "line"); + const givenSide = field(entry, "side"); + const side = givenSide === undefined ? "new" : givenSide; + const body = field(entry, "body"); if (typeof path !== "string" || path === "") { throw new TypeError(`${at}.path: expected a repository path`); } @@ -68,43 +72,42 @@ async function readOutcome(path: string): Promise { if (!isObject(value) || !("ok" in value)) { throw new Error("invalid agent outcome"); } - const outcome = value; - if (outcome["ok"] === false) { + const ok = field(value, "ok"); + if (ok === false) { // Only known labels reach the comment, never raw agent output. - const failures: unknown[] = Array.isArray(outcome["failures"]) ? outcome["failures"] : []; + const listed = field(value, "failures"); + const failures: unknown[] = Array.isArray(listed) ? listed : []; return { ok: false, failures: failures.flatMap((entry: unknown) => { - const { type, model, reason } = isObject(entry) ? entry : {}; + const type = field(entry, "type"); + const model = field(entry, "model"); + const reason = field(entry, "reason"); return isProfileType(type) && typeof model === "string" && isFailureReason(reason) ? [{ type, model, reason }] : []; }), }; } + const body = field(value, "body"); + const reviewPath = field(value, "reviewPath"); + const anchorsPath = field(value, "anchorsPath"); + const profile = field(value, "profile"); if ( - outcome["ok"] !== true || - typeof outcome["body"] !== "string" || - typeof outcome["reviewPath"] !== "string" || - typeof outcome["anchorsPath"] !== "string" || - !isObject(outcome["profile"]) + ok !== true || + typeof body !== "string" || + typeof reviewPath !== "string" || + typeof anchorsPath !== "string" || + !isObject(profile) ) { throw new Error("invalid agent outcome"); } - const profile = outcome["profile"]; - if (!isProfileType(profile["type"]) || typeof profile["model"] !== "string") { + const type = field(profile, "type"); + const model = field(profile, "model"); + if (!isProfileType(type) || typeof model !== "string") { throw new Error("invalid agent outcome"); } - return { - ok: true, - body: outcome["body"], - reviewPath: outcome["reviewPath"], - anchorsPath: outcome["anchorsPath"], - profile: { - type: profile["type"], - model: profile["model"], - }, - }; + return { ok: true, body, reviewPath, anchorsPath, profile: { type, model } }; } async function readAgentArtifact( @@ -188,7 +191,7 @@ export async function postRuntime( } const [verdict = "", ...rest] = review.split("\n"); - const [event, mark] = VERDICTS[verdict.trim()] ?? ["COMMENT", "๐Ÿ’ฌ"]; + const [event, mark] = VERDICTS.get(verdict.trim()) ?? ["COMMENT", "๐Ÿ’ฌ"]; const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json"); const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath); const postReview = (entries: Anchor[]): Promise => diff --git a/src/processes/isolation.test.ts b/src/processes/isolation.test.ts index 3493a55..22353ec 100644 --- a/src/processes/isolation.test.ts +++ b/src/processes/isolation.test.ts @@ -25,14 +25,14 @@ function runAs( selected: RuntimeProfile, command: string, args: string[], - env?: NodeJS.ProcessEnv, + env?: ReadonlyMap, ): SpawnSyncReturns { return spawnSync(command, args, { cwd: "/", uid: selected.uid, gid: selected.gid, encoding: "utf8", - ...(env !== undefined && { env }), + ...(env !== undefined && { env: Object.fromEntries(env) }), }); } @@ -104,9 +104,12 @@ test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async () test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", () => { const selected = profile(20_020, "/tmp"); - const result = runAs(selected, "/usr/bin/cat", [`/proc/${process.pid}/environ`], { - PARENT_SECRET: "must-not-cross-boundary", - }); + const result = runAs( + selected, + "/usr/bin/cat", + [`/proc/${process.pid}/environ`], + new Map([["PARENT_SECRET", "must-not-cross-boundary"]]), + ); expect(result.status).not.toBe(0); }); diff --git a/src/profiles/profiles.test.ts b/src/profiles/profiles.test.ts index 067c48a..6b9d337 100644 --- a/src/profiles/profiles.test.ts +++ b/src/profiles/profiles.test.ts @@ -157,20 +157,22 @@ test("does not mistake incidental quota text for exhaustion", async () => { }); test("exposes only the selected profile credential to the agent", () => { - const base = { - PATH: "/usr/bin", - HOME: "/home/bot", - UNRELATED_SECRET: "must-not-reach-agent", - BOT_AGENTS_MASTER_KEY: "master", - PROFILES: "contains every secret", - REVIEWER_TOKEN: "reviewer", - CLAUDE_CODE_OAUTH_TOKEN: "old-claude", - ANTHROPIC_API_KEY: "old-anthropic-key", - ANTHROPIC_AUTH_TOKEN: "old-anthropic-token", - OPENAI_API_KEY: "old-openai", - CODEX_API_KEY: "old-codex-key", - CODEX_ACCESS_TOKEN: "old-codex-token", - }; + const base = new Map( + Object.entries({ + PATH: "/usr/bin", + HOME: "/home/bot", + UNRELATED_SECRET: "must-not-reach-agent", + BOT_AGENTS_MASTER_KEY: "master", + PROFILES: "contains every secret", + REVIEWER_TOKEN: "reviewer", + CLAUDE_CODE_OAUTH_TOKEN: "old-claude", + ANTHROPIC_API_KEY: "old-anthropic-key", + ANTHROPIC_AUTH_TOKEN: "old-anthropic-token", + OPENAI_API_KEY: "old-openai", + CODEX_API_KEY: "old-codex-key", + CODEX_ACCESS_TOKEN: "old-codex-token", + }), + ); const profile = { index: 0, type: "claude" as const, @@ -181,15 +183,19 @@ test("exposes only the selected profile credential to the agent", () => { tokenFile: "/runtime/profile-0/token", }; - expect(agentEnvironment(base, profile, "selected")).toStrictEqual({ - PATH: "/usr/bin", - HOME: "/runtime/profile-0", - CLAUDE_CODE_OAUTH_TOKEN: "selected", - }); - expect(sanitizedEnvironment(base)).toStrictEqual({ - PATH: "/usr/bin", - HOME: "/home/bot", - }); + expect(agentEnvironment(base, profile, "selected")).toStrictEqual( + new Map([ + ["PATH", "/usr/bin"], + ["HOME", "/runtime/profile-0"], + ["CLAUDE_CODE_OAUTH_TOKEN", "selected"], + ]), + ); + expect(sanitizedEnvironment(base)).toStrictEqual( + new Map([ + ["PATH", "/usr/bin"], + ["HOME", "/home/bot"], + ]), + ); }); test("uses a private Codex home without inventing an API credential", () => { @@ -203,11 +209,13 @@ test("uses a private Codex home without inventing an API credential", () => { tokenFile: null, }; - expect(agentEnvironment({ PATH: "/bin" }, profile)).toStrictEqual({ - PATH: "/bin", - HOME: "/runtime/profile-1", - CODEX_HOME: "/runtime/profile-1/.codex", - }); + expect(agentEnvironment(new Map([["PATH", "/bin"]]), profile)).toStrictEqual( + new Map([ + ["PATH", "/bin"], + ["HOME", "/runtime/profile-1"], + ["CODEX_HOME", "/runtime/profile-1/.codex"], + ]), + ); }); test("exposes an OpenRouter key only to the OpenCode profile", () => { @@ -221,9 +229,11 @@ test("exposes an OpenRouter key only to the OpenCode profile", () => { tokenFile: "/runtime/profile-2/token", }; - expect(agentEnvironment({ PATH: "/bin" }, profile, "selected")).toStrictEqual({ - PATH: "/bin", - HOME: "/runtime/profile-2", - OPENROUTER_API_KEY: "selected", - }); + expect(agentEnvironment(new Map([["PATH", "/bin"]]), profile, "selected")).toStrictEqual( + new Map([ + ["PATH", "/bin"], + ["HOME", "/runtime/profile-2"], + ["OPENROUTER_API_KEY", "selected"], + ]), + ); }); diff --git a/src/profiles/profiles.ts b/src/profiles/profiles.ts index 80613bb..65d4f4a 100644 --- a/src/profiles/profiles.ts +++ b/src/profiles/profiles.ts @@ -80,8 +80,12 @@ export function runProfiles( return attemptFrom(profiles, attempt, failures, 0); } -export function sanitizedEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const result: NodeJS.ProcessEnv = {}; +// Environments are maps from variable name to value, converted to the object +// form only where a child process is spawned. +export function sanitizedEnvironment( + environment: ReadonlyMap, +): Map { + const result: Map = new Map(); for (const name of [ "PATH", "HOME", @@ -108,27 +112,27 @@ export function sanitizedEnvironment(environment: NodeJS.ProcessEnv): NodeJS.Pro "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", ]) { - const value = environment[name]; - if (value !== undefined) result[name] = value; + const value = environment.get(name); + if (value !== undefined) result.set(name, value); } return result; } export function agentEnvironment( - environment: NodeJS.ProcessEnv, + environment: ReadonlyMap, profile: ProfileHome, token?: string, -): NodeJS.ProcessEnv { +): Map { const result = sanitizedEnvironment(environment); - result["HOME"] = profile.home; - if (profile.type === "codex") result["CODEX_HOME"] = `${profile.home}/.codex`; + result.set("HOME", profile.home); + if (profile.type === "codex") result.set("CODEX_HOME", `${profile.home}/.codex`); if (token !== undefined && token !== "") { const tokenName = { claude: "CLAUDE_CODE_OAUTH_TOKEN", codex: "OPENAI_API_KEY", opencode: "OPENROUTER_API_KEY", }[profile.type]; - result[tokenName] = token; + result.set(tokenName, token); } return result; } diff --git a/src/run/index.ts b/src/run/index.ts index a4820e4..53e5bc8 100644 --- a/src/run/index.ts +++ b/src/run/index.ts @@ -24,7 +24,7 @@ import { retryInvalidToken, stageCodexAuth, } from "#agents"; -import { isObject } from "#common"; +import { field, isObject } from "#common"; import { echo, exitCode, killProfileProcesses, readProfileFile } from "#processes"; import { AgentAuthError, @@ -62,11 +62,11 @@ const env = (name: string): string => { return value; }; -function definedEnvironment(): NodeJS.ProcessEnv { - const result: NodeJS.ProcessEnv = {}; +function definedEnvironment(): Map { + const result: Map = new Map(); for (const [name, value] of Object.entries(process.env)) { if (value !== undefined) { - result[name] = value; + result.set(name, value); } } return result; @@ -104,17 +104,21 @@ async function postAuthorComment(body: string): Promise { } async function runRoot(command: string, args: string[]): Promise { - const code = await exitCode(spawn(command, args, { env: PROCESS_ENV, stdio: "ignore" })); + const code = await exitCode( + spawn(command, args, { env: Object.fromEntries(PROCESS_ENV), stdio: "ignore" }), + ); if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`); } async function configureGitAuthor(): Promise { const user: unknown = await (await gitea("user")).json(); - if (!isObject(user) || typeof user["login"] !== "string" || typeof user["email"] !== "string") { - throw new Error("user: unexpected Gitea response"); + const login = field(user, "login"); + const email = field(user, "email"); + if (typeof login !== "string" || typeof email !== "string") { + throw new TypeError("user: unexpected Gitea response"); } - await runRoot("git", ["config", "user.name", user["login"]]); - await runRoot("git", ["config", "user.email", user["email"]]); + await runRoot("git", ["config", "user.name", login]); + await runRoot("git", ["config", "user.email", email]); } async function readProfileToken(profile: RuntimeProfile): Promise { @@ -130,7 +134,7 @@ async function runAgentCommand( ): Promise { const child = spawn(command, args, { cwd: WORKSPACE, - env: agentEnvironment(PROCESS_ENV, profile, token), + env: Object.fromEntries(agentEnvironment(PROCESS_ENV, profile, token)), uid: profile.uid, gid: profile.gid, stdio: "ignore", @@ -159,8 +163,9 @@ async function prepareProfile(profile: RuntimeProfile): Promise { // One issue comment as the prompt quotes it. function formatComment(comment: unknown): string { - const { user, created_at: createdAt, body } = isObject(comment) ? comment : {}; - const login = isObject(user) ? user["login"] : undefined; + const login = field(field(comment, "user"), "login"); + const createdAt = field(comment, "created_at"); + const body = field(comment, "body"); return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`; } @@ -173,22 +178,22 @@ async function renderPrompt(profile: RuntimeProfile): Promise { if (!Array.isArray(comments)) { throw new TypeError("comments: unexpected Gitea response"); } - const values: NodeJS.ProcessEnv = { - COMMENT: env("COMMENT"), - ISSUE_COMMENTS: comments.map((comment: unknown) => formatComment(comment)).join("\n"), - CODE_RULES: await (await gitea(RULES_PATH)).text(), - EVENT_NAME: EVENT, - GITEA_API_URL: API, - GITEA_REPOSITORY: REPO, - ISSUE_INDEX: INDEX, - REVIEW_PATH: reviewPath, - ANCHORS_PATH: anchorsPath, - }; + const values: Map = new Map([ + ["COMMENT", env("COMMENT")], + ["ISSUE_COMMENTS", comments.map((comment: unknown) => formatComment(comment)).join("\n")], + ["CODE_RULES", await (await gitea(RULES_PATH)).text()], + ["EVENT_NAME", EVENT], + ["GITEA_API_URL", API], + ["GITEA_REPOSITORY", REPO], + ["ISSUE_INDEX", INDEX], + ["REVIEW_PATH", reviewPath], + ["ANCHORS_PATH", anchorsPath], + ]); const template = await readFile(new URL("../../prompt.md", import.meta.url), "utf8"); return { prompt: template.replaceAll( /\$\{(\w+)\}/gu, - (match: string, name: string) => values[name] ?? match, + (match: string, name: string) => values.get(name) ?? match, ), reviewPath, anchorsPath, @@ -210,10 +215,7 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string) ], { cwd: WORKSPACE, - env: { - ...agentEnvironment(PROCESS_ENV, profile, token), - IS_SANDBOX: "1", - }, + env: Object.fromEntries(agentEnvironment(PROCESS_ENV, profile, token).set("IS_SANDBOX", "1")), uid: profile.uid, gid: profile.gid, stdio: ["ignore", "pipe", "pipe"], @@ -234,17 +236,20 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string) if (line !== "") { const event: unknown = JSON.parse(line); if (isObject(event)) { - const content = isObject(event["message"]) ? event["message"]["content"] : undefined; + const content = field(field(event, "message"), "content"); for (const part of Array.isArray(content) ? content : []) { if (isObject(part)) { - const text = part["thinking"] ?? part["text"] ?? part["name"]; + const text = field(part, "thinking") ?? field(part, "text") ?? field(part, "name"); if (typeof text === "string" && text !== "") { console.log(text); } } } - if (event["type"] === "result") { - const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event; + if (field(event, "type") === "result") { + const isError = field(event, "is_error"); + const answer = field(event, "result"); + const subtype = field(event, "subtype"); + const apiStatus = field(event, "api_error_status"); result = { isError: typeof isError === "boolean" ? isError : undefined, result: typeof answer === "string" ? answer : undefined, @@ -289,11 +294,11 @@ async function pollForInstructions( async function codexDeviceLogin( profile: RuntimeProfile, - environment: NodeJS.ProcessEnv, + environment: ReadonlyMap, ): Promise { const login = spawn("codex", ["login", "--device-auth"], { cwd: WORKSPACE, - env: environment, + env: Object.fromEntries(environment), uid: profile.uid, gid: profile.gid, stdio: ["ignore", "pipe", "pipe"], @@ -318,12 +323,12 @@ async function codexDeviceLogin( async function codexApiLogin( profile: RuntimeProfile, - environment: NodeJS.ProcessEnv, + environment: ReadonlyMap, token: string, ): Promise { const login = spawn("codex", ["login", "--with-api-key"], { cwd: WORKSPACE, - env: environment, + env: Object.fromEntries(environment), uid: profile.uid, gid: profile.gid, stdio: ["pipe", "inherit", "pipe"], @@ -357,7 +362,7 @@ async function runCodex( } else { const check = spawn("codex", ["login", "status"], { cwd: WORKSPACE, - env: environment, + env: Object.fromEntries(environment), uid: profile.uid, gid: profile.gid, stdio: "ignore", @@ -379,7 +384,7 @@ async function runCodex( ], { cwd: WORKSPACE, - env: environment, + env: Object.fromEntries(environment), uid: profile.uid, gid: profile.gid, stdio: ["ignore", "inherit", "pipe"], @@ -420,7 +425,7 @@ async function runOpenCode( ): Promise { const child = spawn("opencode", openCodeArgs(profile.model, prompt), { cwd: WORKSPACE, - env: agentEnvironment(PROCESS_ENV, profile, token), + env: Object.fromEntries(agentEnvironment(PROCESS_ENV, profile, token)), uid: profile.uid, gid: profile.gid, stdio: ["ignore", "pipe", "pipe"], diff --git a/src/runtime/credentials.ts b/src/runtime/credentials.ts index 09a96d1..d594d2f 100644 --- a/src/runtime/credentials.ts +++ b/src/runtime/credentials.ts @@ -1,6 +1,6 @@ import { webcrypto } from "node:crypto"; -import { isMapping } from "#common"; +import { field, isMapping } from "#common"; import type { BotProfile } from "#profiles"; interface DefaultModels { @@ -80,7 +80,9 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P throw new Error(`${at}.${key}: unknown field`); } } - const { type, model, token } = entry; + const type = field(entry, "type"); + const model = field(entry, "model"); + const token = field(entry, "token"); if (type !== "claude" && type !== "codex" && type !== "opencode") { throw new Error(`${at}.type: expected "claude", "codex", or "opencode"`); } @@ -112,7 +114,8 @@ export async function parseCredentials(text: string, masterKey: string): Promise throw new Error(`${key}: unknown field`); } } - const { "reviewer-token": reviewerToken, profiles } = value; + const reviewerToken = field(value, "reviewer-token"); + const profiles = field(value, "profiles"); if (typeof reviewerToken !== "string") { throw new TypeError("reviewer-token: expected an encrypted credential"); } diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index edafa40..fba9fc3 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -1,7 +1,7 @@ import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { isMapping } from "#common"; +import { field, isMapping } from "#common"; import type { ProfileFailure, ProfileIdentity } from "#profiles"; import { parseCredentials } from "./credentials.ts"; @@ -107,11 +107,11 @@ export async function prepareRuntime( function isRuntimeManifest(value: unknown): value is RuntimeManifest { return ( isMapping(value) && - typeof value["runtimeDir"] === "string" && - typeof value["manifestPath"] === "string" && - typeof value["reviewerFile"] === "string" && - typeof value["outcomeFile"] === "string" && - Array.isArray(value["profiles"]) + typeof field(value, "runtimeDir") === "string" && + typeof field(value, "manifestPath") === "string" && + typeof field(value, "reviewerFile") === "string" && + typeof field(value, "outcomeFile") === "string" && + Array.isArray(field(value, "profiles")) ); } diff --git a/src/testing/index.ts b/src/testing/index.ts index 2558862..0284e01 100644 --- a/src/testing/index.ts +++ b/src/testing/index.ts @@ -1,5 +1,5 @@ -// Test support: the current user's IDs, a fresh temporary directory, and a -// plain command runner. It imports no source module, so the repository-level +// Test support: the current user's IDs, a fresh temporary directory, the +// inherited environment, and a plain command runner. It imports no source module, so the repository-level // tests in tests/, which reach src/ only through `#testing`, never touch // internals. import { spawn } from "node:child_process"; @@ -33,14 +33,26 @@ export interface CommandResult { stderr: string; } +// This process's environment as a map from variable name to value. +export function inheritedEnvironment(): Map { + const result: Map = new Map(); + for (const [name, value] of Object.entries(process.env)) { + if (value !== undefined) result.set(name, value); + } + return result; +} + // Runs a command with stdin closed and only the given environment, and // collects its stderr. export async function runCommand( command: string, args: string[], - env: NodeJS.ProcessEnv, + env: ReadonlyMap, ): Promise { - const child = spawn(command, args, { env, stdio: ["ignore", "ignore", "pipe"] }); + const child = spawn(command, args, { + env: Object.fromEntries(env), + stdio: ["ignore", "ignore", "pipe"], + }); const [stderr, closed] = await Promise.all([text(child.stderr), once(child, "close")]); const exitCode: unknown = closed[0]; return { success: exitCode === 0, stderr }; diff --git a/tests/entrypoints.test.ts b/tests/entrypoints.test.ts index 8f33d95..ed7af66 100644 --- a/tests/entrypoints.test.ts +++ b/tests/entrypoints.test.ts @@ -15,7 +15,11 @@ const PHASES: [phase: string, firstVariable: string][] = [ test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => { const path = fileURLToPath(new URL(`../src/${phase}/index.ts`, import.meta.url)); - const result = await runCommand(process.execPath, [path], { PATH: process.env["PATH"] ?? "" }); + const result = await runCommand( + process.execPath, + [path], + new Map([["PATH", process.env["PATH"] ?? ""]]), + ); expect(result.success).toBe(false); expect(result.stderr).toContain(`${firstVariable} is not set`); }); diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index 0028e2f..0c96fa1 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -3,24 +3,22 @@ import process from "node:process"; import { assert, expect, test } from "vite-plus/test"; import { parse } from "yaml"; -import { makeTempDir, runCommand } from "#testing"; +import { inheritedEnvironment, makeTempDir, runCommand } from "#testing"; -type JsonObject = NodeJS.Dict; +// A parsed YAML or JSON mapping, keyed by its dynamic field names. +type Mapping = Map; -const isMapping = (value: unknown): value is JsonObject => - typeof value === "object" && value !== null && !Array.isArray(value); - -const mapping = (value: unknown, at: string): JsonObject => { - assert(isMapping(value), at); - return value; +const mapping = (value: unknown, at: string): Mapping => { + assert(typeof value === "object" && value !== null && !Array.isArray(value), at); + return new Map(Object.entries(value)); }; -const sequence = (value: unknown, at: string): JsonObject[] => { +const sequence = (value: unknown, at: string): Mapping[] => { assert(Array.isArray(value), at); return value.map((entry: unknown, index) => mapping(entry, `${at}[${index}]`)); }; -async function yaml(path: string): Promise { +async function yaml(path: string): Promise { const value: unknown = parse(await readFile(path, "utf8")); return mapping(value, path); } @@ -33,8 +31,8 @@ async function yamlFiles(): Promise { .filter((path) => !/^(?:\.git|\.superpowers|node_modules)\//u.test(path)); } -const findStep = (steps: JsonObject[], key: string, value: string): JsonObject => { - const step = steps.find((entry) => entry[key] === value); +const findStep = (steps: Mapping[], key: string, value: string): Mapping => { + const step = steps.find((entry) => entry.get(key) === value); assert(step !== undefined, `${key}: ${value}`); return step; }; @@ -47,11 +45,12 @@ test("all committed YAML parses", async () => { test("the action exposes only the master key and scopes it to preparation", async () => { const action = await yaml("action.yml"); - expect(Object.keys(mapping(action["inputs"], "inputs"))).toStrictEqual(["master-key"]); - const steps = sequence(mapping(action["runs"], "runs")["steps"], "runs.steps"); + expect([...mapping(action.get("inputs"), "inputs").keys()]).toStrictEqual(["master-key"]); + const steps = sequence(mapping(action.get("runs"), "runs").get("steps"), "runs.steps"); for (const step of steps) { - if (typeof step["run"] === "string") { - expect(step["run"].includes("\n"), String(step["name"])).toBe(false); + const run = step.get("run"); + if (typeof run === "string") { + expect(run.includes("\n"), String(step.get("name"))).toBe(false); } } const checkout = findStep(steps, "uses", "actions/checkout@v4"); @@ -59,16 +58,16 @@ test("the action exposes only the master key and scopes it to preparation", asyn const agent = findStep(steps, "id", "agent"); const post = findStep(steps, "id", "post"); findStep(steps, "id", "source"); - expect(mapping(checkout["with"], "checkout.with")["token"]).toBe("${{ gitea.token }}"); - expect(mapping(prepare["env"], "prepare.env")["BOT_AGENTS_MASTER_KEY"]).toBe( + expect(mapping(checkout.get("with"), "checkout.with").get("token")).toBe("${{ gitea.token }}"); + expect(mapping(prepare.get("env"), "prepare.env").get("BOT_AGENTS_MASTER_KEY")).toBe( "${{ inputs.master-key }}", ); - expect(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY")).toBe(false); - expect(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY")).toBe(false); - expect(mapping(agent["env"], "agent.env")["GITEA_TOKEN"]).toBe("${{ gitea.token }}"); - assert(String(post["if"]).includes("always()")); + expect(JSON.stringify(Object.fromEntries(agent)).includes("BOT_AGENTS_MASTER_KEY")).toBe(false); + expect(JSON.stringify(Object.fromEntries(post)).includes("BOT_AGENTS_MASTER_KEY")).toBe(false); + expect(mapping(agent.get("env"), "agent.env").get("GITEA_TOKEN")).toBe("${{ gitea.token }}"); + assert(String(post.get("if")).includes("always()")); for (const step of [prepare, agent, post]) { - assert(String(step["run"]).includes("steps.source.outputs.path")); + assert(String(step.get("run")).includes("steps.source.outputs.path")); } }); @@ -76,12 +75,11 @@ test("the source protection script stages immutable action files", async () => { const temp = await makeTempDir(); const outputFile = `${temp}/output`; try { - const result = await runCommand("/bin/bash", ["scripts/protect-source.sh"], { - ...process.env, - ACTION_PATH: process.cwd(), - GITHUB_OUTPUT: outputFile, - RUNNER_TEMP: temp, - }); + const environment = inheritedEnvironment(); + environment.set("ACTION_PATH", process.cwd()); + environment.set("GITHUB_OUTPUT", outputFile); + environment.set("RUNNER_TEMP", temp); + const result = await runCommand("/bin/bash", ["scripts/protect-source.sh"], environment); assert(result.success, result.stderr); const path = (await readFile(outputFile, "utf8")).trim().slice(5); @@ -119,27 +117,30 @@ test("module-relative runtime assets exist", async () => { test("the repository uses its trusted main action", async () => { const workflow = await yaml(".gitea/workflows/agents.yml"); - const job = mapping(mapping(workflow["jobs"], "jobs")["agent"], "jobs.agent"); - const permissions = mapping(job["permissions"], "jobs.agent.permissions"); - expect(permissions).toStrictEqual({ + const job = mapping(mapping(workflow.get("jobs"), "jobs").get("agent"), "jobs.agent"); + const permissions = mapping(job.get("permissions"), "jobs.agent.permissions"); + expect(Object.fromEntries(permissions)).toStrictEqual({ code: "write", issues: "write", "pull-requests": "write", }); - const steps = sequence(job["steps"], "jobs.agent.steps"); - expect(steps.findIndex((step) => step["uses"] === "./")).toStrictEqual(-1); + const steps = sequence(job.get("steps"), "jobs.agent.steps"); + expect(steps.findIndex((step) => step.get("uses") === "./")).toStrictEqual(-1); const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main"); - expect(mapping(trusted["with"], "trusted.with")["master-key"]).toBe( + expect(mapping(trusted.get("with"), "trusted.with").get("master-key")).toBe( "${{ secrets.BOT_AGENTS_MASTER_KEY }}", ); - expect(JSON.stringify(workflow).includes("gitea.actor != 'bot'")).toBe(true); + expect(JSON.stringify(Object.fromEntries(workflow)).includes("gitea.actor != 'bot'")).toBe(true); }); test("CI checks every phase and the root isolation suite", async () => { const workflow = await yaml(".gitea/workflows/check.yml"); - const job = mapping(mapping(workflow["jobs"], "jobs")["check"], "jobs.check"); - const commands = sequence(job["steps"], "jobs.check.steps") - .map((step) => (typeof step["run"] === "string" ? step["run"] : "")) + const job = mapping(mapping(workflow.get("jobs"), "jobs").get("check"), "jobs.check"); + const commands = sequence(job.get("steps"), "jobs.check.steps") + .map((step) => { + const run = step.get("run"); + return typeof run === "string" ? run : ""; + }) .join("\n"); // `vp check` type-checks every phase, and `vp test` runs each of them under // Node as well as the root-only isolation tests. @@ -176,8 +177,12 @@ test("profile preparation does not receive provider credentials", async () => { test("central profiles fall back from Codex to Claude to OpenCode", async () => { const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8")); - const profiles = sequence(mapping(credentials, "profiles.json")["profiles"], "profiles"); - expect(profiles.map((profile) => profile["type"])).toStrictEqual(["codex", "claude", "opencode"]); - expect(profiles[2]?.["model"]).toBe("openrouter/qwen/qwen3.8-27b"); - assert(String(profiles[2]?.["token"]).startsWith("v1:")); + const profiles = sequence(mapping(credentials, "profiles.json").get("profiles"), "profiles"); + expect(profiles.map((profile) => profile.get("type"))).toStrictEqual([ + "codex", + "claude", + "opencode", + ]); + expect(profiles[2]?.get("model")).toBe("openrouter/qwen/qwen3.8-27b"); + assert(String(profiles[2]?.get("token")).startsWith("v1:")); }); -- 2.52.0 From d09806daad5e5732b4e137649170caac075318fb Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Mon, 19 Oct 2026 11:26:48 +0900 Subject: [PATCH 02/20] Lint with every Oxlint category and list only the offs and options --- scripts/project-lint.ts | 186 ++++++++++++++++++++------------ src/agents/auth.test.ts | 18 ++-- src/agents/auth.ts | 4 +- src/agents/opencode.ts | 16 ++- src/post/index.ts | 4 +- src/post/post.test.ts | 16 ++- src/post/post.ts | 33 ++++-- src/prepare/index.ts | 4 +- src/processes/isolation.test.ts | 4 +- src/processes/isolation.ts | 12 ++- src/profiles/profiles.test.ts | 41 +++---- src/profiles/profiles.ts | 22 ++-- src/run/index.ts | 36 ++++--- src/runtime/credentials.test.ts | 2 +- src/runtime/credentials.ts | 8 +- src/runtime/runtime.test.ts | 15 +-- src/testing/index.ts | 6 +- tests/workflow.test.ts | 5 +- vite.config.ts | 18 +++- 19 files changed, 293 insertions(+), 157 deletions(-) diff --git a/scripts/project-lint.ts b/scripts/project-lint.ts index 01c8e4b..1b99c1f 100644 --- a/scripts/project-lint.ts +++ b/scripts/project-lint.ts @@ -1,7 +1,7 @@ /** - * `project` โ€” lint rules this repository holds its own source to, on top of - * the Oxlint categories in `vite.config.ts`. Its rules of its own are - * `no-disable-comments` and `no-asset-imports`. + * `project` โ€” this repository's changes to the Oxlint categories in + * `vite.config.ts`. Its rules of its own are `no-disable-comments` and + * `no-asset-imports`. * * Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry. * @@ -14,7 +14,7 @@ import type { DummyRuleMap } from "vite-plus/lint"; /** The source code both hosts hand a rule, with the comment list. */ interface ProjectSourceCode extends TextSourceCode { - getAllComments(): TSESTree.Comment[]; + getAllComments: () => TSESTree.Comment[]; } /** ESLint's default rule types, reporting TypeScript-ESTree nodes. */ @@ -161,11 +161,16 @@ const noAssetImportsRule: ProjectRule = { }; /** - * This repository's own rule list, spread by `vite.config.ts`: rules that - * sit outside the Oxlint categories, plus the template's extras. Built-in - * Oxlint rule ids sit here too, so `vite.config.ts` lists no rule by name. + * This repository's changes to the Oxlint categories in `vite.config.ts`, + * which turn every non-nursery category on: its own rules, the category + * rules it turns off, and options that differ from a rule's defaults. */ const rules: DummyRuleMap = { + // Its own rules. + "project/no-disable-comments": "error", + "project/no-asset-imports": "error", + + // Options. // The explicit-types template's `camelcase` stand-in: no `_` inside a name // once leading and trailing ones are trimmed, unless it has no lowercase // ASCII letter (UPPER_SNAKE). @@ -174,31 +179,6 @@ const rules: DummyRuleMap = { "^_*[^_]*_*$|^[^a-z]*$", { properties: false, classFields: false, onlyDeclarations: false, ignoreDestructuring: true }, ], - // Rules outside the categories. - "no-array-constructor": "error", - "no-case-declarations": "error", - "no-empty": "error", - "no-fallthrough": "error", - "no-inner-declarations": "error", - "no-prototype-builtins": "error", - "no-redeclare": "error", - "no-regex-spaces": "error", - "no-var": "error", - "prefer-const": "error", - "require-await": "error", - "require-yield": "error", - "typescript/adjacent-overload-signatures": "error", - "typescript/ban-ts-comment": "error", - "typescript/no-empty-interface": "error", - "typescript/no-empty-object-type": "error", - "typescript/no-explicit-any": "error", - "typescript/no-namespace": "error", - "typescript/no-non-null-asserted-optional-chain": "error", - "typescript/no-unsafe-function-type": "error", - "typescript/no-wrapper-object-types": "error", - "typescript/prefer-as-const": "error", - "typescript/prefer-namespace-keyword": "error", - "typescript/triple-slash-reference": "error", // On Node, these come from an explicit `node:` import instead of the global scope. "no-restricted-globals": [ "error", @@ -209,45 +189,111 @@ const rules: DummyRuleMap = { "setImmediate", "clearImmediate", ], - // The explicit-types template's extras. - "import/no-cycle": "error", - // `../` is banned in every file; anything above a file needs a `#` alias. - "import/no-relative-parent-imports": "error", - "typescript/consistent-type-imports": "error", - "typescript/no-import-type-side-effects": "error", - "typescript/no-non-null-assertion": "error", - // No disable comments at all. - "project/no-disable-comments": "error", - "project/no-asset-imports": "error", - "unicorn/no-abusive-eslint-disable": "error", - // Size and complexity limits match Deno's: this repo's `deno lint` ran the - // `recommended` set with no config file, so no size or complexity rule - // applied and none applies here. Each Oxlint limit rule, from a category or - // not, is off: - // - `complexity`: Deno had no `max-complexity`. - // - `max-lines`: Deno had no `max-file-lines`. - // - `max-params`: Deno had no `max-params`. - // - `id-length`: Deno had no `max-identifier-length`. - // - `no-nested-ternary`: Deno had no `max-nest-ternaries`. - // - `max-lines-per-function`, `max-classes-per-file`, `max-depth`, - // `max-nested-callbacks`, `max-statements`, `import/max-dependencies`, - // `unicorn/max-nested-calls`: Deno had none of these. - complexity: "off", - "max-lines": "off", - "max-params": "off", - "id-length": "off", - "no-nested-ternary": "off", - "max-lines-per-function": "off", - "max-classes-per-file": "off", - "max-depth": "off", - "max-nested-callbacks": "off", - "max-statements": "off", - "import/max-dependencies": "off", - "unicorn/max-nested-calls": "off", - // Pedantic, but Node's and the web's own types (`Response`, `Stats`, - // `RequestInit`) are mutable, so no parameter of theirs can pass; the - // explicit-types template and the app repos turn it off too. + // Single capital type parameters, which explicit-types requires, may be one letter. + "id-length": ["error", { exceptionPatterns: ["^[A-Z]$"] }], + "one-var": ["error", "never"], + "no-use-before-define": ["error", { functions: false }], + "func-style": ["error", "declaration", { allowArrowFunctions: true }], + "unicorn/import-style": ["error", { styles: { "node:path": { named: true } } }], + // Each limit is the smallest value the current code passes. + complexity: ["error", 26], + "max-depth": ["error", 6], + "max-lines": ["error", { max: 2048, skipBlankLines: false, skipComments: false }], + "max-lines-per-function": ["error", 96], + "max-params": ["error", 4], + "max-statements": ["error", 32], + "import/max-dependencies": ["error", { max: 13 }], + "unicorn/max-nested-calls": ["error", { max: 4 }], + + // Collides with another rule, a plugin config, or Oxfmt. + // Collides with `#private`: members are public unless marked so. + "typescript/explicit-member-accessibility": "off", + // Collides with `typescript/prefer-readonly-parameter-types` being off. + "typescript/prefer-readonly": "off", + // Collides with Node's and the web's mutable types (`Response`, `Stats`). "typescript/prefer-readonly-parameter-types": "off", + // Collides with explicit-types `no-utility-types`, which bans `Record`. + "typescript/consistent-indexed-object-style": "off", + // Collides with explicitly annotated declarations. + "typescript/no-inferrable-types": "off", + // Collides with import-structure `enforce-import-order`. + "sort-imports": "off", + // Collides with import-structure `enforce-mod-file` barrels. + "oxc/no-barrel-file": "off", + // Collides with exporting each declaration where it stands. + "import/group-exports": "off", + // Collides with exporting each declaration where it stands. + "import/exports-last": "off", + // Collides with `import/no-default-export`. + "import/prefer-default-export": "off", + // Collides with `unicorn/prefer-ternary`. + "no-ternary": "off", + // Collides with `require-await` and `promise-function-async`. + "oxc/no-async-await": "off", + // Collides with `typescript/prefer-optional-chain`. + "oxc/no-optional-chaining": "off", + // Collides with file mode checks such as `mode & 0o077`. + "no-bitwise": "off", + // Collides with explicit-types `no-optional-null`, which makes `undefined` the absent value. + "no-undefined": "off", + // Collides with `typescript/no-floating-promises`, which `void` satisfies. + "no-void": "off", + // Collides with JSON and Node APIs, which return `null`. + "unicorn/no-null": "off", + // Collides with `import/no-default-export`. + "import/no-named-export": "off", + // Collides with `typescript/consistent-type-imports`. + "import/consistent-type-specifier-style": "off", + // Collides with `require-await`, which fails an `async` wrapper with no `await`. + "typescript/promise-function-async": "off", + // Collides with explicit-types `no-undefined-type`: a `let` that `try` + // assigns would need `T | undefined`. + "init-declarations": "off", + // Collides with `no-await-in-loop`: sequential steps chain through `reduce`. + "unicorn/no-array-reduce": "off", + // Collides with literal Gitea Actions `${{ }}` expressions in workflow strings. + "no-template-curly-in-string": "off", + + // Node code: this action runs on Node. + // Node built-ins are the action's runtime. + "import/no-nodejs-modules": "off", + // Command-line entry points set their exit status. + "unicorn/no-process-exit": "off", + // The action reports to the job log through the console. + "no-console": "off", + + // PENDING USER: union members are values in profiles.json, the Gitea API, + // and posted comments; renaming them changes behavior. + "explicit-types/upper-snake-string-unions": "off", + // PENDING USER: a fitting `name` needs a constructor and changes logged + // error names. + "unicorn/custom-error-definition": "off", + + // Too much churn. + // Literal numbers read better than a constant per use. + "no-magic-numbers": "off", + // Keys follow meaning, not the alphabet. + "sort-keys": "off", + // Comments may start with code or lowercase identifiers. + "capitalized-comments": "off", + // explicit-types `require-return-type` covers return types. + "typescript/explicit-function-return-type": "off", + // explicit-types `require-return-type` covers return types. + "typescript/explicit-module-boundary-types": "off", + // Empty callbacks are legitimate no-ops. + "no-empty-function": "off", + // Methods may not need `this`. + "class-methods-use-this": "off", + // Braces only where a case declares. + "unicorn/switch-case-braces": "off", + // Plain property reads are fine. + "prefer-destructuring": "off", + // Object rest and spread are idiomatic. + "oxc/no-rest-spread-properties": "off", + // Short numbers need no separators. + "unicorn/numeric-separators-style": "off", + // `continue` keeps loop bodies flat. + "no-continue": "off", }; /** diff --git a/src/agents/auth.test.ts b/src/agents/auth.test.ts index 3ea92d7..71de04e 100644 --- a/src/agents/auth.test.ts +++ b/src/agents/auth.test.ts @@ -19,10 +19,10 @@ test("retries once after an invalid refresh token", async () => { () => Promise.resolve({ code: 1, - error: attempts++ === 0 ? "ERROR: invalid_refresh_token" : "still failed", + error: (attempts += 1) === 1 ? "ERROR: invalid_refresh_token" : "still failed", }), () => { - relogins++; + relogins += 1; return Promise.resolve(); }, ); @@ -39,14 +39,14 @@ test("does not retry an unrelated failure", async () => { let relogins = 0; await retryInvalidToken( () => { - attempts++; + attempts += 1; return Promise.resolve({ code: 1, error: "rate limited", }); }, () => { - relogins++; + relogins += 1; return Promise.resolve(); }, ); @@ -87,11 +87,11 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => { const result = await retryInvalidToken( () => Promise.resolve({ - code: attempts++ > 0 ? 0 : 1, + code: (attempts += 1) > 1 ? 0 : 1, error: attempts === 1 ? REVOKED_CODEX.join("\n") : "", }), () => { - relogins++; + relogins += 1; return Promise.resolve(); }, ); @@ -144,7 +144,8 @@ test.skipIf(IS_WINDOWS)("stages and persists only a regular private Codex login" const staged = await stageCodexAuth(canonical, home, currentUid(), currentGid()); expect(await readFile(staged, "utf8")).toStrictEqual(AUTH); - expect((await stat(staged)).mode & 0o777).toBe(0o600); + const stagedStat = await stat(staged); + expect(stagedStat.mode & 0o777).toBe(0o600); const refreshed = JSON.stringify({ auth_mode: "chatgpt", @@ -153,7 +154,8 @@ test.skipIf(IS_WINDOWS)("stages and persists only a regular private Codex login" await writeFile(staged, refreshed); await persistCodexAuth(staged, canonical); expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed); - expect((await stat(canonical)).mode & 0o777).toBe(0o600); + const canonicalStat = await stat(canonical); + expect(canonicalStat.mode & 0o777).toBe(0o600); } finally { await rm(root, { recursive: true }); } diff --git a/src/agents/auth.ts b/src/agents/auth.ts index 8170a2a..b30a9e5 100644 --- a/src/agents/auth.ts +++ b/src/agents/auth.ts @@ -121,7 +121,9 @@ export async function stageCodexAuth( await chmod(stagedPath, 0o600); await chown(stagedPath, uid, gid); } catch (error) { - if (!isNotFound(error)) throw error; + if (!isNotFound(error)) { + throw error; + } } return stagedPath; } diff --git a/src/agents/opencode.ts b/src/agents/opencode.ts index 0cc5489..172694c 100644 --- a/src/agents/opencode.ts +++ b/src/agents/opencode.ts @@ -49,7 +49,9 @@ export async function collectOpenCodeOutput( export function openCodeError(output: string): Error | null { let failure: Error | null = null; for (const line of output.split("\n")) { - if (line.trim() === "") continue; + if (line.trim() === "") { + continue; + } let event: unknown; try { event = JSON.parse(line); @@ -80,12 +82,18 @@ export function openCodeError(output: string): Error | null { export function parseOpenCodeEvents(output: string): string { let result: string | null = null; for (const line of output.split("\n")) { - if (line.trim() === "") continue; + if (line.trim() === "") { + continue; + } const event: unknown = JSON.parse(line); const part = field(event, "part"); - if (field(event, "type") !== "text" || !isObject(part)) continue; + if (field(event, "type") !== "text" || !isObject(part)) { + continue; + } const text = field(part, "text"); - if (typeof text === "string" && text.trim() !== "") result = text; + if (typeof text === "string" && text.trim() !== "") { + result = text; + } } if (result === null) { throw openCodeError(output) ?? new Error("OpenCode produced no text output"); diff --git a/src/post/index.ts b/src/post/index.ts index dedc16b..ba950a3 100644 --- a/src/post/index.ts +++ b/src/post/index.ts @@ -8,7 +8,9 @@ export { type Fetcher, type PostContext, postRuntime } from "./post.ts"; function required(name: string): string { const value = process.env[name]; - if (value === undefined || value === "") throw new Error(`${name} is not set`); + if (value === undefined || value === "") { + throw new Error(`${name} is not set`); + } return value; } diff --git a/src/post/post.test.ts b/src/post/post.test.ts index 9103307..2f7969c 100644 --- a/src/post/post.test.ts +++ b/src/post/post.test.ts @@ -45,9 +45,15 @@ async function writeArtifacts( review?: string, anchors?: unknown, ): Promise { - if (!outcome.ok) return; - if (review !== undefined) await writePrivate(outcome.reviewPath, review); - if (anchors !== undefined) await writePrivate(outcome.anchorsPath, JSON.stringify(anchors)); + if (!outcome.ok) { + return; + } + if (review !== undefined) { + await writePrivate(outcome.reviewPath, review); + } + if (anchors !== undefined) { + await writePrivate(outcome.anchorsPath, JSON.stringify(anchors)); + } } function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest { @@ -106,7 +112,9 @@ const missing = async (path: string): Promise => { await stat(path); return false; } catch (error) { - if (isNotFound(error)) return true; + if (isNotFound(error)) { + return true; + } throw error; } }; diff --git a/src/post/post.ts b/src/post/post.ts index 6d667d0..a275e05 100644 --- a/src/post/post.ts +++ b/src/post/post.ts @@ -22,12 +22,12 @@ interface Anchor { const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu"); const stripAnsi = (text: string): string => text.replace(ANSI, ""); -const VERDICTS: Map = new Map([ +const VERDICTS = new Map([ ["Approved", ["APPROVED", "โœ…"]], ["Changes requested", ["REQUEST_CHANGES", "๐Ÿ›‘"]], ]); -const PROFILE_TYPES: Set = new Set(["claude", "codex", "opencode"]); -const FAILURE_REASONS: Set = new Set(["sign-in failed", "usage exhausted", "failed"]); +const PROFILE_TYPES = new Set(["claude", "codex", "opencode"]); +const FAILURE_REASONS = new Set(["sign-in failed", "usage exhausted", "failed"]); const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value); const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value); const failureComment = (failures: ProfileFailure[]): string => @@ -40,7 +40,9 @@ class GiteaRequestError extends Error {} function parseAnchors(text: string, sourcePath: string): Anchor[] { const entries: unknown = JSON.parse(text); - if (!Array.isArray(entries)) throw new TypeError(`${sourcePath}: not an array`); + if (!Array.isArray(entries)) { + throw new TypeError(`${sourcePath}: not an array`); + } return entries.map((entry: unknown, index) => { const at = `${sourcePath}[${index}]`; if (!isObject(entry)) { @@ -116,12 +118,16 @@ async function readAgentArtifact( name: string, ): Promise { const profile = manifest.profiles.find((entry) => join(entry.home, name) === path); - if (profile === undefined) throw new Error(`${name}: outside profile home`); + if (profile === undefined) { + throw new Error(`${name}: outside profile home`); + } let stat: Stats; try { stat = await lstat(path); } catch (error) { - if (isNotFound(error)) return null; + if (isNotFound(error)) { + return null; + } throw error; } if ( @@ -143,7 +149,8 @@ export async function postRuntime( ): Promise { const manifest = await readRuntimeManifest(manifestPath); try { - const reviewerToken = (await readFile(manifest.reviewerFile, "utf8")).trim(); + const reviewerText = await readFile(manifest.reviewerFile, "utf8"); + const reviewerToken = reviewerText.trim(); const request = async (path: string, body: unknown): Promise => { let response: Response; try { @@ -208,20 +215,26 @@ export async function postRuntime( })), }); await postReview(anchors).catch(async (error: unknown) => { - if (anchors.length === 0) throw error; + if (anchors.length === 0) { + throw error; + } console.error( `inline comments rejected: ${error instanceof Error ? error.message : String(error)}`, ); await postReview([]); }); } catch (error) { - if (error instanceof GiteaRequestError) throw error; + if (error instanceof GiteaRequestError) { + throw error; + } await postComment(failureComment([{ ...outcome.profile, reason: "failed" }])); throw error; } } finally { await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => { - if (!isNotFound(error)) throw error; + if (!isNotFound(error)) { + throw error; + } }); } } diff --git a/src/prepare/index.ts b/src/prepare/index.ts index e4fb6f4..e077470 100644 --- a/src/prepare/index.ts +++ b/src/prepare/index.ts @@ -5,7 +5,9 @@ import { prepareRuntime } from "#runtime"; const env = (name: string): string => { const value = process.env[name]; - if (value === undefined || value === "") throw new Error(`${name} is not set`); + if (value === undefined || value === "") { + throw new Error(`${name} is not set`); + } return value; }; diff --git a/src/processes/isolation.test.ts b/src/processes/isolation.test.ts index 22353ec..6611172 100644 --- a/src/processes/isolation.test.ts +++ b/src/processes/isolation.test.ts @@ -96,7 +96,9 @@ test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async () await killProfileProcesses(selected.uid); const status = await readFile(`/proc/${pid}/status`, "utf8").catch((error: unknown) => { - if (isNotFound(error)) return null; + if (isNotFound(error)) { + return null; + } throw error; }); expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true); diff --git a/src/processes/isolation.ts b/src/processes/isolation.ts index 8cac73c..32730d4 100644 --- a/src/processes/isolation.ts +++ b/src/processes/isolation.ts @@ -6,8 +6,8 @@ import { isNotFound } from "#common"; import type { RuntimeProfile } from "#runtime"; export function isRunnableProfileProcess(status: string, uid: number): boolean { - const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1]; - const state = /^State:\s+(\w)/mu.exec(status)?.[1]; + const owner = /^Uid:\s+(?\d+)/mu.exec(status)?.[1]; + const state = /^State:\s+(?\w)/mu.exec(status)?.[1]; return owner !== undefined && Number(owner) === uid && state !== "Z"; } @@ -33,12 +33,16 @@ export async function killProfileProcesses(uid: number, attempt = 0): Promise { { type: "codex", model: "gpt-5.6-terra" }, ], (profile) => { - if (profile.type === "claude") throw new Error(message); + if (profile.type === "claude") { + throw new Error(message); + } return Promise.resolve("fallback"); }, ), @@ -84,7 +86,9 @@ test("falls through after a sign-in failure and records each try", async () => { if (profile.type === "codex") { throw new AgentAuthError("codex sign-in failed"); } - if (profile.type === "claude") throw new Error("API Error: 429"); + if (profile.type === "claude") { + throw new Error("API Error: 429"); + } return Promise.resolve("fallback"); }, failures, @@ -116,7 +120,8 @@ test("throws the last sign-in failure when every profile fails", async () => { ), ).rejects.toThrow( expect.toSatisfy( - (e: unknown) => e instanceof AgentAuthError && e.message.includes("claude sign-in failed"), + (error: unknown) => + error instanceof AgentAuthError && error.message.includes("claude sign-in failed"), ), ); expect(failures.map(({ reason }) => reason)).toStrictEqual(["sign-in failed", "sign-in failed"]); @@ -157,22 +162,20 @@ test("does not mistake incidental quota text for exhaustion", async () => { }); test("exposes only the selected profile credential to the agent", () => { - const base = new Map( - Object.entries({ - PATH: "/usr/bin", - HOME: "/home/bot", - UNRELATED_SECRET: "must-not-reach-agent", - BOT_AGENTS_MASTER_KEY: "master", - PROFILES: "contains every secret", - REVIEWER_TOKEN: "reviewer", - CLAUDE_CODE_OAUTH_TOKEN: "old-claude", - ANTHROPIC_API_KEY: "old-anthropic-key", - ANTHROPIC_AUTH_TOKEN: "old-anthropic-token", - OPENAI_API_KEY: "old-openai", - CODEX_API_KEY: "old-codex-key", - CODEX_ACCESS_TOKEN: "old-codex-token", - }), - ); + const base = new Map([ + ["PATH", "/usr/bin"], + ["HOME", "/home/bot"], + ["UNRELATED_SECRET", "must-not-reach-agent"], + ["BOT_AGENTS_MASTER_KEY", "master"], + ["PROFILES", "contains every secret"], + ["REVIEWER_TOKEN", "reviewer"], + ["CLAUDE_CODE_OAUTH_TOKEN", "old-claude"], + ["ANTHROPIC_API_KEY", "old-anthropic-key"], + ["ANTHROPIC_AUTH_TOKEN", "old-anthropic-token"], + ["OPENAI_API_KEY", "old-openai"], + ["CODEX_API_KEY", "old-codex-key"], + ["CODEX_ACCESS_TOKEN", "old-codex-token"], + ]); const profile = { index: 0, type: "claude" as const, diff --git a/src/profiles/profiles.ts b/src/profiles/profiles.ts index 65d4f4a..af19b04 100644 --- a/src/profiles/profiles.ts +++ b/src/profiles/profiles.ts @@ -38,12 +38,16 @@ function isUsageExhausted(error: unknown): boolean { } function failureReason(error: unknown): FailureReason { - if (error instanceof AgentAuthError) return "sign-in failed"; + if (error instanceof AgentAuthError) { + return "sign-in failed"; + } return isUsageExhausted(error) ? "usage exhausted" : "failed"; } export function failureFooter(failures: ProfileFailure[]): string { - if (failures.length === 0) return "> Profile: none"; + if (failures.length === 0) { + return "> Profile: none"; + } return `> Tried: ${failures .map(({ type, model, reason }) => `\`${type}\` ยท \`${model}\` (${reason})`) .join(", ")}`; @@ -66,7 +70,9 @@ async function attemptFrom( } catch (error) { const reason = failureReason(error); failures.push({ type: profile.type, model: profile.model, reason }); - if (reason === "failed" || index === profiles.length - 1) throw error; + if (reason === "failed" || index === profiles.length - 1) { + throw error; + } console.error(`${profile.type} ${reason}; trying next profile`); return attemptFrom(profiles, attempt, failures, index + 1); } @@ -85,7 +91,7 @@ export function runProfiles( export function sanitizedEnvironment( environment: ReadonlyMap, ): Map { - const result: Map = new Map(); + const result = new Map(); for (const name of [ "PATH", "HOME", @@ -113,7 +119,9 @@ export function sanitizedEnvironment( "NODE_EXTRA_CA_CERTS", ]) { const value = environment.get(name); - if (value !== undefined) result.set(name, value); + if (value !== undefined) { + result.set(name, value); + } } return result; } @@ -125,7 +133,9 @@ export function agentEnvironment( ): Map { const result = sanitizedEnvironment(environment); result.set("HOME", profile.home); - if (profile.type === "codex") result.set("CODEX_HOME", `${profile.home}/.codex`); + if (profile.type === "codex") { + result.set("CODEX_HOME", `${profile.home}/.codex`); + } if (token !== undefined && token !== "") { const tokenName = { claude: "CLAUDE_CODE_OAUTH_TOKEN", diff --git a/src/run/index.ts b/src/run/index.ts index 53e5bc8..56ef719 100644 --- a/src/run/index.ts +++ b/src/run/index.ts @@ -58,12 +58,14 @@ interface RenderedPrompt { const env = (name: string): string => { const value = process.env[name]; - if (value === undefined) throw new Error(`${name} is not set`); + if (value === undefined) { + throw new Error(`${name} is not set`); + } return value; }; function definedEnvironment(): Map { - const result: Map = new Map(); + const result = new Map(); for (const [name, value] of Object.entries(process.env)) { if (value !== undefined) { result.set(name, value); @@ -107,11 +109,14 @@ async function runRoot(command: string, args: string[]): Promise { const code = await exitCode( spawn(command, args, { env: Object.fromEntries(PROCESS_ENV), stdio: "ignore" }), ); - if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`); + if (code !== 0) { + throw new Error(`${command} ${args[0]} exited with ${code}`); + } } async function configureGitAuthor(): Promise { - const user: unknown = await (await gitea("user")).json(); + const userResponse = await gitea("user"); + const user: unknown = await userResponse.json(); const login = field(user, "login"); const email = field(user, "email"); if (typeof login !== "string" || typeof email !== "string") { @@ -140,7 +145,9 @@ async function runAgentCommand( stdio: "ignore", }); const code = await exitCode(child); - if (code !== 0) throw new Error(`${command} ${args[0]} exited with ${code}`); + if (code !== 0) { + throw new Error(`${command} ${args[0]} exited with ${code}`); + } } async function prepareProfile(profile: RuntimeProfile): Promise { @@ -172,16 +179,15 @@ function formatComment(comment: unknown): string { async function renderPrompt(profile: RuntimeProfile): Promise { const reviewPath = await makeProfileFile(profile, "review.md"); const anchorsPath = await makeProfileFile(profile, "anchors.json"); - const comments: unknown = await ( - await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`) - ).json(); + const commentsResponse = await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`); + const comments: unknown = await commentsResponse.json(); if (!Array.isArray(comments)) { throw new TypeError("comments: unexpected Gitea response"); } - const values: Map = new Map([ + const values = new Map([ ["COMMENT", env("COMMENT")], ["ISSUE_COMMENTS", comments.map((comment: unknown) => formatComment(comment)).join("\n")], - ["CODE_RULES", await (await gitea(RULES_PATH)).text()], + ["CODE_RULES", await gitea(RULES_PATH).then((response) => response.text())], ["EVENT_NAME", EVENT], ["GITEA_API_URL", API], ["GITEA_REPOSITORY", REPO], @@ -192,7 +198,7 @@ async function renderPrompt(profile: RuntimeProfile): Promise { const template = await readFile(new URL("../../prompt.md", import.meta.url), "utf8"); return { prompt: template.replaceAll( - /\$\{(\w+)\}/gu, + /\$\{(?\w+)\}/gu, (match: string, name: string) => values.get(name) ?? match, ), reviewPath, @@ -367,7 +373,9 @@ async function runCodex( gid: profile.gid, stdio: "ignore", }); - if ((await exitCode(check)) !== 0) await codexDeviceLogin(profile, environment); + if ((await exitCode(check)) !== 0) { + await codexDeviceLogin(profile, environment); + } } const outputPath = await makeProfileFile(profile, "result.md"); const attempt = async (): Promise => { @@ -478,7 +486,9 @@ async function runProfile(profile: RuntimeProfile): Promise { await persistCodexAuth(stagedCodexAuth, CODEX_AUTH); } } - if (resultPath !== null) body = await readProfileFile(profile, resultPath); + if (resultPath !== null) { + body = await readProfileFile(profile, resultPath); + } if (body === null || reviewPath === null || anchorsPath === null) { throw new Error("agent attempt produced no result"); } diff --git a/src/runtime/credentials.test.ts b/src/runtime/credentials.test.ts index 9f582c3..2a41dca 100644 --- a/src/runtime/credentials.test.ts +++ b/src/runtime/credentials.test.ts @@ -4,7 +4,7 @@ import { decryptCredential, encryptCredential, parseCredentials } from "./creden const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"; -const NONCE = Uint8Array.from({ length: 12 }, (_, index) => index + 32); +const NONCE = Uint8Array.from({ length: 12 }, (_value, index) => index + 32); const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA"; test("decrypts a known AES-256-GCM credential", async () => { diff --git a/src/runtime/credentials.ts b/src/runtime/credentials.ts index d594d2f..15e0eef 100644 --- a/src/runtime/credentials.ts +++ b/src/runtime/credentials.ts @@ -47,7 +47,9 @@ export async function decryptCredential(encrypted: string, masterKey: string): P throw new Error("credential must use v1::"); } const nonce = decodeBase64Url(encodedNonce); - if (nonce.length !== 12) throw new Error("credential nonce must be 12 bytes"); + if (nonce.length !== 12) { + throw new Error("credential nonce must be 12 bytes"); + } const plaintext = await webcrypto.subtle.decrypt( { name: "AES-GCM", iv: buffer(nonce) }, await importMasterKey(masterKey, "decrypt"), @@ -61,7 +63,9 @@ export async function encryptCredential( masterKey: string, nonce: Uint8Array = webcrypto.getRandomValues(new Uint8Array(12)), ): Promise { - if (nonce.length !== 12) throw new Error("AES-GCM nonce must be 12 bytes"); + if (nonce.length !== 12) { + throw new Error("AES-GCM nonce must be 12 bytes"); + } const ciphertext = await webcrypto.subtle.encrypt( { name: "AES-GCM", iv: buffer(nonce) }, await importMasterKey(masterKey, "encrypt"), diff --git a/src/runtime/runtime.test.ts b/src/runtime/runtime.test.ts index 08a6d70..a408740 100644 --- a/src/runtime/runtime.test.ts +++ b/src/runtime/runtime.test.ts @@ -49,7 +49,8 @@ test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credenti assert(claude.tokenFile !== null); expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token"); await expectPrivate(claude.tokenFile, claude.uid, 0o600); - expect((await stat(codex.home)).uid).toStrictEqual(codex.uid); + const codexHome = await stat(codex.home); + expect(codexHome.uid).toStrictEqual(codex.uid); expect(codex.tokenFile).toBe(null); const ownToken = spawnSync("/bin/cat", [claude.tokenFile], { @@ -83,12 +84,12 @@ test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fail process.execPath, [fileURLToPath(new URL("../prepare/index.ts", import.meta.url))], { - env: { - ACTION_PATH: action, - RUNNER_TEMP: root, - BOT_AGENTS_MASTER_KEY: MASTER_KEY, - GITHUB_OUTPUT: action, - }, + env: Object.fromEntries([ + ["ACTION_PATH", action], + ["RUNNER_TEMP", root], + ["BOT_AGENTS_MASTER_KEY", MASTER_KEY], + ["GITHUB_OUTPUT", action], + ]), }, ); diff --git a/src/testing/index.ts b/src/testing/index.ts index 0284e01..ffca8a2 100644 --- a/src/testing/index.ts +++ b/src/testing/index.ts @@ -35,9 +35,11 @@ export interface CommandResult { // This process's environment as a map from variable name to value. export function inheritedEnvironment(): Map { - const result: Map = new Map(); + const result = new Map(); for (const [name, value] of Object.entries(process.env)) { - if (value !== undefined) result.set(name, value); + if (value !== undefined) { + result.set(name, value); + } } return result; } diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index 0c96fa1..e6fcac6 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -82,7 +82,8 @@ test("the source protection script stages immutable action files", async () => { const result = await runCommand("/bin/bash", ["scripts/protect-source.sh"], environment); assert(result.success, result.stderr); - const path = (await readFile(outputFile, "utf8")).trim().slice(5); + const output = await readFile(outputFile, "utf8"); + const path = output.trim().slice(5); const files = [ "src/prepare/index.ts", "src/run/index.ts", @@ -106,7 +107,7 @@ test("the source protection script stages immutable action files", async () => { test("module-relative runtime assets exist", async () => { const module = new URL("../src/run/index.ts", import.meta.url); const source = await readFile(module, "utf8"); - const assets = [...source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/gu)].map( + const assets = [...source.matchAll(/new URL\("(?[^"]+)", import\.meta\.url\)/gu)].map( ([, asset = ""]) => new URL(asset, module), ); const stats = await Promise.all(assets.map((asset) => stat(asset))); diff --git a/vite.config.ts b/vite.config.ts index 51c87fc..eb968fb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,13 +12,29 @@ export default defineConfig({ "eslint-plugin-import-structure", "./scripts/project-lint.ts", ], - categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" }, + categories: { + correctness: "error", + suspicious: "error", + pedantic: "error", + perf: "error", + style: "error", + restriction: "error", + nursery: "off", + }, rules: { ...explicitTypes.configs.concrete.rules, + ...explicitTypes.configs.naming.rules, ...explicitTypes.configs.absence.rules, ...importStructure.configs.modules.rules, ...projectLint.configs.project.rules, }, + overrides: [ + { + // Vite+ and Oxlint load these through their default export. + files: ["vite.config.ts", "scripts/project-lint.ts"], + rules: { "import/no-default-export": "off" }, + }, + ], options: { typeAware: true, typeCheck: true, -- 2.52.0 From df66bea5e4c72a1cb1377271e94f3be38cbb08e8 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Mon, 19 Oct 2026 14:20:24 +0900 Subject: [PATCH 03/20] Enable each lint plugin through its all config and share the Node code override --- scripts/project-lint.ts | 36 ++++++++++++++++++++++-------------- vite.config.ts | 12 ++++-------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/scripts/project-lint.ts b/scripts/project-lint.ts index 1b99c1f..0c747b7 100644 --- a/scripts/project-lint.ts +++ b/scripts/project-lint.ts @@ -166,11 +166,6 @@ const noAssetImportsRule: ProjectRule = { * rules it turns off, and options that differ from a rule's defaults. */ const rules: DummyRuleMap = { - // Its own rules. - "project/no-disable-comments": "error", - "project/no-asset-imports": "error", - - // Options. // The explicit-types template's `camelcase` stand-in: no `_` inside a name // once leading and trailing ones are trimmed, unless it has no lowercase // ASCII letter (UPPER_SNAKE). @@ -191,6 +186,7 @@ const rules: DummyRuleMap = { ], // Single capital type parameters, which explicit-types requires, may be one letter. "id-length": ["error", { exceptionPatterns: ["^[A-Z]$"] }], + // Option tweaks. "one-var": ["error", "never"], "no-use-before-define": ["error", { functions: false }], "func-style": ["error", "declaration", { allowArrowFunctions: true }], @@ -204,8 +200,10 @@ const rules: DummyRuleMap = { "max-statements": ["error", 32], "import/max-dependencies": ["error", { max: 13 }], "unicorn/max-nested-calls": ["error", { max: 4 }], - - // Collides with another rule, a plugin config, or Oxfmt. + // No lint suppression and only `.ts` imports, as in the plugin template. + "project/no-disable-comments": "error", + "project/no-asset-imports": "error", + // Off, collides with a repo or plugin rule, Oxfmt, or another category rule: // Collides with `#private`: members are public unless marked so. "typescript/explicit-member-accessibility": "off", // Collides with `typescript/prefer-readonly-parameter-types` being off. @@ -253,23 +251,20 @@ const rules: DummyRuleMap = { "unicorn/no-array-reduce": "off", // Collides with literal Gitea Actions `${{ }}` expressions in workflow strings. "no-template-curly-in-string": "off", - - // Node code: this action runs on Node. + // Node code, everywhere: this action runs on Node. // Node built-ins are the action's runtime. "import/no-nodejs-modules": "off", // Command-line entry points set their exit status. "unicorn/no-process-exit": "off", // The action reports to the job log through the console. "no-console": "off", - // PENDING USER: union members are values in profiles.json, the Gitea API, // and posted comments; renaming them changes behavior. "explicit-types/upper-snake-string-unions": "off", // PENDING USER: a fitting `name` needs a constructor and changes logged // error names. "unicorn/custom-error-definition": "off", - - // Too much churn. + // Off, too much churn: // Literal numbers read better than a constant per use. "no-magic-numbers": "off", // Keys follow meaning, not the alphabet. @@ -296,9 +291,22 @@ const rules: DummyRuleMap = { "no-continue": "off", }; +/** Node code (`vite.config.ts` and `scripts/`), spread into `overrides`. */ +const nodeRules: DummyRuleMap = { + // Vite and the Oxlint plugin host read a default export. + "import/no-default-export": "off", + // Node code: tooling imports Node's built-ins. + "import/no-nodejs-modules": "off", + // Node code: a CLI exits with a status. + "unicorn/no-process-exit": "off", + // Node code: a CLI reports through the console. + "no-console": "off", +}; + /** * The repo-local plugin, namespace `project`: its own rules, enabled as - * `project/`, and `configs.project`, the rule list above. + * `project/`, `configs.project`, the rule list above, and + * `configs.node`, the Node code offs. */ const plugin = { meta: { name: "project" }, @@ -306,7 +314,7 @@ const plugin = { "no-disable-comments": noDisableCommentsRule, "no-asset-imports": noAssetImportsRule, }, - configs: { project: { rules } }, + configs: { project: { rules }, node: { rules: nodeRules } }, }; export default plugin; diff --git a/vite.config.ts b/vite.config.ts index eb968fb..24bb629 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,20 +19,16 @@ export default defineConfig({ perf: "error", style: "error", restriction: "error", - nursery: "off", }, rules: { - ...explicitTypes.configs.concrete.rules, - ...explicitTypes.configs.naming.rules, - ...explicitTypes.configs.absence.rules, - ...importStructure.configs.modules.rules, + ...explicitTypes.configs.all.rules, + ...importStructure.configs.all.rules, ...projectLint.configs.project.rules, }, overrides: [ { - // Vite+ and Oxlint load these through their default export. - files: ["vite.config.ts", "scripts/project-lint.ts"], - rules: { "import/no-default-export": "off" }, + files: ["vite.config.ts", "scripts/**"], + rules: projectLint.configs.node.rules, }, ], options: { -- 2.52.0 From f2de92067ff724defa84da808e549a09bc1225e8 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Mon, 19 Oct 2026 16:49:57 +0900 Subject: [PATCH 04/20] Export the whole overrides array from project-lint and document the lint config --- .agents/memory/bot-agents-lint-opt-outs.md | 21 --------- .agents/memory/imports-aliases-barrels.md | 2 +- .agents/memory/lint-config-shape.md | 48 -------------------- .agents/memory/lint-config.md | 51 ++++++++++++++++++++++ .agents/memory/lint-overrides-policy.md | 33 -------------- .agents/memory/project-lint-plugin.md | 4 +- AGENTS.md | 8 ++-- scripts/project-lint.ts | 21 ++++++--- vite.config.ts | 9 +--- 9 files changed, 77 insertions(+), 120 deletions(-) delete mode 100644 .agents/memory/bot-agents-lint-opt-outs.md delete mode 100644 .agents/memory/lint-config-shape.md create mode 100644 .agents/memory/lint-config.md delete mode 100644 .agents/memory/lint-overrides-policy.md diff --git a/.agents/memory/bot-agents-lint-opt-outs.md b/.agents/memory/bot-agents-lint-opt-outs.md deleted file mode 100644 index 309ab3d..0000000 --- a/.agents/memory/bot-agents-lint-opt-outs.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -description: bot-agents lint has prefer-readonly-parameter-types and the size/complexity rules off (Deno had no limits) and no overrides; nothing else ---- - -The repo follows [[lint-config-shape]] with one recorded exception, present -when the migration PR was approved: - -- `typescript/prefer-readonly-parameter-types` is `"off"` in - `scripts/project-lint.ts`, as in the explicit-types template, because - Node/web types (`Response`, `Stats`, `RequestInit`) can never satisfy it. -- `vite.config.ts` has no `overrides`. The `scripts/**` override for - `typescript/no-unsafe-enum-comparison` was removed (2026-10-16): - `scripts/project-lint.ts` compares node types with `AST_NODE_TYPES` members - from `@typescript-eslint/types` instead of string literals - ([[lint-overrides-policy]]). - -Size and complexity limits match Deno, which had none here, so every Oxlint -limit rule is `"off"` ([[lint-config-shape]]). `src/run/index.ts` and `src/post/index.ts` keep -main's single-file layout (2026-10-17 revert of the size splits). Sequential -awaits in loops became recursion or a `reduce` chain (`no-await-in-loop`) to -stay sequential. Add no other opt-out without the user. diff --git a/.agents/memory/imports-aliases-barrels.md b/.agents/memory/imports-aliases-barrels.md index 3a944f4..e93f298 100644 --- a/.agents/memory/imports-aliases-barrels.md +++ b/.agents/memory/imports-aliases-barrels.md @@ -72,7 +72,7 @@ import { x } from "../x.ts"; // no: no ../ anywhere - An area used from outside exposes its API through its alias target, and callers import the alias (`#ui`), not files inside it. - Barrels are also the standard fix for `import/max-dependencies`: one barrel - import replaces many direct ones ([[lint-overrides-policy]]). + import replaces many direct ones ([[lint-config]]). - `import/no-cycle` is on; a barrel must not create a cycle. Relative imports are written with the `.ts` extension. diff --git a/.agents/memory/lint-config-shape.md b/.agents/memory/lint-config-shape.md deleted file mode 100644 index eb2b255..0000000 --- a/.agents/memory/lint-config-shape.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -description: vite.config.ts lint uses Oxlint categories at error with Deno size limits, names no rule itself, and spreads configs from the plugins and scripts/project-lint.ts ---- - -The lint config is short and category-driven (user decisions 2026-10-14/15): -turn on whole categories, then list only what parity or a real need adds. - -```ts -lint: { - plugins: ["typescript", "unicorn", "oxc", "import"], - jsPlugins: ["eslint-plugin-explicit-types", "./scripts/project-lint.ts"], - categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" }, - rules: { ...explicitTypes.configs.concrete.rules, ...projectLint.configs.project.rules }, - ignorePatterns: ["src/generated/**"], // generated files only, see below - options: { typeAware: true, typeCheck: true, maxWarnings: 0, reportUnusedDisableDirectives: "error" }, -}, -``` - -- **Categories:** `correctness`, `suspicious`, `pedantic`, `perf` at `error`. - `style` and `restriction` are never turned on wholesale; pick single rules - from them in `configs.project`. Extra rules that categories bring are fine. -- **Size limits match Deno** (2026-10-17, replaces the 2026-10-16 "default - limits stay"): repos behave as they did on Deno. bot-agents ran `deno lint` - with no config, so it had no size or complexity limit, and every Oxlint - limit rule (`complexity`, `max-lines`, `max-params`, `id-length`, - `no-nested-ternary`, `max-lines-per-function`, `max-classes-per-file`, - `max-depth`, `max-nested-callbacks`, `max-statements`, - `import/max-dependencies`, `unicorn/max-nested-calls`) is `"off"` in - `configs.project`. No file is split for size. Do not add new opt-outs - anywhere without asking the user. -- **`vite.config.ts` names no rule.** Every rule id, built-in ones included, - lives in `configs.project` of `scripts/project-lint.ts` - ([[project-lint-plugin]]). `vite.config.ts` only spreads configs. -- **Published plugins are consumed through their configs**: ESLint flat config - uses `plugin.configs.`; Oxlint lists the package in `jsPlugins` and - spreads `plugin.configs..rules`, since Oxlint plugins carry no configs. - Use the configs equivalent to what the repo enabled under Deno. -- **`maxWarnings: 0`** keeps Deno's "every diagnostic fails" behaviour. -- **`ignorePatterns`** is only for generated files (compiled output, codegen). - Hand-written code is never ignored. -- **No lint disable comments**: [[lint-no-disable-comments]]. -- **Parity:** [[lint-parity-never-shrinks]]. **Naming:** [[naming-id-match]]. -- Don't add a second import sorter; the import-structure plugin's - `enforce-import-order` is the only one, and Oxfmt's `sortImports` stays off. - -`perf` came last: in the template it flagged nine `.includes` on constant -arrays, fixed as `ReadonlySet` + `.has()` rather than by turning a rule off. -That is the expected response to any new rule: change the code. diff --git a/.agents/memory/lint-config.md b/.agents/memory/lint-config.md new file mode 100644 index 0000000..c546420 --- /dev/null +++ b/.agents/memory/lint-config.md @@ -0,0 +1,51 @@ +--- +description: Lint config shape: every non-nursery Oxlint category on, plugins via `all`, a blacklist of offs in configs.project, per-file exceptions only in projectOverrides, ignorePatterns only for generated output +--- + +Policy (2026-10-19): a blacklist, not a whitelist. Replaces the older +category-subset and "no overrides" notes. + +- **Categories:** `correctness`, `suspicious`, `pedantic`, `perf`, `style`, + `restriction` at `error`. `nursery` stays off (no line for it). +- **Plugins** are enabled whole through their `all` config + (`explicitTypes.configs.all.rules`, `importStructure.configs.all.rules`). +- **Blacklist:** repo-wide offs and option tweaks live in + `projectLint.configs.project.rules` in `scripts/project-lint.ts`, spread + last in `rules`, each with a one-line reason. Never re-list a rule a + category already turns on. bot-agents runs on Node everywhere, so + `import/no-nodejs-modules`, `unicorn/no-process-exit`, and `no-console` are + off repo-wide. +- **Per-file exceptions** only via `projectOverrides`, the whole `overrides` + array exported from `scripts/project-lint.ts`: the Node code entry + (`vite.config.ts`, `scripts/**`, `nodeRules`) first, then one entry per + exception with a why-comment. `vite.config.ts` has exactly + `overrides: projectOverrides`. +- **No `allowFiles`** options in custom rules, and no disable comments + ([[lint-no-disable-comments]]). +- **`ignorePatterns`** only for generated or build output, never hand-written + code. A hand-written file is linted, with override entries for only what it + legitimately breaks. bot-agents has no generated code, so none. +- **Precedence:** categories < `rules` (a later spread wins) < `overrides` (a + later entry wins). +- Size and complexity limits (`complexity`, `max-lines`, ...) are set to the + smallest value the current code passes. + +```ts +// scripts/project-lint.ts +export const projectOverrides: OxlintOverride[] = [ + { files: ["vite.config.ts", "scripts/**"], rules: nodeRules }, + // Why: src/example.ts must ... (a per-file exception, if one is ever needed) + // { files: ["src/example.ts"], rules: { "some-rule": "off" } }, +]; + +// vite.config.ts +rules: { + ...explicitTypes.configs.all.rules, + ...importStructure.configs.all.rules, + ...projectLint.configs.project.rules, +}, +overrides: projectOverrides, +``` + +A new category hit is fixed in the code; an off or exception is added only +for a real collision or churn, with its reason. Rules: [[project-lint-plugin]]. diff --git a/.agents/memory/lint-overrides-policy.md b/.agents/memory/lint-overrides-policy.md deleted file mode 100644 index d745855..0000000 --- a/.agents/memory/lint-overrides-policy.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -description: No lint overrides for source files; narrow ones only for scripts/**; generated files go in ignorePatterns; size limits match Deno, so nothing is split for size ---- - -Final policy (2026-10-16). The user considers per-file `overrides` bad -practice. - -- **Source files: never.** No `overrides` entry may target `src/**` or any - other hand-written production or test code. -- **The one exception** is in lint-plugin repos: `import/max-dependencies` - off for `src/rules/index.ts`, the rule registry, which must import every - rule. It is not a precedent for apps. -- **`scripts/**`:** a narrow override is allowed when a tooling script truly - needs it, e.g. `no-console` in a CLI script. -- **Generated files:** list them in `ignorePatterns`, never `overrides`. -- **Anything else needs the user's approval first.** When the user is away, - don't add it: refactor, or leave the violation failing and log the question. - -## Fix the code instead - -Category rule violations are fixed in the code without behavior change, -covered by the tests, inside the same migration PR; e.g. `prefer-set-has` -becomes `const K: ReadonlySet = new Set([...])` with `.has`. - -Size and complexity limits are not refactored for: they match what Deno -enforced (2026-10-17, replacing the earlier "refactor to Oxlint's defaults"), -which for bot-agents is none, so those rules are `"off"` -([[lint-config-shape]]). - -Alias rule: an alias may point anywhere and owns its target (a folder's -`index.ts` owns the folder and below, any other file owns only itself); no -two aliases may own the same file, and `../` is banned everywhere -([[imports-aliases-barrels]]). diff --git a/.agents/memory/project-lint-plugin.md b/.agents/memory/project-lint-plugin.md index f0af358..27df2fd 100644 --- a/.agents/memory/project-lint-plugin.md +++ b/.agents/memory/project-lint-plugin.md @@ -52,5 +52,5 @@ export default plugin; and every repo should look the same. `scripts/` is tooling, not production code: never packed or published, and -no tests. Narrow `overrides` for `scripts/**` are allowed when a script truly -needs one (e.g. `no-console`); see [[lint-overrides-policy]]. +no tests. Its offs sit in the Node code entry of `projectOverrides`; see +[[lint-config]]. diff --git a/AGENTS.md b/AGENTS.md index ec3bf99..fb855e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,9 +50,11 @@ relative imports. `scripts/` is flat: one file per tool, no subfolders and no `i ([.agents/memory/imports-aliases-barrels.md](.agents/memory/imports-aliases-barrels.md)). The only config files are `package.json`, `tsconfig.json`, and -`vite.config.ts`, plus `package-lock.json`. Repo-local lint rules and the rule -list live in `scripts/project-lint.ts`. Lint disable comments are not allowed; -fix the source instead. +`vite.config.ts`, plus `package-lock.json`. Repo-local lint rules, the rule +list, and the `overrides` array (`projectOverrides`) live in +`scripts/project-lint.ts` +([.agents/memory/lint-config.md](.agents/memory/lint-config.md)). Lint disable +comments are not allowed; fix the source instead. The composite action uses npm only to install the externally distributed Claude Code, Codex, and OpenCode command-line tools; do not treat those diff --git a/scripts/project-lint.ts b/scripts/project-lint.ts index 0c747b7..d913ba3 100644 --- a/scripts/project-lint.ts +++ b/scripts/project-lint.ts @@ -10,7 +10,7 @@ import type { LanguageOptions, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core"; import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/types"; -import type { DummyRuleMap } from "vite-plus/lint"; +import type { DummyRuleMap, OxlintOverride } from "vite-plus/lint"; /** The source code both hosts hand a rule, with the comment list. */ interface ProjectSourceCode extends TextSourceCode { @@ -291,7 +291,7 @@ const rules: DummyRuleMap = { "no-continue": "off", }; -/** Node code (`vite.config.ts` and `scripts/`), spread into `overrides`. */ +/** Node code (`vite.config.ts` and `scripts/`): the first `projectOverrides` entry. */ const nodeRules: DummyRuleMap = { // Vite and the Oxlint plugin host read a default export. "import/no-default-export": "off", @@ -303,10 +303,21 @@ const nodeRules: DummyRuleMap = { "no-console": "off", }; +/** + * The whole `overrides` array of `vite.config.ts`: the Node code entry first, + * then each per-file exception, each with a comment saying why. Later entries + * win over earlier ones, and all of them over `rules`. + */ +export const projectOverrides: OxlintOverride[] = [ + { + files: ["vite.config.ts", "scripts/**"], + rules: nodeRules, + }, +]; + /** * The repo-local plugin, namespace `project`: its own rules, enabled as - * `project/`, `configs.project`, the rule list above, and - * `configs.node`, the Node code offs. + * `project/`, and `configs.project`, the rule list above. */ const plugin = { meta: { name: "project" }, @@ -314,7 +325,7 @@ const plugin = { "no-disable-comments": noDisableCommentsRule, "no-asset-imports": noAssetImportsRule, }, - configs: { project: { rules }, node: { rules: nodeRules } }, + configs: { project: { rules } }, }; export default plugin; diff --git a/vite.config.ts b/vite.config.ts index 24bb629..c6a5295 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,7 @@ import explicitTypes from "eslint-plugin-explicit-types"; import importStructure from "eslint-plugin-import-structure"; import { defineConfig } from "vite-plus"; -import projectLint from "./scripts/project-lint.ts"; +import projectLint, { projectOverrides } from "./scripts/project-lint.ts"; export default defineConfig({ lint: { @@ -25,12 +25,7 @@ export default defineConfig({ ...importStructure.configs.all.rules, ...projectLint.configs.project.rules, }, - overrides: [ - { - files: ["vite.config.ts", "scripts/**"], - rules: projectLint.configs.node.rules, - }, - ], + overrides: projectOverrides, options: { typeAware: true, typeCheck: true, -- 2.52.0 From bd1ddb94069c9d7181b117ae72e58e500e50a801 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Mon, 19 Oct 2026 19:41:06 +0900 Subject: [PATCH 05/20] Update eslint-plugin-explicit-types to 0.7.0 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 972a900..4349f53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "@eslint/core": "^0.17.0", "@types/node": "^24.19.2", "@typescript-eslint/types": "^8.71.2", - "eslint-plugin-explicit-types": "^0.6.1", + "eslint-plugin-explicit-types": "^0.7.0", "eslint-plugin-import-structure": "^0.10.2", "vite-plus": "^1.2.0", "yaml": "^2.9.1" @@ -1890,9 +1890,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-explicit-types": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-explicit-types/-/eslint-plugin-explicit-types-0.6.1.tgz", - "integrity": "sha512-8JU2Ceoi1aLbhYo4LHwb9Eone/pJuMnzVP1ZlGJn/ETxVzJQFziRnJwOF2dRmInn9dGqvwq05dc3o5tVyTGP2Q==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-explicit-types/-/eslint-plugin-explicit-types-0.7.0.tgz", + "integrity": "sha512-TCX40NrxEKH+zXhIPvuY6e9dWjhHfAlEbkNlcyvhY7fUFG+sSIt3VMT0C/U2QGFbtVhDnV1xE7JkOlGGXzPcZQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index d73313c..1b5c593 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@eslint/core": "^0.17.0", "@types/node": "^24.19.2", "@typescript-eslint/types": "^8.71.2", - "eslint-plugin-explicit-types": "^0.6.1", + "eslint-plugin-explicit-types": "^0.7.0", "eslint-plugin-import-structure": "^0.10.2", "vite-plus": "^1.2.0", "yaml": "^2.9.1" -- 2.52.0 From a65bf353ab194a6f224cb053528607f9633e4a72 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Mon, 19 Oct 2026 19:41:06 +0900 Subject: [PATCH 06/20] Turn off no-await-in-loop, consistent-return and switch-exhaustiveness-check, turn on no-array-reduce, and restore the plain loops --- scripts/project-lint.ts | 8 +++++-- src/profiles/profiles.ts | 43 ++++++++++++++--------------------- src/run/index.ts | 22 +++++------------- src/runtime/runtime.ts | 48 ++++++++++++++++++---------------------- 4 files changed, 50 insertions(+), 71 deletions(-) diff --git a/scripts/project-lint.ts b/scripts/project-lint.ts index d913ba3..20fc6a9 100644 --- a/scripts/project-lint.ts +++ b/scripts/project-lint.ts @@ -247,8 +247,6 @@ const rules: DummyRuleMap = { // Collides with explicit-types `no-undefined-type`: a `let` that `try` // assigns would need `T | undefined`. "init-declarations": "off", - // Collides with `no-await-in-loop`: sequential steps chain through `reduce`. - "unicorn/no-array-reduce": "off", // Collides with literal Gitea Actions `${{ }}` expressions in workflow strings. "no-template-curly-in-string": "off", // Node code, everywhere: this action runs on Node. @@ -264,6 +262,12 @@ const rules: DummyRuleMap = { // PENDING USER: a fitting `name` needs a constructor and changes logged // error names. "unicorn/custom-error-definition": "off", + // Sequential awaits are intentional; workarounds cost more than they save. + "no-await-in-loop": "off", + // Not type-aware; TypeScript's `noImplicitReturns` covers it. + "consistent-return": "off", + // TypeScript's type checking covers exhaustive switches. + "typescript/switch-exhaustiveness-check": "off", // Off, too much churn: // Literal numbers read better than a constant per use. "no-magic-numbers": "off", diff --git a/src/profiles/profiles.ts b/src/profiles/profiles.ts index af19b04..90fd3fe 100644 --- a/src/profiles/profiles.ts +++ b/src/profiles/profiles.ts @@ -53,37 +53,26 @@ export function failureFooter(failures: ProfileFailure[]): string { .join(", ")}`; } -// Tries the profiles from `index` on, moving on only after a sign-in failure -// or exhausted usage, and records every profile it tried. -async function attemptFrom( - profiles: P[], - attempt: (profile: P) => Promise, - failures: ProfileFailure[], - index: number, -): Promise { - const profile = profiles[index]; - if (profile === undefined) { - throw new Error("profiles must contain at least one profile"); - } - try { - return await attempt(profile); - } catch (error) { - const reason = failureReason(error); - failures.push({ type: profile.type, model: profile.model, reason }); - if (reason === "failed" || index === profiles.length - 1) { - throw error; - } - console.error(`${profile.type} ${reason}; trying next profile`); - return attemptFrom(profiles, attempt, failures, index + 1); - } -} - -export function runProfiles( +// Tries the profiles in order, moving on only after a sign-in failure or +// exhausted usage, and records every profile it tried. +export async function runProfiles( profiles: P[], attempt: (profile: P) => Promise, failures: ProfileFailure[] = [], ): Promise { - return attemptFrom(profiles, attempt, failures, 0); + for (const [index, profile] of profiles.entries()) { + try { + return await attempt(profile); + } catch (error) { + const reason = failureReason(error); + failures.push({ type: profile.type, model: profile.model, reason }); + if (reason === "failed" || index === profiles.length - 1) { + throw error; + } + console.error(`${profile.type} ${reason}; trying next profile`); + } + } + throw new Error("profiles must contain at least one profile"); } // Environments are maps from variable name to value, converted to the object diff --git a/src/run/index.ts b/src/run/index.ts index 56ef719..8e8be96 100644 --- a/src/run/index.ts +++ b/src/run/index.ts @@ -283,21 +283,6 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string) return result.result; } -// Polls every 100 ms until the login prints something, failing if it exits first. -async function pollForInstructions( - shownText: () => string, - status: Promise, -): Promise { - if (shownText().trim() !== "") { - return; - } - const exited = await Promise.race([status.then(() => true), sleep(100, false)]); - if (exited) { - throw new AgentAuthError("AI bot login produced no instructions"); - } - await pollForInstructions(shownText, status); -} - async function codexDeviceLogin( profile: RuntimeProfile, environment: ReadonlyMap, @@ -319,7 +304,12 @@ async function codexDeviceLogin( }; const drained = Promise.all([collect(login.stdout), collect(login.stderr)]); await sleep(3000); - await pollForInstructions(() => shown, status); + while (shown.trim() === "") { + const exited = await Promise.race([status.then(() => true), sleep(100, false)]); + if (exited) { + throw new AgentAuthError("AI bot login produced no instructions"); + } + } await postAuthorComment(withProfile(shown, profile)); const [code] = await Promise.all([status, drained]); if (code !== 0) { diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index fba9fc3..cf09236 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -61,32 +61,28 @@ export async function prepareRuntime( await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0); // One profile at a time, in order, as each claims the next UID. - const profiles = await credentials.profiles.reduce>( - async (previous, profile, index) => { - const prepared = await previous; - const uid = FIRST_PROFILE_UID + index; - const home = join(runtimeDir, `profile-${index}`); - await mkdir(home, { mode: 0o700 }); - await chmod(home, 0o700); - await chown(home, uid, uid); - const tokenFile = - profile.token === undefined || profile.token === "" ? null : join(home, "token"); - if (tokenFile !== null && profile.token !== undefined) { - await writePrivate(tokenFile, profile.token, uid, uid); - } - prepared.push({ - index, - type: profile.type, - model: profile.model, - uid, - gid: uid, - home, - tokenFile, - }); - return prepared; - }, - Promise.resolve([]), - ); + const profiles: RuntimeProfile[] = []; + for (const [index, profile] of credentials.profiles.entries()) { + const uid = FIRST_PROFILE_UID + index; + const home = join(runtimeDir, `profile-${index}`); + await mkdir(home, { mode: 0o700 }); + await chmod(home, 0o700); + await chown(home, uid, uid); + const tokenFile = + profile.token === undefined || profile.token === "" ? null : join(home, "token"); + if (tokenFile !== null && profile.token !== undefined) { + await writePrivate(tokenFile, profile.token, uid, uid); + } + profiles.push({ + index, + type: profile.type, + model: profile.model, + uid, + gid: uid, + home, + tokenFile, + }); + } const manifestPath = join(runtimeDir, "manifest.json"); const manifest: RuntimeManifest = { -- 2.52.0 From d879b04e4696c30e2d13d00a6061da17708054b1 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Mon, 19 Oct 2026 19:44:35 +0900 Subject: [PATCH 07/20] Turn on promise-function-async and init-declarations and satisfy them with async functions and null-initialized lets --- scripts/project-lint.ts | 25 +++++++------- src/agents/auth.test.ts | 39 +++++++++++---------- src/agents/auth.ts | 2 +- src/agents/opencode.ts | 2 +- src/post/post.test.ts | 60 +++++++++++++++++++-------------- src/post/post.ts | 16 +++++---- src/processes/isolation.test.ts | 2 +- src/processes/isolation.ts | 5 ++- src/profiles/profiles.test.ts | 35 ++++++++++--------- src/run/index.ts | 12 +++++-- src/runtime/credentials.test.ts | 10 +++--- src/runtime/credentials.ts | 13 +++++-- src/testing/index.ts | 5 +-- tests/workflow.test.ts | 20 +++++++++-- 14 files changed, 147 insertions(+), 99 deletions(-) diff --git a/scripts/project-lint.ts b/scripts/project-lint.ts index 20fc6a9..1c4b886 100644 --- a/scripts/project-lint.ts +++ b/scripts/project-lint.ts @@ -195,7 +195,7 @@ const rules: DummyRuleMap = { complexity: ["error", 26], "max-depth": ["error", 6], "max-lines": ["error", { max: 2048, skipBlankLines: false, skipComments: false }], - "max-lines-per-function": ["error", 96], + "max-lines-per-function": ["error", 98], "max-params": ["error", 4], "max-statements": ["error", 32], "import/max-dependencies": ["error", { max: 13 }], @@ -242,11 +242,6 @@ const rules: DummyRuleMap = { "import/no-named-export": "off", // Collides with `typescript/consistent-type-imports`. "import/consistent-type-specifier-style": "off", - // Collides with `require-await`, which fails an `async` wrapper with no `await`. - "typescript/promise-function-async": "off", - // Collides with explicit-types `no-undefined-type`: a `let` that `try` - // assigns would need `T | undefined`. - "init-declarations": "off", // Collides with literal Gitea Actions `${{ }}` expressions in workflow strings. "no-template-curly-in-string": "off", // Node code, everywhere: this action runs on Node. @@ -256,18 +251,18 @@ const rules: DummyRuleMap = { "unicorn/no-process-exit": "off", // The action reports to the job log through the console. "no-console": "off", - // PENDING USER: union members are values in profiles.json, the Gitea API, - // and posted comments; renaming them changes behavior. - "explicit-types/upper-snake-string-unions": "off", - // PENDING USER: a fitting `name` needs a constructor and changes logged - // error names. - "unicorn/custom-error-definition": "off", // Sequential awaits are intentional; workarounds cost more than they save. "no-await-in-loop": "off", // Not type-aware; TypeScript's `noImplicitReturns` covers it. "consistent-return": "off", // TypeScript's type checking covers exhaustive switches. "typescript/switch-exhaustiveness-check": "off", + // PENDING USER: union members are values in profiles.json, the Gitea API, + // and posted comments; renaming them changes behavior. + "explicit-types/upper-snake-string-unions": "off", + // PENDING USER: a fitting `name` needs a constructor and changes logged + // error names. + "unicorn/custom-error-definition": "off", // Off, too much churn: // Literal numbers read better than a constant per use. "no-magic-numbers": "off", @@ -317,6 +312,12 @@ export const projectOverrides: OxlintOverride[] = [ files: ["vite.config.ts", "scripts/**"], rules: nodeRules, }, + // exitCode must return the promise it marks handled; an async wrapper's + // promise would reject unhandled before callers await it. + { + files: ["src/processes/child.ts"], + rules: { "typescript/promise-function-async": "off" }, + }, ]; /** diff --git a/src/agents/auth.test.ts b/src/agents/auth.test.ts index 71de04e..5188b4c 100644 --- a/src/agents/auth.test.ts +++ b/src/agents/auth.test.ts @@ -16,14 +16,16 @@ test("retries once after an invalid refresh token", async () => { let attempts = 0; let relogins = 0; const result = await retryInvalidToken( - () => - Promise.resolve({ + async () => { + const resolved = await Promise.resolve({ code: 1, error: (attempts += 1) === 1 ? "ERROR: invalid_refresh_token" : "still failed", - }), - () => { + }); + return resolved; + }, + async () => { relogins += 1; - return Promise.resolve(); + await Promise.resolve(); }, ); @@ -38,16 +40,17 @@ test("does not retry an unrelated failure", async () => { let attempts = 0; let relogins = 0; await retryInvalidToken( - () => { + async () => { attempts += 1; - return Promise.resolve({ + const resolved = await Promise.resolve({ code: 1, error: "rate limited", }); + return resolved; }, - () => { + async () => { relogins += 1; - return Promise.resolve(); + await Promise.resolve(); }, ); @@ -85,14 +88,16 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => { let relogins = 0; let attempts = 0; const result = await retryInvalidToken( - () => - Promise.resolve({ + async () => { + const resolved = await Promise.resolve({ code: (attempts += 1) > 1 ? 0 : 1, error: attempts === 1 ? REVOKED_CODEX.join("\n") : "", - }), - () => { + }); + return resolved; + }, + async () => { relogins += 1; - return Promise.resolve(); + await Promise.resolve(); }, ); @@ -171,10 +176,8 @@ test.skipIf(IS_WINDOWS)("rejects a symlink or malformed Codex login", async () = await writeFile(malformed, '{"tokens":{}}', { mode: 0o600 }); await symlink(canonical, linked); - await expect(() => persistCodexAuth(linked, canonical)).rejects.toThrow("regular file"); - await expect(() => persistCodexAuth(malformed, canonical)).rejects.toThrow( - "invalid Codex login", - ); + await expect(persistCodexAuth(linked, canonical)).rejects.toThrow("regular file"); + await expect(persistCodexAuth(malformed, canonical)).rejects.toThrow("invalid Codex login"); } finally { await rm(root, { recursive: true }); } diff --git a/src/agents/auth.ts b/src/agents/auth.ts index b30a9e5..22a2868 100644 --- a/src/agents/auth.ts +++ b/src/agents/auth.ts @@ -83,7 +83,7 @@ async function readCodexAuth(path: string): Promise { throw new Error("Codex login must be private"); } const text = await readFile(path, "utf8"); - let value: unknown; + let value: unknown = null; try { value = JSON.parse(text); } catch { diff --git a/src/agents/opencode.ts b/src/agents/opencode.ts index 172694c..a8bf54e 100644 --- a/src/agents/opencode.ts +++ b/src/agents/opencode.ts @@ -52,7 +52,7 @@ export function openCodeError(output: string): Error | null { if (line.trim() === "") { continue; } - let event: unknown; + let event: unknown = null; try { event = JSON.parse(line); } catch { diff --git a/src/post/post.test.ts b/src/post/post.test.ts index 2f7969c..3b6953e 100644 --- a/src/post/post.test.ts +++ b/src/post/post.test.ts @@ -126,9 +126,10 @@ function requestBody(init?: RequestInit): unknown { // A fake Gitea that records each request body and answers with `status`. function recordingFetch(bodies: unknown[], status = 201): Fetcher { - return (_url, init) => { + return async (_url, init) => { bodies.push(requestBody(init)); - return Promise.resolve(new Response(null, { status })); + const resolved = await Promise.resolve(new Response(null, { status })); + return resolved; }; } @@ -136,9 +137,10 @@ test("posts a successful issue result and removes runtime credentials", async () const result = await fixture({ ok: true, body: "done" }); const requests: RecordedRequest[] = []; - await postRuntime(result.manifestPath, context, (url, init) => { + await postRuntime(result.manifestPath, context, async (url, init) => { requests.push({ url, init }); - return Promise.resolve(new Response(null, { status: 201 })); + const resolved = await Promise.resolve(new Response(null, { status: 201 })); + return resolved; }); expect(requests.length).toBe(1); @@ -166,9 +168,12 @@ test("retries a rejected inline review without anchors", async () => { ...context, eventName: "pull_request", }, - (_url, init) => { + async (_url, init) => { bodies.push(requestBody(init)); - return Promise.resolve(new Response(null, { status: bodies.length === 1 ? 422 : 200 })); + const resolved = await Promise.resolve( + new Response(null, { status: bodies.length === 1 ? 422 : 200 }), + ); + return resolved; }, ); @@ -193,9 +198,9 @@ test("reports each tried profile and keeps the action failed", async () => { }); const bodies: unknown[] = []; - await expect(() => - postRuntime(result.manifestPath, context, recordingFetch(bodies)), - ).rejects.toThrow("agent phase failed"); + await expect(postRuntime(result.manifestPath, context, recordingFetch(bodies))).rejects.toThrow( + "agent phase failed", + ); expect(bodies).toStrictEqual([ { @@ -224,9 +229,9 @@ test("drops failure entries that are not known labels", async () => { ); const bodies: unknown[] = []; - await expect(() => - postRuntime(result.manifestPath, context, recordingFetch(bodies)), - ).rejects.toThrow("agent phase failed"); + await expect(postRuntime(result.manifestPath, context, recordingFetch(bodies))).rejects.toThrow( + "agent phase failed", + ); expect(bodies).toStrictEqual([ { @@ -240,9 +245,9 @@ test("reports a generic failure when the runner wrote no outcome", async () => { await rm(result.outcomeFile); const bodies: unknown[] = []; - await expect(() => - postRuntime(result.manifestPath, context, recordingFetch(bodies)), - ).rejects.toThrow("agent phase failed"); + await expect(postRuntime(result.manifestPath, context, recordingFetch(bodies))).rejects.toThrow( + "agent phase failed", + ); expect(bodies).toStrictEqual([{ body: "AI bot failed.\n\n> Profile: none" }]); assert(await missing(result.runtimeDir)); @@ -251,10 +256,11 @@ test("reports a generic failure when the runner wrote no outcome", async () => { test("removes runtime credentials when posting fails", async () => { const result = await fixture({ ok: true, body: "done" }); - await expect(() => - postRuntime(result.manifestPath, context, () => - Promise.resolve(new Response("failed", { status: 500 })), - ), + await expect( + postRuntime(result.manifestPath, context, async () => { + const resolved = await Promise.resolve(new Response("failed", { status: 500 })); + return resolved; + }), ).rejects.toThrow("500"); assert(await missing(result.runtimeDir)); }); @@ -263,10 +269,11 @@ test("preserves a Gitea transport failure without retrying a comment", async () const result = await fixture({ ok: true, body: "done" }); let requests = 0; - await expect(() => - postRuntime(result.manifestPath, context, () => { + await expect( + postRuntime(result.manifestPath, context, async () => { requests += 1; - return Promise.reject(new Error(`network failure ${requests}`)); + await Promise.resolve(); + throw new Error(`network failure ${requests}`); }), ).rejects.toThrow("network failure 1"); @@ -279,10 +286,11 @@ test.skipIf(IS_WINDOWS)("refuses to follow an agent artifact symlink", async () await symlink(result.reviewerFile, result.reviewPath); let requests = 0; - await expect(() => - postRuntime(result.manifestPath, context, () => { + await expect( + postRuntime(result.manifestPath, context, async () => { requests += 1; - return Promise.resolve(new Response(null, { status: 200 })); + const resolved = await Promise.resolve(new Response(null, { status: 200 })); + return resolved; }), ).rejects.toThrow("regular private file"); @@ -294,7 +302,7 @@ test("reports an attributed failure when a pull request review is missing", asyn const result = await fixture({ ok: true, body: "done" }); const bodies: unknown[] = []; - await expect(() => + await expect( postRuntime( result.manifestPath, { diff --git a/src/post/post.ts b/src/post/post.ts index a275e05..f072e04 100644 --- a/src/post/post.ts +++ b/src/post/post.ts @@ -121,7 +121,7 @@ async function readAgentArtifact( if (profile === undefined) { throw new Error(`${name}: outside profile home`); } - let stat: Stats; + let stat: Stats | null = null; try { stat = await lstat(path); } catch (error) { @@ -152,7 +152,7 @@ export async function postRuntime( const reviewerText = await readFile(manifest.reviewerFile, "utf8"); const reviewerToken = reviewerText.trim(); const request = async (path: string, body: unknown): Promise => { - let response: Response; + let response: Response | null = null; try { response = await fetcher(`${context.apiUrl}/${path}`, { method: "POST", @@ -170,12 +170,13 @@ export async function postRuntime( throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`); } }; - const postComment = (body: string): Promise => - request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, { + const postComment = async (body: string): Promise => { + await request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, { body: stripAnsi(body), }); + }; - let outcome: RuntimeOutcome; + let outcome: RuntimeOutcome | null = null; try { outcome = await readOutcome(manifest.outcomeFile); } catch (error) { @@ -201,8 +202,8 @@ export async function postRuntime( const [event, mark] = VERDICTS.get(verdict.trim()) ?? ["COMMENT", "๐Ÿ’ฌ"]; const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json"); const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath); - const postReview = (entries: Anchor[]): Promise => - request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, { + const postReview = async (entries: Anchor[]): Promise => { + await request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, { body: stripAnsi( withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile), ), @@ -214,6 +215,7 @@ export async function postRuntime( old_position: side === "old" ? line : 0, })), }); + }; await postReview(anchors).catch(async (error: unknown) => { if (anchors.length === 0) { throw error; diff --git a/src/processes/isolation.test.ts b/src/processes/isolation.test.ts index 6611172..38bb634 100644 --- a/src/processes/isolation.test.ts +++ b/src/processes/isolation.test.ts @@ -49,7 +49,7 @@ test.skipIf(IS_WINDOWS)("refuses to read a profile result symlink as root", asyn await writeFile(secret, "reviewer-secret", { mode: 0o600 }); await symlink(secret, result); - await expect(() => readProfileFile(profile(currentUid(), root), result)).rejects.toThrow( + await expect(readProfileFile(profile(currentUid(), root), result)).rejects.toThrow( "regular private file", ); } finally { diff --git a/src/processes/isolation.ts b/src/processes/isolation.ts index 32730d4..31deedf 100644 --- a/src/processes/isolation.ts +++ b/src/processes/isolation.ts @@ -17,7 +17,10 @@ async function profileProcessIds(uid: number): Promise { .filter((entry) => entry.isDirectory() && /^\d+$/u.test(entry.name)) .map((entry) => entry.name); const statuses = await Promise.all( - pids.map((pid) => readFile(`/proc/${pid}/status`, "utf8").catch(() => null)), + pids.map(async (pid) => { + const status = await readFile(`/proc/${pid}/status`, "utf8").catch(() => null); + return status; + }), ); return pids .filter((_pid, index) => { diff --git a/src/profiles/profiles.test.ts b/src/profiles/profiles.test.ts index d6bf513..e70bd67 100644 --- a/src/profiles/profiles.test.ts +++ b/src/profiles/profiles.test.ts @@ -23,12 +23,13 @@ test("falls through only after usage exhaustion", async () => { { type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "codex", model: "gpt-5.6-terra" }, ], - (profile) => { + async (profile) => { visited.push(profile.type); if (profile.type === "claude") { throw new Error("You've hit your usage limit"); } - return Promise.resolve("done"); + const resolved = await Promise.resolve("done"); + return resolved; }, ); @@ -42,11 +43,12 @@ test("recognizes Claude's weekly limit message", async () => { { type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "codex", model: "gpt-5.6-terra" }, ], - (profile) => { + async (profile) => { if (profile.type === "claude") { throw new Error("You've hit your weekly limit ยท resets 12am (UTC)"); } - return Promise.resolve("fallback"); + const resolved = await Promise.resolve("fallback"); + return resolved; }, ); @@ -55,20 +57,22 @@ test("recognizes Claude's weekly limit message", async () => { test("recognizes provider API exhaustion status", async () => { const results = await Promise.all( - ["API Error: 402 Insufficient funds", "HTTP error: 429"].map((message) => - runProfiles( + ["API Error: 402 Insufficient funds", "HTTP error: 429"].map(async (message) => { + const result = await runProfiles( [ { type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "codex", model: "gpt-5.6-terra" }, ], - (profile) => { + async (profile) => { if (profile.type === "claude") { throw new Error(message); } - return Promise.resolve("fallback"); + const resolved = await Promise.resolve("fallback"); + return resolved; }, - ), - ), + ); + return result; + }), ); expect(results).toStrictEqual(["fallback", "fallback"]); @@ -82,14 +86,15 @@ test("falls through after a sign-in failure and records each try", async () => { { type: "claude", model: "claude-sonnet-5", token: "first" }, { type: "opencode", model: "openrouter/qwen/qwen3.8-27b" }, ], - (profile) => { + async (profile) => { if (profile.type === "codex") { throw new AgentAuthError("codex sign-in failed"); } if (profile.type === "claude") { throw new Error("API Error: 429"); } - return Promise.resolve("fallback"); + const resolved = await Promise.resolve("fallback"); + return resolved; }, failures, ); @@ -107,7 +112,7 @@ test("falls through after a sign-in failure and records each try", async () => { test("throws the last sign-in failure when every profile fails", async () => { const failures: ProfileFailure[] = []; - await expect(() => + await expect( runProfiles( [ { type: "codex", model: "gpt-5.6-terra" }, @@ -129,7 +134,7 @@ test("throws the last sign-in failure when every profile fails", async () => { test("preserves an ordinary agent failure", async () => { const visited: string[] = []; - await expect(() => + await expect( runProfiles( [ { type: "claude", model: "claude-sonnet-5", token: "first" }, @@ -146,7 +151,7 @@ test("preserves an ordinary agent failure", async () => { test("does not mistake incidental quota text for exhaustion", async () => { const visited: string[] = []; - await expect(() => + await expect( runProfiles( [ { type: "claude", model: "claude-sonnet-5", token: "first" }, diff --git a/src/run/index.ts b/src/run/index.ts index 8e8be96..6fcc6dc 100644 --- a/src/run/index.ts +++ b/src/run/index.ts @@ -184,10 +184,12 @@ async function renderPrompt(profile: RuntimeProfile): Promise { if (!Array.isArray(comments)) { throw new TypeError("comments: unexpected Gitea response"); } + const rulesResponse = await gitea(RULES_PATH); + const codeRules = await rulesResponse.text(); const values = new Map([ ["COMMENT", env("COMMENT")], ["ISSUE_COMMENTS", comments.map((comment: unknown) => formatComment(comment)).join("\n")], - ["CODE_RULES", await gitea(RULES_PATH).then((response) => response.text())], + ["CODE_RULES", codeRules], ["EVENT_NAME", EVENT], ["GITEA_API_URL", API], ["GITEA_REPOSITORY", REPO], @@ -432,8 +434,12 @@ async function runOpenCode( const collected = collectOpenCodeOutput( child.stdout, child.stderr, - (chunk) => echo(process.stdout, chunk), - (chunk) => echo(process.stderr, chunk), + async (chunk) => { + await echo(process.stdout, chunk); + }, + async (chunk) => { + await echo(process.stderr, chunk); + }, ); const [code, streams] = await Promise.all([status, collected]); const error = streams.stderr.trim(); diff --git a/src/runtime/credentials.test.ts b/src/runtime/credentials.test.ts index 2a41dca..c99873d 100644 --- a/src/runtime/credentials.test.ts +++ b/src/runtime/credentials.test.ts @@ -16,11 +16,11 @@ test("encrypts a credential with a fresh supplied nonce", async () => { }); test("rejects a credential encrypted by another master key", async () => { - await expect(() => decryptCredential(ENCRYPTED, OTHER_KEY)).rejects.toThrow(); + await expect(decryptCredential(ENCRYPTED, OTHER_KEY)).rejects.toThrow(); }); test("rejects a credential with a nonstandard nonce", async () => { - await expect(() => + await expect( decryptCredential("v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", MASTER_KEY), ).rejects.toThrow("nonce must be 12 bytes"); }); @@ -57,10 +57,8 @@ test("decrypts the central reviewer token and ordered profiles", async () => { }); test("rejects malformed central credentials", async () => { - await expect(() => parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow( - "reviewer-token", - ); - await expect(() => + await expect(parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow("reviewer-token"); + await expect( parseCredentials( JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "claude" }] }), MASTER_KEY, diff --git a/src/runtime/credentials.ts b/src/runtime/credentials.ts index 15e0eef..f578886 100644 --- a/src/runtime/credentials.ts +++ b/src/runtime/credentials.ts @@ -33,12 +33,16 @@ function encodeBase64Url(value: Uint8Array): string { const buffer = (value: Uint8Array): ArrayBuffer => Uint8Array.from(value).buffer; -function importMasterKey(encoded: string, usage: webcrypto.KeyUsage): Promise { +async function importMasterKey( + encoded: string, + usage: webcrypto.KeyUsage, +): Promise { const bytes = decodeBase64Url(encoded); if (bytes.length !== 32) { throw new Error("master-key must be 32 bytes encoded as base64url"); } - return webcrypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [usage]); + const key = await webcrypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [usage]); + return key; } export async function decryptCredential(encrypted: string, masterKey: string): Promise { @@ -127,7 +131,10 @@ export async function parseCredentials(text: string, masterKey: string): Promise throw new Error("profiles must contain at least one profile"); } const parsed = await Promise.all( - profiles.map((entry: unknown, index) => parseProfile(entry, index, masterKey)), + profiles.map(async (entry: unknown, index) => { + const profile = await parseProfile(entry, index, masterKey); + return profile; + }), ); return { reviewerToken: await decryptCredential(reviewerToken, masterKey), profiles: parsed }; } diff --git a/src/testing/index.ts b/src/testing/index.ts index ffca8a2..b1ab073 100644 --- a/src/testing/index.ts +++ b/src/testing/index.ts @@ -24,8 +24,9 @@ export const IS_ROOT: boolean = currentUid() === 0; // Symlinks and POSIX modes, as these tests use them, need a Unix host. export const IS_WINDOWS: boolean = process.platform === "win32"; -export function makeTempDir(): Promise { - return mkdtemp(join(tmpdir(), "bot-agents-test-")); +export async function makeTempDir(): Promise { + const path = await mkdtemp(join(tmpdir(), "bot-agents-test-")); + return path; } export interface CommandResult { diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index e6fcac6..26d4225 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -40,7 +40,11 @@ const findStep = (steps: Mapping[], key: string, value: string): Mapping => { test("all committed YAML parses", async () => { const files = await yamlFiles(); assert(files.length > 0); - await Promise.all(files.map((file) => yaml(file))); + await Promise.all( + files.map(async (file) => { + await yaml(file); + }), + ); }); test("the action exposes only the master key and scopes it to preparation", async () => { @@ -94,7 +98,12 @@ test("the source protection script stages immutable action files", async () => { "profiles.json", "prompt.md", ]; - const stats = await Promise.all(files.map((file) => stat(`${path}/${file}`))); + const stats = await Promise.all( + files.map(async (file) => { + const info = await stat(`${path}/${file}`); + return info; + }), + ); for (const [index, info] of stats.entries()) { assert(info.isFile(), files[index]); expect(info.mode & 0o022, files[index]).toStrictEqual(0); @@ -110,7 +119,12 @@ test("module-relative runtime assets exist", async () => { const assets = [...source.matchAll(/new URL\("(?[^"]+)", import\.meta\.url\)/gu)].map( ([, asset = ""]) => new URL(asset, module), ); - const stats = await Promise.all(assets.map((asset) => stat(asset))); + const stats = await Promise.all( + assets.map(async (asset) => { + const info = await stat(asset); + return info; + }), + ); for (const [index, info] of stats.entries()) { assert(info.isFile(), assets[index]?.pathname); } -- 2.52.0 From 71150900ffd9df35bfcc6c4ac461aeeaf69b249c Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Mon, 19 Oct 2026 19:45:02 +0900 Subject: [PATCH 08/20] Turn on custom-error-definition and name the custom errors --- scripts/project-lint.ts | 3 --- src/post/post.ts | 7 ++++++- src/profiles/profiles.ts | 7 ++++++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/scripts/project-lint.ts b/scripts/project-lint.ts index 1c4b886..08d0723 100644 --- a/scripts/project-lint.ts +++ b/scripts/project-lint.ts @@ -260,9 +260,6 @@ const rules: DummyRuleMap = { // PENDING USER: union members are values in profiles.json, the Gitea API, // and posted comments; renaming them changes behavior. "explicit-types/upper-snake-string-unions": "off", - // PENDING USER: a fitting `name` needs a constructor and changes logged - // error names. - "unicorn/custom-error-definition": "off", // Off, too much churn: // Literal numbers read better than a constant per use. "no-magic-numbers": "off", diff --git a/src/post/post.ts b/src/post/post.ts index f072e04..511c9ac 100644 --- a/src/post/post.ts +++ b/src/post/post.ts @@ -36,7 +36,12 @@ const failureComment = (failures: ProfileFailure[]): string => // The `fetch` subset the reviewer posts through, which tests replace. export type Fetcher = (url: string, init: RequestInit) => Promise; -class GiteaRequestError extends Error {} +class GiteaRequestError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "GiteaRequestError"; + } +} function parseAnchors(text: string, sourcePath: string): Anchor[] { const entries: unknown = JSON.parse(text); diff --git a/src/profiles/profiles.ts b/src/profiles/profiles.ts index 90fd3fe..48edcd0 100644 --- a/src/profiles/profiles.ts +++ b/src/profiles/profiles.ts @@ -29,7 +29,12 @@ interface ProfileHome { } // Raised once an agent's credentials are rejected and any re-login is spent. -export class AgentAuthError extends Error {} +export class AgentAuthError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "AgentAuthError"; + } +} function isUsageExhausted(error: unknown): boolean { return /(?:usage|rate) limit|hit your (?:weekly )?limit|insufficient_quota|exceeded your current quota|too many requests|(?:api error|http error|http|status(?: code)?)[ :]+4(?:02|29)\b/iu.test( -- 2.52.0 From c783935936eb55da86d1f48cb421be5ea84da574 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Mon, 19 Oct 2026 19:46:31 +0900 Subject: [PATCH 09/20] Turn on upper-snake-string-unions and convert profile types, failure reasons and anchor sides at their boundaries --- profiles.json | 6 +-- prompt.md | 6 +-- scripts/project-lint.ts | 5 +-- src/post/post.test.ts | 34 ++++++++--------- src/post/post.ts | 18 ++++----- src/processes/isolation.test.ts | 2 +- src/profiles/profiles.test.ts | 68 ++++++++++++++++----------------- src/profiles/profiles.ts | 46 +++++++++++++++------- src/run/index.ts | 20 ++++++++-- src/runtime/credentials.test.ts | 14 +++---- src/runtime/credentials.ts | 28 +++++++------- src/runtime/runtime.test.ts | 4 +- tests/workflow.test.ts | 6 +-- 13 files changed, 144 insertions(+), 113 deletions(-) diff --git a/profiles.json b/profiles.json index f7ef3a0..535fe04 100644 --- a/profiles.json +++ b/profiles.json @@ -2,16 +2,16 @@ "reviewer-token": "v1:XFUvCI9PnXVaW-ig:_3S9BvXywUp29cVYGA_EzFScKwC5oHkV9x2Ld8k-V_uiG7iA8n3fmjXCArzh2Lz-JDgy3IMTHoY", "profiles": [ { - "type": "codex", + "type": "CODEX", "model": "gpt-5.6-terra" }, { - "type": "claude", + "type": "CLAUDE", "model": "claude-sonnet-5", "token": "v1:-dyfF6ZicEhjPk4C:WCuMaGd09tYjtzUCl7abloRzPITvP5C35WpR372w3O_tcaD-ls-yV5sJBdU8iIVApZVCC98sY2gOyM3o9RMa8ciJq8Fuys7KxlYOp8QiluL9AYMSQi6rEsSH71EPWnxSACgo3uDQY8bWQpUlEZvXH9O-ZN9A58dh6b3vRg" }, { - "type": "opencode", + "type": "OPENCODE", "model": "openrouter/qwen/qwen3.8-27b", "token": "v1:4qdfJCdUx9jfiabC:8X0ic_J82lPlq_R3uTLaEbQy7UmEh7BgtG0bDKAcc8FeJ0xvEwkGCKaDoLmT5nta03hgjGdcWKjDdOSMi1kSDfp8J7tRTLtMRzDT8GX8OU_PVDPVahQIAU0" } diff --git a/prompt.md b/prompt.md index 4529f72..b30464d 100644 --- a/prompt.md +++ b/prompt.md @@ -47,9 +47,9 @@ achieved. Every finding that belongs to one line of the diff goes on that line instead of into the body. Write those to `${ANCHORS_PATH}` as a JSON array, each entry -`{"path": "", "line": , "side": "new" | -"old", "body": ""}`. -`side` is `new` for a line in the head file and `old` for one only in the base +`{"path": "", "line": , "side": "NEW" | +"OLD", "body": ""}`. +`side` is `NEW` for a line in the head file and `OLD` for one only in the base file; `line` is that file's own line number, and it must be a line the diff touches, or Gitea refuses the anchor. Write the file only when there is something to anchor, and keep each body to the what, the why, and the how, with diff --git a/scripts/project-lint.ts b/scripts/project-lint.ts index 08d0723..588dcf3 100644 --- a/scripts/project-lint.ts +++ b/scripts/project-lint.ts @@ -257,9 +257,8 @@ const rules: DummyRuleMap = { "consistent-return": "off", // TypeScript's type checking covers exhaustive switches. "typescript/switch-exhaustiveness-check": "off", - // PENDING USER: union members are values in profiles.json, the Gitea API, - // and posted comments; renaming them changes behavior. - "explicit-types/upper-snake-string-unions": "off", + // A `default` on a switch over a union would hide a new member from TS. + "default-case": "off", // Off, too much churn: // Literal numbers read better than a constant per use. "no-magic-numbers": "off", diff --git a/src/post/post.test.ts b/src/post/post.test.ts index 3b6953e..0570247 100644 --- a/src/post/post.test.ts +++ b/src/post/post.test.ts @@ -65,7 +65,7 @@ function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest { profiles: [ { index: 0, - type: "claude", + type: "CLAUDE", model: "test", uid: currentUid(), gid: currentGid(), @@ -89,7 +89,7 @@ async function fixture( const outcome: RuntimeOutcome = source.ok ? { ...source, - profile: { type: "codex", model: "gpt-test" }, + profile: { type: "CODEX", model: "gpt-test" }, reviewPath: `${profileHome}/review.md`, anchorsPath: `${profileHome}/anchors.json`, } @@ -147,7 +147,7 @@ test("posts a successful issue result and removes runtime credentials", async () const [request] = requests; expect(request?.url).toStrictEqual(`${context.apiUrl}/repos/owner/repo/issues/7/comments`); expect(requestBody(request?.init)).toStrictEqual({ - body: "done\n\n> Profile: `codex` ยท `gpt-test`", + body: "done\n\n> Profile: `CODEX` ยท `gpt-test`", }); expect(request?.init?.headers).toStrictEqual({ Authorization: "token reviewer-secret", @@ -158,7 +158,7 @@ test("posts a successful issue result and removes runtime credentials", async () test("retries a rejected inline review without anchors", async () => { const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [ - { path: "run.ts", line: 1, side: "new", body: "Finding" }, + { path: "run.ts", line: 1, side: "NEW", body: "Finding" }, ]); const bodies: unknown[] = []; @@ -178,11 +178,11 @@ test("retries a rejected inline review without anchors", async () => { ); expect(bodies[0]).toMatchObject({ - comments: [{ body: "Finding\n\n> Profile: `codex` ยท `gpt-test`" }], + comments: [{ body: "Finding\n\n> Profile: `CODEX` ยท `gpt-test`" }], }); expect(bodies[1]).toMatchObject({ comments: [], - body: "โœ… Approved\nReady\n\n> Profile: `codex` ยท `gpt-test`", + body: "โœ… Approved\nReady\n\n> Profile: `CODEX` ยท `gpt-test`", }); assert(await missing(result.runtimeDir)); }); @@ -191,9 +191,9 @@ test("reports each tried profile and keeps the action failed", async () => { const result = await fixture({ ok: false, failures: [ - { type: "codex", model: "gpt-test", reason: "sign-in failed" }, - { type: "claude", model: "claude-test", reason: "usage exhausted" }, - { type: "opencode", model: "openrouter/test", reason: "failed" }, + { type: "CODEX", model: "gpt-test", reason: "SIGN_IN_FAILED" }, + { type: "CLAUDE", model: "claude-test", reason: "USAGE_EXHAUSTED" }, + { type: "OPENCODE", model: "openrouter/test", reason: "FAILED" }, ], }); const bodies: unknown[] = []; @@ -205,9 +205,9 @@ test("reports each tried profile and keeps the action failed", async () => { expect(bodies).toStrictEqual([ { body: - "AI bot failed.\n\n> Tried: `codex` ยท `gpt-test` (sign-in failed), " + - "`claude` ยท `claude-test` (usage exhausted), " + - "`opencode` ยท `openrouter/test` (failed)", + "AI bot failed.\n\n> Tried: `CODEX` ยท `gpt-test` (sign-in failed), " + + "`CLAUDE` ยท `claude-test` (usage exhausted), " + + "`OPENCODE` ยท `openrouter/test` (failed)", }, ]); assert(!JSON.stringify(bodies).includes("reviewer-secret")); @@ -221,9 +221,9 @@ test("drops failure entries that are not known labels", async () => { JSON.stringify({ ok: false, failures: [ - { type: "codex", model: "gpt-test", reason: "token sk-secret leaked" }, - { type: "shell", model: "x", reason: "failed" }, - { type: "claude", model: "claude-test", reason: "sign-in failed" }, + { type: "CODEX", model: "gpt-test", reason: "token sk-secret leaked" }, + { type: "shell", model: "x", reason: "FAILED" }, + { type: "CLAUDE", model: "claude-test", reason: "SIGN_IN_FAILED" }, ], }), ); @@ -235,7 +235,7 @@ test("drops failure entries that are not known labels", async () => { expect(bodies).toStrictEqual([ { - body: "AI bot failed.\n\n> Tried: `claude` ยท `claude-test` (sign-in failed)", + body: "AI bot failed.\n\n> Tried: `CLAUDE` ยท `claude-test` (sign-in failed)", }, ]); }); @@ -315,7 +315,7 @@ test("reports an attributed failure when a pull request review is missing", asyn expect(bodies).toStrictEqual([ { - body: "AI bot failed.\n\n> Tried: `codex` ยท `gpt-test` (failed)", + body: "AI bot failed.\n\n> Tried: `CODEX` ยท `gpt-test` (failed)", }, ]); assert(await missing(result.runtimeDir)); diff --git a/src/post/post.ts b/src/post/post.ts index 511c9ac..3886e5c 100644 --- a/src/post/post.ts +++ b/src/post/post.ts @@ -16,7 +16,7 @@ export interface PostContext { interface Anchor { path: string; line: number; - side: "new" | "old"; + side: "NEW" | "OLD"; body: string; } @@ -26,8 +26,8 @@ const VERDICTS = new Map([ ["Approved", ["APPROVED", "โœ…"]], ["Changes requested", ["REQUEST_CHANGES", "๐Ÿ›‘"]], ]); -const PROFILE_TYPES = new Set(["claude", "codex", "opencode"]); -const FAILURE_REASONS = new Set(["sign-in failed", "usage exhausted", "failed"]); +const PROFILE_TYPES = new Set(["CLAUDE", "CODEX", "OPENCODE"]); +const FAILURE_REASONS = new Set(["SIGN_IN_FAILED", "USAGE_EXHAUSTED", "FAILED"]); const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value); const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value); const failureComment = (failures: ProfileFailure[]): string => @@ -56,7 +56,7 @@ function parseAnchors(text: string, sourcePath: string): Anchor[] { const path = field(entry, "path"); const line = field(entry, "line"); const givenSide = field(entry, "side"); - const side = givenSide === undefined ? "new" : givenSide; + const side = givenSide === undefined ? "NEW" : givenSide; const body = field(entry, "body"); if (typeof path !== "string" || path === "") { throw new TypeError(`${at}.path: expected a repository path`); @@ -64,8 +64,8 @@ function parseAnchors(text: string, sourcePath: string): Anchor[] { if (typeof line !== "number" || !Number.isInteger(line) || line < 1) { throw new TypeError(`${at}.line: expected a line number`); } - if (side !== "new" && side !== "old") { - throw new Error(`${at}.side: expected "new" or "old"`); + if (side !== "NEW" && side !== "OLD") { + throw new Error(`${at}.side: expected "NEW" or "OLD"`); } if (typeof body !== "string" || body.trim() === "") { throw new TypeError(`${at}.body: expected the finding`); @@ -216,8 +216,8 @@ export async function postRuntime( comments: entries.map(({ path, line, side, body }) => ({ path, body: stripAnsi(withProfile(body, outcome.profile)), - new_position: side === "new" ? line : 0, - old_position: side === "old" ? line : 0, + new_position: side === "NEW" ? line : 0, + old_position: side === "OLD" ? line : 0, })), }); }; @@ -234,7 +234,7 @@ export async function postRuntime( if (error instanceof GiteaRequestError) { throw error; } - await postComment(failureComment([{ ...outcome.profile, reason: "failed" }])); + await postComment(failureComment([{ ...outcome.profile, reason: "FAILED" }])); throw error; } } finally { diff --git a/src/processes/isolation.test.ts b/src/processes/isolation.test.ts index 38bb634..b580a5b 100644 --- a/src/processes/isolation.test.ts +++ b/src/processes/isolation.test.ts @@ -11,7 +11,7 @@ import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from const profile = (uid: number, home: string): RuntimeProfile => ({ index: uid - 20_000, - type: "claude", + type: "CLAUDE", model: "test", uid, gid: uid, diff --git a/src/profiles/profiles.test.ts b/src/profiles/profiles.test.ts index e70bd67..5441a91 100644 --- a/src/profiles/profiles.test.ts +++ b/src/profiles/profiles.test.ts @@ -11,8 +11,8 @@ import { } from "./profiles.ts"; test("formats profile attribution as a Markdown quote", () => { - expect(profileFooter({ type: "opencode", model: "openrouter/qwen/qwen3.8-27b" })).toBe( - "> Profile: `opencode` ยท `openrouter/qwen/qwen3.8-27b`", + expect(profileFooter({ type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" })).toBe( + "> Profile: `OPENCODE` ยท `openrouter/qwen/qwen3.8-27b`", ); }); @@ -20,12 +20,12 @@ test("falls through only after usage exhaustion", async () => { const visited: string[] = []; const result = await runProfiles( [ - { type: "claude", model: "claude-sonnet-5", token: "first" }, - { type: "codex", model: "gpt-5.6-terra" }, + { type: "CLAUDE", model: "claude-sonnet-5", token: "first" }, + { type: "CODEX", model: "gpt-5.6-terra" }, ], async (profile) => { visited.push(profile.type); - if (profile.type === "claude") { + if (profile.type === "CLAUDE") { throw new Error("You've hit your usage limit"); } const resolved = await Promise.resolve("done"); @@ -34,17 +34,17 @@ test("falls through only after usage exhaustion", async () => { ); expect(result).toBe("done"); - expect(visited).toStrictEqual(["claude", "codex"]); + expect(visited).toStrictEqual(["CLAUDE", "CODEX"]); }); test("recognizes Claude's weekly limit message", async () => { const result = await runProfiles( [ - { type: "claude", model: "claude-sonnet-5", token: "first" }, - { type: "codex", model: "gpt-5.6-terra" }, + { type: "CLAUDE", model: "claude-sonnet-5", token: "first" }, + { type: "CODEX", model: "gpt-5.6-terra" }, ], async (profile) => { - if (profile.type === "claude") { + if (profile.type === "CLAUDE") { throw new Error("You've hit your weekly limit ยท resets 12am (UTC)"); } const resolved = await Promise.resolve("fallback"); @@ -60,11 +60,11 @@ test("recognizes provider API exhaustion status", async () => { ["API Error: 402 Insufficient funds", "HTTP error: 429"].map(async (message) => { const result = await runProfiles( [ - { type: "claude", model: "claude-sonnet-5", token: "first" }, - { type: "codex", model: "gpt-5.6-terra" }, + { type: "CLAUDE", model: "claude-sonnet-5", token: "first" }, + { type: "CODEX", model: "gpt-5.6-terra" }, ], async (profile) => { - if (profile.type === "claude") { + if (profile.type === "CLAUDE") { throw new Error(message); } const resolved = await Promise.resolve("fallback"); @@ -82,15 +82,15 @@ test("falls through after a sign-in failure and records each try", async () => { const failures: ProfileFailure[] = []; const result = await runProfiles( [ - { type: "codex", model: "gpt-5.6-terra" }, - { type: "claude", model: "claude-sonnet-5", token: "first" }, - { type: "opencode", model: "openrouter/qwen/qwen3.8-27b" }, + { type: "CODEX", model: "gpt-5.6-terra" }, + { type: "CLAUDE", model: "claude-sonnet-5", token: "first" }, + { type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" }, ], async (profile) => { - if (profile.type === "codex") { + if (profile.type === "CODEX") { throw new AgentAuthError("codex sign-in failed"); } - if (profile.type === "claude") { + if (profile.type === "CLAUDE") { throw new Error("API Error: 429"); } const resolved = await Promise.resolve("fallback"); @@ -101,12 +101,12 @@ test("falls through after a sign-in failure and records each try", async () => { expect(result).toBe("fallback"); expect(failures).toStrictEqual([ - { type: "codex", model: "gpt-5.6-terra", reason: "sign-in failed" }, - { type: "claude", model: "claude-sonnet-5", reason: "usage exhausted" }, + { type: "CODEX", model: "gpt-5.6-terra", reason: "SIGN_IN_FAILED" }, + { type: "CLAUDE", model: "claude-sonnet-5", reason: "USAGE_EXHAUSTED" }, ]); expect(failureFooter(failures)).toStrictEqual( - "> Tried: `codex` ยท `gpt-5.6-terra` (sign-in failed), " + - "`claude` ยท `claude-sonnet-5` (usage exhausted)", + "> Tried: `CODEX` ยท `gpt-5.6-terra` (sign-in failed), " + + "`CLAUDE` ยท `claude-sonnet-5` (usage exhausted)", ); }); @@ -115,8 +115,8 @@ test("throws the last sign-in failure when every profile fails", async () => { await expect( runProfiles( [ - { type: "codex", model: "gpt-5.6-terra" }, - { type: "claude", model: "claude-sonnet-5", token: "first" }, + { type: "CODEX", model: "gpt-5.6-terra" }, + { type: "CLAUDE", model: "claude-sonnet-5", token: "first" }, ], (profile) => { throw new AgentAuthError(`${profile.type} sign-in failed`); @@ -126,10 +126,10 @@ test("throws the last sign-in failure when every profile fails", async () => { ).rejects.toThrow( expect.toSatisfy( (error: unknown) => - error instanceof AgentAuthError && error.message.includes("claude sign-in failed"), + error instanceof AgentAuthError && error.message.includes("CLAUDE sign-in failed"), ), ); - expect(failures.map(({ reason }) => reason)).toStrictEqual(["sign-in failed", "sign-in failed"]); + expect(failures.map(({ reason }) => reason)).toStrictEqual(["SIGN_IN_FAILED", "SIGN_IN_FAILED"]); }); test("preserves an ordinary agent failure", async () => { @@ -137,8 +137,8 @@ test("preserves an ordinary agent failure", async () => { await expect( runProfiles( [ - { type: "claude", model: "claude-sonnet-5", token: "first" }, - { type: "codex", model: "gpt-5.6-terra" }, + { type: "CLAUDE", model: "claude-sonnet-5", token: "first" }, + { type: "CODEX", model: "gpt-5.6-terra" }, ], (profile) => { visited.push(profile.type); @@ -146,7 +146,7 @@ test("preserves an ordinary agent failure", async () => { }, ), ).rejects.toThrow("tests failed"); - expect(visited).toStrictEqual(["claude"]); + expect(visited).toStrictEqual(["CLAUDE"]); }); test("does not mistake incidental quota text for exhaustion", async () => { @@ -154,8 +154,8 @@ test("does not mistake incidental quota text for exhaustion", async () => { await expect( runProfiles( [ - { type: "claude", model: "claude-sonnet-5", token: "first" }, - { type: "codex", model: "gpt-5.6-terra" }, + { type: "CLAUDE", model: "claude-sonnet-5", token: "first" }, + { type: "CODEX", model: "gpt-5.6-terra" }, ], (profile) => { visited.push(profile.type); @@ -163,7 +163,7 @@ test("does not mistake incidental quota text for exhaustion", async () => { }, ), ).rejects.toThrow("quota parser test failed"); - expect(visited).toStrictEqual(["claude"]); + expect(visited).toStrictEqual(["CLAUDE"]); }); test("exposes only the selected profile credential to the agent", () => { @@ -183,7 +183,7 @@ test("exposes only the selected profile credential to the agent", () => { ]); const profile = { index: 0, - type: "claude" as const, + type: "CLAUDE" as const, model: "claude-sonnet-5", uid: 20_000, gid: 20_000, @@ -209,7 +209,7 @@ test("exposes only the selected profile credential to the agent", () => { test("uses a private Codex home without inventing an API credential", () => { const profile = { index: 1, - type: "codex" as const, + type: "CODEX" as const, model: "gpt-5.6-terra", uid: 20_001, gid: 20_001, @@ -229,7 +229,7 @@ test("uses a private Codex home without inventing an API credential", () => { test("exposes an OpenRouter key only to the OpenCode profile", () => { const profile = { index: 2, - type: "opencode" as const, + type: "OPENCODE" as const, model: "openrouter/qwen/qwen3.8-27b", uid: 20_002, gid: 20_002, diff --git a/src/profiles/profiles.ts b/src/profiles/profiles.ts index 48edcd0..2d25a17 100644 --- a/src/profiles/profiles.ts +++ b/src/profiles/profiles.ts @@ -1,6 +1,6 @@ // The agent and model a profile runs, which every report names. export interface ProfileIdentity { - type: "claude" | "codex" | "opencode"; + type: "CLAUDE" | "CODEX" | "OPENCODE"; model: string; } @@ -16,7 +16,19 @@ export function withProfile(body: string, profile: ProfileIdentity): string { return `${body}\n\n${profileFooter(profile)}`; } -export type FailureReason = "sign-in failed" | "usage exhausted" | "failed"; +export type FailureReason = "SIGN_IN_FAILED" | "USAGE_EXHAUSTED" | "FAILED"; + +// The words a failure reason reads as in logs and posted comments. +function toMessage(reason: FailureReason): string { + switch (reason) { + case "SIGN_IN_FAILED": + return "sign-in failed"; + case "USAGE_EXHAUSTED": + return "usage exhausted"; + case "FAILED": + return "failed"; + } +} export interface ProfileFailure extends ProfileIdentity { reason: FailureReason; @@ -44,9 +56,9 @@ function isUsageExhausted(error: unknown): boolean { function failureReason(error: unknown): FailureReason { if (error instanceof AgentAuthError) { - return "sign-in failed"; + return "SIGN_IN_FAILED"; } - return isUsageExhausted(error) ? "usage exhausted" : "failed"; + return isUsageExhausted(error) ? "USAGE_EXHAUSTED" : "FAILED"; } export function failureFooter(failures: ProfileFailure[]): string { @@ -54,7 +66,7 @@ export function failureFooter(failures: ProfileFailure[]): string { return "> Profile: none"; } return `> Tried: ${failures - .map(({ type, model, reason }) => `\`${type}\` ยท \`${model}\` (${reason})`) + .map(({ type, model, reason }) => `\`${type}\` ยท \`${model}\` (${toMessage(reason)})`) .join(", ")}`; } @@ -71,10 +83,10 @@ export async function runProfiles( } catch (error) { const reason = failureReason(error); failures.push({ type: profile.type, model: profile.model, reason }); - if (reason === "failed" || index === profiles.length - 1) { + if (reason === "FAILED" || index === profiles.length - 1) { throw error; } - console.error(`${profile.type} ${reason}; trying next profile`); + console.error(`${profile.type} ${toMessage(reason)}; trying next profile`); } } throw new Error("profiles must contain at least one profile"); @@ -120,6 +132,18 @@ export function sanitizedEnvironment( return result; } +// The environment variable each agent CLI reads its credential from. +function tokenVariable(type: ProfileIdentity["type"]): string { + switch (type) { + case "CLAUDE": + return "CLAUDE_CODE_OAUTH_TOKEN"; + case "CODEX": + return "OPENAI_API_KEY"; + case "OPENCODE": + return "OPENROUTER_API_KEY"; + } +} + export function agentEnvironment( environment: ReadonlyMap, profile: ProfileHome, @@ -127,15 +151,11 @@ export function agentEnvironment( ): Map { const result = sanitizedEnvironment(environment); result.set("HOME", profile.home); - if (profile.type === "codex") { + if (profile.type === "CODEX") { result.set("CODEX_HOME", `${profile.home}/.codex`); } if (token !== undefined && token !== "") { - const tokenName = { - claude: "CLAUDE_CODE_OAUTH_TOKEN", - codex: "OPENAI_API_KEY", - opencode: "OPENROUTER_API_KEY", - }[profile.type]; + const tokenName = tokenVariable(profile.type); result.set(tokenName, token); } return result; diff --git a/src/run/index.ts b/src/run/index.ts index 6fcc6dc..5d6e22b 100644 --- a/src/run/index.ts +++ b/src/run/index.ts @@ -150,6 +150,18 @@ async function runAgentCommand( } } +// The agent name the skills installer knows each profile type by. +function skillsAgent(type: RuntimeProfile["type"]): string { + switch (type) { + case "CLAUDE": + return "claude-code"; + case "CODEX": + return "codex"; + case "OPENCODE": + return "opencode"; + } +} + async function prepareProfile(profile: RuntimeProfile): Promise { await runAgentCommand(profile, "git", [ "config", @@ -163,7 +175,7 @@ async function prepareProfile(profile: RuntimeProfile): Promise { ...SKILLS_INSTALL.split(" "), "--global", "--agent", - profile.type === "claude" ? "claude-code" : profile.type, + skillsAgent(profile.type), "--yes", ]); } @@ -451,7 +463,7 @@ async function runOpenCode( async function runProfile(profile: RuntimeProfile): Promise { const token = await readProfileToken(profile); - const authentication = profile.type === "codex" ? codexAuthentication(token ?? undefined) : null; + const authentication = profile.type === "CODEX" ? codexAuthentication(token ?? undefined) : null; let stagedCodexAuth: string | null = null; let body: string | null = null; let resultPath: string | null = null; @@ -465,9 +477,9 @@ async function runProfile(profile: RuntimeProfile): Promise { const rendered = await renderPrompt(profile); reviewPath = rendered.reviewPath; anchorsPath = rendered.anchorsPath; - if (profile.type === "claude") { + if (profile.type === "CLAUDE") { body = await runClaude(profile, token ?? "", rendered.prompt); - } else if (profile.type === "codex") { + } else if (profile.type === "CODEX") { resultPath = await runCodex( profile, rendered.prompt, diff --git a/src/runtime/credentials.test.ts b/src/runtime/credentials.test.ts index c99873d..ef9b43d 100644 --- a/src/runtime/credentials.test.ts +++ b/src/runtime/credentials.test.ts @@ -31,9 +31,9 @@ test("decrypts the central reviewer token and ordered profiles", async () => { JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [ - { type: "codex", model: "gpt-custom" }, - { type: "claude", token: ENCRYPTED }, - { type: "opencode", token: ENCRYPTED }, + { type: "CODEX", model: "gpt-custom" }, + { type: "CLAUDE", token: ENCRYPTED }, + { type: "OPENCODE", token: ENCRYPTED }, ], }), MASTER_KEY, @@ -41,14 +41,14 @@ test("decrypts the central reviewer token and ordered profiles", async () => { ).toStrictEqual({ reviewerToken: "secret-token", profiles: [ - { type: "codex", model: "gpt-custom" }, + { type: "CODEX", model: "gpt-custom" }, { - type: "claude", + type: "CLAUDE", model: "claude-sonnet-5", token: "secret-token", }, { - type: "opencode", + type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b", token: "secret-token", }, @@ -60,7 +60,7 @@ test("rejects malformed central credentials", async () => { await expect(parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow("reviewer-token"); await expect( parseCredentials( - JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "claude" }] }), + JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "CLAUDE" }] }), MASTER_KEY, ), ).rejects.toThrow("profiles[0].token"); diff --git a/src/runtime/credentials.ts b/src/runtime/credentials.ts index f578886..3cf4fd0 100644 --- a/src/runtime/credentials.ts +++ b/src/runtime/credentials.ts @@ -3,18 +3,18 @@ import { webcrypto } from "node:crypto"; import { field, isMapping } from "#common"; import type { BotProfile } from "#profiles"; -interface DefaultModels { - claude: string; - codex: string; - opencode: string; +// The model a profile runs when profiles.json names none. +function defaultModel(type: BotProfile["type"]): string { + switch (type) { + case "CLAUDE": + return "claude-sonnet-5"; + case "CODEX": + return "gpt-5.6-terra"; + case "OPENCODE": + return "openrouter/qwen/qwen3.8-27b"; + } } -const DEFAULT_MODELS: DefaultModels = { - claude: "claude-sonnet-5", - codex: "gpt-5.6-terra", - opencode: "openrouter/qwen/qwen3.8-27b", -}; - export interface BotCredentials { reviewerToken: string; profiles: BotProfile[]; @@ -91,13 +91,13 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P const type = field(entry, "type"); const model = field(entry, "model"); const token = field(entry, "token"); - if (type !== "claude" && type !== "codex" && type !== "opencode") { - throw new Error(`${at}.type: expected "claude", "codex", or "opencode"`); + if (type !== "CLAUDE" && type !== "CODEX" && type !== "OPENCODE") { + throw new Error(`${at}.type: expected "CLAUDE", "CODEX", or "OPENCODE"`); } if (model !== undefined && (typeof model !== "string" || model === "")) { throw new Error(`${at}.model: expected a model name`); } - if ((type === "claude" || type === "opencode") && typeof token !== "string") { + if ((type === "CLAUDE" || type === "OPENCODE") && typeof token !== "string") { throw new Error(`${at}.token: expected an encrypted credential`); } if (token !== undefined && typeof token !== "string") { @@ -105,7 +105,7 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P } return { type, - model: model ?? DEFAULT_MODELS[type], + model: model ?? defaultModel(type), ...(typeof token === "string" && { token: await decryptCredential(token, masterKey), }), diff --git a/src/runtime/runtime.test.ts b/src/runtime/runtime.test.ts index a408740..55b6e6c 100644 --- a/src/runtime/runtime.test.ts +++ b/src/runtime/runtime.test.ts @@ -32,7 +32,7 @@ test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credenti `${action}/profiles.json`, JSON.stringify({ "reviewer-token": ENCRYPTED, - profiles: [{ type: "claude", token: ENCRYPTED }, { type: "codex" }], + profiles: [{ type: "CLAUDE", token: ENCRYPTED }, { type: "CODEX" }], }), ); @@ -77,7 +77,7 @@ test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fail `${action}/profiles.json`, JSON.stringify({ "reviewer-token": ENCRYPTED, - profiles: [{ type: "claude", token: ENCRYPTED }], + profiles: [{ type: "CLAUDE", token: ENCRYPTED }], }), ); const result = spawnSync( diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index 26d4225..9564744 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -194,9 +194,9 @@ test("central profiles fall back from Codex to Claude to OpenCode", async () => const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8")); const profiles = sequence(mapping(credentials, "profiles.json").get("profiles"), "profiles"); expect(profiles.map((profile) => profile.get("type"))).toStrictEqual([ - "codex", - "claude", - "opencode", + "CODEX", + "CLAUDE", + "OPENCODE", ]); expect(profiles[2]?.get("model")).toBe("openrouter/qwen/qwen3.8-27b"); assert(String(profiles[2]?.get("token")).startsWith("v1:")); -- 2.52.0 From a0dce6a4c204a80c37e7274fe44758383a6a26db Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Mon, 19 Oct 2026 19:48:42 +0900 Subject: [PATCH 10/20] Port the hugoalh rules, prefer-ascii and no-nan into the project lint plugin --- scripts/project-lint.ts | 549 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 542 insertions(+), 7 deletions(-) diff --git a/scripts/project-lint.ts b/scripts/project-lint.ts index 588dcf3..e2dd491 100644 --- a/scripts/project-lint.ts +++ b/scripts/project-lint.ts @@ -1,20 +1,29 @@ /** - * `project` โ€” this repository's changes to the Oxlint categories in - * `vite.config.ts`. Its rules of its own are `no-disable-comments` and - * `no-asset-imports`. + * `project` - this repository's changes to the Oxlint categories in + * `vite.config.ts`. Its rules of its own are `no-disable-comments`, + * `no-asset-imports`, `prefer-ascii`, and ports of the hugoalh `recommended` + * rules Oxlint lacks, as in eslint-plugin-import-structure's own lint. * * Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry. * * @module */ -import type { LanguageOptions, RuleDefinition, RuleVisitor, TextSourceCode } from "@eslint/core"; +import type { + LanguageOptions, + Position, + RuleDefinition, + RuleTextEdit, + RuleVisitor, + TextSourceCode, +} from "@eslint/core"; import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/types"; import type { DummyRuleMap, OxlintOverride } from "vite-plus/lint"; -/** The source code both hosts hand a rule, with the comment list. */ +/** The source code both hosts hand a rule, with the comment list and index lookup. */ interface ProjectSourceCode extends TextSourceCode { getAllComments: () => TSESTree.Comment[]; + getLocFromIndex: (index: number) => Position; } /** ESLint's default rule types, reporting TypeScript-ESTree nodes. */ @@ -38,7 +47,7 @@ const DIRECTIVE_PATTERN: RegExp = /^\s*(?:eslint|oxlint)-(?:disable|enable)/u; const RULE_NAME: string = "no-disable-comments"; /** - * `no-disable-comments` โ€” rejects every `eslint-disable*`, `eslint-enable*`, + * `no-disable-comments` - rejects every `eslint-disable*`, `eslint-enable*`, * `oxlint-disable*`, and `oxlint-enable*` comment, line or block. Oxlint has * no `noInlineConfig` and no `eslint-comments` rules, so this repo bans them * itself: when a rule fires, fix the source. @@ -68,6 +77,501 @@ const noDisableCommentsRule: ProjectRule = { }, }; +/** The highest code point `prefer-ascii` lets through. */ +const MAX_ASCII_CODE_POINT: number = 127; + +/** + * `prefer-ascii` - every character of the source is ASCII. A file is exempted + * only through an entry in `projectOverrides`. + */ +const preferAsciiRule: ProjectRule = { + meta: { + type: "suggestion", + docs: { description: "Require the source to be ASCII" }, + schema: [], + messages: { + nonAscii: "Non-ASCII character '{{character}}' (U+{{codePoint}}) is not allowed.", + }, + }, + create(context) { + return { + Program(_node: TSESTree.Program): void { + const { sourceCode } = context; + let index: number = 0; + for (const character of sourceCode.text) { + const codePoint: number = character.codePointAt(0) ?? 0; + if (codePoint > MAX_ASCII_CODE_POINT) { + context.report({ + loc: { + start: sourceCode.getLocFromIndex(index), + end: sourceCode.getLocFromIndex(index + character.length), + }, + messageId: "nonAscii", + data: { character, codePoint: codePoint.toString(16).toUpperCase().padStart(4, "0") }, + }); + } + index += character.length; + } + }, + }; + }, +}; + +/** `no-duplicate-typeofs` - `typeof typeof x` is always `"string"`. */ +const noDuplicateTypeofsRule: ProjectRule = { + meta: { + type: "problem", + docs: { description: "Disallow `typeof` applied twice in a row" }, + schema: [], + messages: { doubled: 'Multiple `typeof`s always return `"string"`, possibly not intended.' }, + }, + create(context) { + return { + UnaryExpression(node: TSESTree.UnaryExpression): void { + if ( + node.operator === "typeof" && + node.argument.type === AST_NODE_TYPES.UnaryExpression && + node.argument.operator === "typeof" + ) { + context.report({ node, messageId: "doubled" }); + } + }, + }; + }, +}; + +/** `no-empty-yield` - a `yield` with no expression, likely missing one. */ +const noEmptyYieldRule: ProjectRule = { + meta: { + type: "problem", + docs: { description: "Disallow `yield` without an expression" }, + schema: [], + messages: { empty: "Empty `yield` is not allowed, possibly missing the expression." }, + }, + create(context) { + return { + YieldExpression(node: TSESTree.YieldExpression): void { + if (node.argument === null) { + context.report({ node, messageId: "empty" }); + } + }, + }; + }, +}; + +/** The globals `no-nan` sees through, as in `globalThis.Number.NaN`. */ +const GLOBAL_NAMES: ReadonlySet = new Set(["globalThis", "self", "window"]); + +/** The name a member expression's property spells, computed or not. */ +function propertyName(node: TSESTree.MemberExpression): string | null { + if (node.property.type === AST_NODE_TYPES.Identifier && !node.computed) { + return node.property.name; + } + if (node.property.type === AST_NODE_TYPES.Literal && typeof node.property.value === "string") { + return node.property.value; + } + return null; +} + +/** Whether `node` is a global object, such as `globalThis` or `globalThis.self`. */ +function isGlobalChain(node: TSESTree.Expression): boolean { + if (node.type === AST_NODE_TYPES.Identifier) { + return GLOBAL_NAMES.has(node.name); + } + return ( + node.type === AST_NODE_TYPES.MemberExpression && + GLOBAL_NAMES.has(propertyName(node) ?? "") && + isGlobalChain(node.object) + ); +} + +/** + * Whether a member expression reads the global `NaN`: `Number.NaN`, + * `globalThis.NaN`, or `globalThis.Number.NaN`. + */ +function isNanMember(node: TSESTree.MemberExpression): boolean { + if (propertyName(node) !== "NaN") { + return false; + } + const { object } = node; + if (object.type === AST_NODE_TYPES.Identifier && object.name === "Number") { + return true; + } + return ( + isGlobalChain(object) || + (object.type === AST_NODE_TYPES.MemberExpression && + propertyName(object) === "Number" && + isGlobalChain(object.object)) + ); +} + +/** `no-nan` - a `NaN` value is usually an error; use `null` for "no number". */ +const noNanRule: ProjectRule = { + meta: { + type: "problem", + docs: { description: "Disallow `NaN` as a value" }, + schema: [], + messages: { nan: "Using `NaN` as a value is usually an error and not intended." }, + }, + create(context) { + return { + Identifier(node: TSESTree.Identifier): void { + if (node.name === "NaN" && node.parent.type !== AST_NODE_TYPES.MemberExpression) { + context.report({ node, messageId: "nan" }); + } + }, + MemberExpression(node: TSESTree.MemberExpression): void { + if (isNanMember(node)) { + context.report({ node, messageId: "nan" }); + } + }, + }; + }, +}; + +/** `no-unsafe-number` - a number literal outside the safe integer range. */ +const noUnsafeNumberRule: ProjectRule = { + meta: { + type: "problem", + docs: { description: "Disallow number literals outside the safe integer range" }, + schema: [], + messages: { + unsafe: + "Number literals with values equal to 2^53 or greater are too large to be represented accurately.", + }, + }, + create(context) { + return { + Literal(node: TSESTree.Literal): void { + if ( + typeof node.value === "number" && + (node.value < Number.MIN_SAFE_INTEGER || node.value > Number.MAX_SAFE_INTEGER) + ) { + context.report({ node, messageId: "unsafe" }); + } + }, + }; + }, +}; + +/** A loop statement, whose last `continue` `no-useless-continue` checks. */ +type LoopStatement = + | TSESTree.DoWhileStatement + | TSESTree.ForInStatement + | TSESTree.ForOfStatement + | TSESTree.ForStatement + | TSESTree.WhileStatement; + +/** A loop's trailing unlabeled `continue`, and whether removing it is a safe fix. */ +interface TrailingContinue { + statement: TSESTree.ContinueStatement; + removable: boolean; +} + +/** + * The unlabeled `continue` a loop ends with, and whether removing it is a + * safe fix: it is in a block, rather than the loop's whole body. + */ +function trailingContinue(node: LoopStatement): TrailingContinue | null { + if (node.body.type === AST_NODE_TYPES.BlockStatement) { + const last = node.body.body.at(-1); + return last?.type === AST_NODE_TYPES.ContinueStatement && last.label === null + ? { statement: last, removable: true } + : null; + } + if (node.body.type === AST_NODE_TYPES.ContinueStatement && node.body.label === null) { + return { statement: node.body, removable: false }; + } + return null; +} + +/** + * `no-useless-continue` - an unlabeled `continue` that ends a loop body. A + * trailing `continue;` in a block is removed by the fix; a loop whose whole + * body is `continue;` is only reported. + */ +const noUselessContinueRule: ProjectRule = { + meta: { + type: "suggestion", + docs: { description: "Disallow an unlabeled `continue` at the end of a loop" }, + fixable: "code", + schema: [], + messages: { + useless: "The statement `continue` at the end of the loop and without a label is useless.", + }, + }, + create(context) { + function check(node: LoopStatement): void { + const found: TrailingContinue | null = trailingContinue(node); + if (found === null) { + return; + } + if (found.removable) { + context.report({ + node: found.statement, + messageId: "useless", + fix: (fixer): RuleTextEdit => fixer.remove(found.statement), + }); + } else { + context.report({ node: found.statement, messageId: "useless" }); + } + } + return { + DoWhileStatement: check, + ForInStatement: check, + ForOfStatement: check, + ForStatement: check, + WhileStatement: check, + }; + }, +}; + +/** Node keys that say where a node is, not what it is, in ESLint and Oxlint. */ +const POSITION_KEYS: ReadonlySet = new Set([ + "parent", + "range", + "loc", + "start", + "end", +]); + +/** Node types whose member lists mean the same in any order. */ +const UNORDERED_TYPES: ReadonlySet = new Set([ + "TSInterfaceBody", + "TSTypeLiteral", + "TSUnionType", + "TSIntersectionType", +]); + +/** Type annotations too simple for an alias of their own (`no-useless-type`). */ +const SIMPLE_TYPES: ReadonlySet = new Set([ + "TSAnyKeyword", + "TSBigIntKeyword", + "TSBooleanKeyword", + "TSIntrinsicKeyword", + "TSNeverKeyword", + "TSNullKeyword", + "TSNumberKeyword", + "TSObjectKeyword", + "TSStringKeyword", + "TSSymbolKeyword", + "TSUndefinedKeyword", + "TSUnknownKeyword", + "TSVoidKeyword", +]); + +/** + * A node's shape as text, without positions, so two declarations compare + * alike wherever they sit. Members of a type literal, interface body, + * union, or intersection are sorted, since their order means nothing. + */ +function serialize(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((item: unknown) => serialize(item)).join(",")}]`; + } + if (typeof value !== "object" || value === null) { + return JSON.stringify(value) ?? "undefined"; + } + const unordered: boolean = "type" in value && UNORDERED_TYPES.has(String(value.type)); + const parts: string[] = Object.entries(value) + .filter(([key]: [string, unknown]) => !POSITION_KEYS.has(key)) + .map(([key, child]: [string, unknown]) => { + const text: string = + unordered && Array.isArray(child) + ? `[${child + .map((item: unknown) => serialize(item)) + .toSorted() + .join(",")}]` + : serialize(child); + return `${key}:${text}`; + }); + return `{${parts.join(",")}}`; +} + +/** The top-level declarations in a block, exported or not, that `matches`. */ +function declarationsOf( + statements: TSESTree.Node[], + matches: (node: TSESTree.Node) => node is T, +): T[] { + const found: T[] = []; + for (const statement of statements) { + const declaration: TSESTree.Node | null = + statement.type === AST_NODE_TYPES.ExportNamedDeclaration ? statement.declaration : statement; + if (declaration !== null && matches(declaration)) { + found.push(declaration); + } + } + return found; +} + +/** Whether a node is an interface declaration. */ +function isInterface(node: TSESTree.Node): node is TSESTree.TSInterfaceDeclaration { + return node.type === AST_NODE_TYPES.TSInterfaceDeclaration; +} + +/** Whether a node is a type alias declaration. */ +function isTypeAlias(node: TSESTree.Node): node is TSESTree.TSTypeAliasDeclaration { + return node.type === AST_NODE_TYPES.TSTypeAliasDeclaration; +} + +/** Groups nodes by a key and returns the groups with more than one node. */ +function duplicateGroups(nodes: T[], keyOf: (node: T) => string): T[][] { + const groups: Map = Map.groupBy(nodes, keyOf); + return [...groups.values()].filter((group: T[]) => group.length > 1); +} + +/** What an interface declares, its `extends` list included. */ +function interfaceShape(node: TSESTree.TSInterfaceDeclaration): string { + return `${serialize(node.extends)}&${serialize(node.body)}`; +} + +/** An interface's name. */ +function interfaceName(node: TSESTree.TSInterfaceDeclaration): string { + return node.id.name; +} + +/** What a type alias stands for. */ +function typeAliasShape(node: TSESTree.TSTypeAliasDeclaration): string { + return serialize(node.typeAnnotation); +} + +/** + * `no-duplicate-interfaces` - two interfaces in one block with the same + * name, which merge silently, or with the same shape, which could be one. + */ +const noDuplicateInterfacesRule: ProjectRule = { + meta: { + type: "suggestion", + docs: { description: "Disallow interfaces with the same name or shape in one block" }, + schema: [], + messages: { + sameShape: "Found multiple interfaces with the same shape, possibly mergeable.", + sameName: "Found multiple interfaces named '{{name}}', possibly not intended.", + }, + }, + create(context) { + function check(statements: TSESTree.Node[]): void { + const nodes: TSESTree.TSInterfaceDeclaration[] = declarationsOf(statements, isInterface); + for (const node of duplicateGroups(nodes, interfaceShape).flat()) { + context.report({ node, messageId: "sameShape" }); + } + for (const node of duplicateGroups(nodes, interfaceName).flat()) { + context.report({ node, messageId: "sameName", data: { name: node.id.name } }); + } + } + return { + BlockStatement(node: TSESTree.BlockStatement): void { + check(node.body); + }, + Program(node: TSESTree.Program): void { + check(node.body); + }, + }; + }, +}; + +/** `no-duplicate-types` - two type aliases in one block with the same type. */ +const noDuplicateTypesRule: ProjectRule = { + meta: { + type: "suggestion", + docs: { description: "Disallow type aliases with the same type in one block" }, + schema: [], + messages: { sameShape: "Found multiple types with the same shape, possibly mergeable." }, + }, + create(context) { + function check(statements: TSESTree.Node[]): void { + const nodes: TSESTree.TSTypeAliasDeclaration[] = declarationsOf(statements, isTypeAlias); + for (const node of duplicateGroups(nodes, typeAliasShape).flat()) { + context.report({ node, messageId: "sameShape" }); + } + } + return { + BlockStatement(node: TSESTree.BlockStatement): void { + check(node.body); + }, + Program(node: TSESTree.Program): void { + check(node.body); + }, + }; + }, +}; + +/** A class member that can carry the `private` keyword. */ +type AccessibilityCarrier = + | TSESTree.AccessorProperty + | TSESTree.MethodDefinition + | TSESTree.PropertyDefinition + | TSESTree.TSAbstractAccessorProperty + | TSESTree.TSAbstractMethodDefinition + | TSESTree.TSAbstractPropertyDefinition + | TSESTree.TSParameterProperty; + +/** + * `no-modifier-private` - class members are made private with `#`, never the + * `private` keyword, which only TypeScript enforces. A `private constructor` + * has no `#` form and stays allowed. + */ +const noModifierPrivateRule: ProjectRule = { + meta: { + type: "suggestion", + docs: { description: "Disallow the private modifier" }, + schema: [], + messages: { private: "Use a # private name instead of the private modifier." }, + }, + create(context) { + function check(node: AccessibilityCarrier): void { + const isConstructor: boolean = + node.type === AST_NODE_TYPES.MethodDefinition && node.kind === "constructor"; + if (node.accessibility === "private" && !isConstructor) { + context.report({ node, messageId: "private" }); + } + } + return { + AccessorProperty: check, + MethodDefinition: check, + PropertyDefinition: check, + TSAbstractAccessorProperty: check, + TSAbstractMethodDefinition: check, + TSAbstractPropertyDefinition: check, + TSParameterProperty: check, + }; + }, +}; + +/** Whether a type is too simple to need an alias: a keyword, literal, or bare name. */ +function isSimpleType(node: TSESTree.TypeNode): boolean { + if (SIMPLE_TYPES.has(node.type)) { + return true; + } + if (node.type === AST_NODE_TYPES.TSLiteralType) { + return node.literal.type === AST_NODE_TYPES.Literal; + } + return ( + node.type === AST_NODE_TYPES.TSTypeReference && + node.typeName.type === AST_NODE_TYPES.Identifier && + node.typeArguments === undefined + ); +} + +/** `no-useless-type` - a type alias for a keyword, literal, or bare type name. */ +const noUselessTypeRule: ProjectRule = { + meta: { + type: "suggestion", + docs: { description: "Disallow type aliases too simple to need a name" }, + schema: [], + messages: { simple: "The type is too simple to need an alias." }, + }, + create(context) { + return { + TSTypeAliasDeclaration(node: TSESTree.TSTypeAliasDeclaration): void { + if (isSimpleType(node.typeAnnotation)) { + context.report({ node, messageId: "simple" }); + } + }, + }; + }, +}; + /** A module specifier read as a path: relative, absolute, or a `#` alias. */ const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u; @@ -104,7 +608,7 @@ function isAssetSource(source: string): boolean { } /** - * `no-asset-imports` โ€” rejects importing anything but a script module: a + * `no-asset-imports` - rejects importing anything but a script module: a * source path, static or a dynamic `import()` with a string literal, ending in * an extension other than `.ts`, `.js`, `.mts`, or `.mjs` (`.css`, `.json`, * `.svg`, `.jsx`, `.tsx`, `.cjs`) or carrying a query string (`?raw`, `?url`), @@ -203,6 +707,17 @@ const rules: DummyRuleMap = { // No lint suppression and only `.ts` imports, as in the plugin template. "project/no-disable-comments": "error", "project/no-asset-imports": "error", + // Ports of the hugoalh `recommended` rules Oxlint lacks, and ASCII-only source. + "project/prefer-ascii": "error", + "project/no-duplicate-typeofs": "error", + "project/no-empty-yield": "error", + "project/no-nan": "error", + "project/no-unsafe-number": "error", + "project/no-useless-continue": "error", + "project/no-duplicate-interfaces": "error", + "project/no-duplicate-types": "error", + "project/no-modifier-private": "error", + "project/no-useless-type": "error", // Off, collides with a repo or plugin rule, Oxfmt, or another category rule: // Collides with `#private`: members are public unless marked so. "typescript/explicit-member-accessibility": "off", @@ -314,6 +829,16 @@ export const projectOverrides: OxlintOverride[] = [ files: ["src/processes/child.ts"], rules: { "typescript/promise-function-async": "off" }, }, + // Posted Gitea text shows emoji verdict marks and a middle dot. + { + files: [ + "src/post/post.ts", + "src/post/post.test.ts", + "src/profiles/profiles.ts", + "src/profiles/profiles.test.ts", + ], + rules: { "project/prefer-ascii": "off" }, + }, ]; /** @@ -325,6 +850,16 @@ const plugin = { rules: { "no-disable-comments": noDisableCommentsRule, "no-asset-imports": noAssetImportsRule, + "prefer-ascii": preferAsciiRule, + "no-duplicate-typeofs": noDuplicateTypeofsRule, + "no-empty-yield": noEmptyYieldRule, + "no-nan": noNanRule, + "no-unsafe-number": noUnsafeNumberRule, + "no-useless-continue": noUselessContinueRule, + "no-duplicate-interfaces": noDuplicateInterfacesRule, + "no-duplicate-types": noDuplicateTypesRule, + "no-modifier-private": noModifierPrivateRule, + "no-useless-type": noUselessTypeRule, }, configs: { project: { rules } }, }; -- 2.52.0 From 8b9aedf9118bc95cd11530331892d10c3f860e00 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Mon, 19 Oct 2026 19:49:24 +0900 Subject: [PATCH 11/20] Record the 2026-10-19 lint decisions and the UPPER_SNAKE value rule in the agent notes --- .../memory/bot-agents-migration-decisions.md | 12 ++--- .agents/memory/enum-values-upper-snake.md | 48 +++++++++++++++++++ .agents/memory/lint-config.md | 32 +++++++++++++ .agents/memory/project-lint-plugin.md | 17 +++++-- 4 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 .agents/memory/enum-values-upper-snake.md diff --git a/.agents/memory/bot-agents-migration-decisions.md b/.agents/memory/bot-agents-migration-decisions.md index 463da32..3fdc32c 100644 --- a/.agents/memory/bot-agents-migration-decisions.md +++ b/.agents/memory/bot-agents-migration-decisions.md @@ -21,13 +21,11 @@ Node + Vite+ migration (PR #20, `node-migration`, 2026-10-15/16): - **Published lint plugins** (2026-10-18, after they reached npm): `eslint-plugin-import-structure` (`modules` config) and `eslint-plugin-explicit-types` (`concrete` and `absence` configs) are - devDependencies, enabled in `vite.config.ts`. explicit-types' `naming` - config is left off: its `upper-snake-string-unions` and - `no-upper-snake-object-keys` rules fire on wire tokens this repo does not - name (profile types such as `"claude"`, failure reasons posted to Gitea, - environment variable names), which cannot change without changing - behavior. Open question for the user. `eslint-plugin-lit-robust` does not apply - (no Lit). + devDependencies, enabled in `vite.config.ts` through their `all` configs. + `upper-snake-string-unions` was off while the user decided; since + 2026-10-19 it is on and profile types, failure reasons, and anchor sides + are `UPPER_SNAKE` ([[enum-values-upper-snake]]). `eslint-plugin-lit-robust` + does not apply (no Lit). - **Stricter input checks were accepted** in the migration: Gitea `/user` and comment responses, the runtime manifest, and the outcome's profile type are validated; some type errors became `TypeError` with the same messages. diff --git a/.agents/memory/enum-values-upper-snake.md b/.agents/memory/enum-values-upper-snake.md new file mode 100644 index 0000000..954c61e --- /dev/null +++ b/.agents/memory/enum-values-upper-snake.md @@ -0,0 +1,48 @@ +--- +description: Enum-like string values are UPPER_SNAKE everywhere this repo owns the format, JSON included; external standards are converted at the boundary with explicit switch functions +--- + +Decided 2026-10-19 (all repos). An enum-like string value, a member of a +string-literal union, is `UPPER_SNAKE` wherever this repo owns the format: +TypeScript unions, `profiles.json`, the runtime outcome file, and the +`anchors.json` the agent writes per `prompt.md`. explicit-types +`upper-snake-string-unions` enforces it and stays on. + +A value an outside party defines (a CLI flag, an environment variable name, +an HTTP API field, a human-readable message) is converted at the boundary by +an explicit `switch` function, never by renaming our own values to match it: + +```ts +export interface ProfileIdentity { + type: "CLAUDE" | "CODEX" | "OPENCODE"; + model: string; +} + +// The agent name the skills installer knows each profile type by. +function skillsAgent(type: RuntimeProfile["type"]): string { + switch (type) { + case "CLAUDE": + return "claude-code"; + case "CODEX": + return "codex"; + case "OPENCODE": + return "opencode"; + } +} +``` + +In this repo: + +- Profile `type`: `CLAUDE`, `CODEX`, `OPENCODE` (also in `profiles.json`). + `skillsAgent()` (run phase) and `tokenVariable()` (profiles) map them to the + installer's agent name and the credential environment variable; + `defaultModel()` (credentials) picks a model. +- `FailureReason`: `SIGN_IN_FAILED`, `USAGE_EXHAUSTED`, `FAILED`; `toMessage()` + gives the words that logs and posted comments show. +- Anchor `side`: `NEW` or `OLD` (`prompt.md` tells the agent so); the Gitea + review call turns it into `new_position` / `old_position`. + +A lookup object keyed by enum values would need `UPPER_SNAKE` keys, which +explicit-types `no-upper-snake-object-keys` bans, so use a `switch`. TypeScript +checks such a switch for exhaustiveness through the declared return type; no +`default:` branch ([[lint-config]]). diff --git a/.agents/memory/lint-config.md b/.agents/memory/lint-config.md index c546420..751ed86 100644 --- a/.agents/memory/lint-config.md +++ b/.agents/memory/lint-config.md @@ -47,5 +47,37 @@ rules: { overrides: projectOverrides, ``` +## Rule decisions (2026-10-19, all repos) + +- `typescript/promise-function-async` is on with `require-await` and + `typescript/return-await`. A function that returns a promise is `async` + and awaits it: `async f() { const x = await g(); return x; }`. Test + doubles do the same (`const resolved = await Promise.resolve(v); return + resolved;`), and `expect(promise).rejects` takes the promise itself. + `src/processes/child.ts` is exempt in `projectOverrides`: `exitCode` must + return the very promise it marks handled, or a failed spawn would surface + as an unhandled rejection before callers await it. +- `init-declarations` is on: an uninitialized `let` becomes + `let x: T | null = null;` (`unknown` already includes `null`); a `try` that + assigns and a `catch` that exits still narrow it. +- `no-use-before-define` is on with only `{ functions: false }`. +- `no-await-in-loop` is off (sequential awaits are intentional) and + `unicorn/no-array-reduce` is on: sequential steps are plain `for` loops, + never `reduce` chains or recursion. +- `consistent-return`, `typescript/switch-exhaustiveness-check`, and + `default-case` are off: TypeScript's checks cover them, and no `default:` + branch is added to an exhaustive switch. +- `unknown` is allowed; `any` is banned and the `typescript/no-unsafe-*` + rules are on. Untrusted data is `unknown` plus a type guard. +- `unicorn/no-array-for-each` (use `for...of`), + `unicorn/prefer-structured-clone`, and `typescript/prefer-nullish-coalescing` + (defaults) stay on. +- explicit-types `upper-snake-string-unions` is on + ([[enum-values-upper-snake]]); `unicorn/custom-error-definition` is on, so a + custom error has a constructor that sets `this.name`. +- `src/post/` and `src/profiles/` (code and tests) are exempt from + `project/prefer-ascii` in `projectOverrides`: posted Gitea text shows emoji + verdict marks and a middle dot. + A new category hit is fixed in the code; an off or exception is added only for a real collision or churn, with its reason. Rules: [[project-lint-plugin]]. diff --git a/.agents/memory/project-lint-plugin.md b/.agents/memory/project-lint-plugin.md index 27df2fd..008f139 100644 --- a/.agents/memory/project-lint-plugin.md +++ b/.agents/memory/project-lint-plugin.md @@ -11,12 +11,19 @@ Oxlint plugin with `meta.name` `"project"`, loaded through `jsPlugins` in plugin configs: Deno parity rules, extras, per-rule options, and opt-outs, built-in Oxlint ids included (`curly`, `id-match`, `import/no-cycle`, ...). - **Custom rules**, enabled as `project/`. Every repo has - `no-disable-comments` and `no-asset-imports`; bot-agents has no others. + `no-disable-comments` and `no-asset-imports`. Since 2026-10-19 every repo + also has `prefer-ascii`, `no-nan` (a "no number" value is `null`, never + `NaN`), and the hugoalh `recommended` ports from + eslint-plugin-import-structure's own lint: `no-duplicate-typeofs`, + `no-empty-yield`, `no-unsafe-number`, `no-useless-continue`, + `no-duplicate-interfaces`, `no-duplicate-types`, `no-useless-type`, and + `no-modifier-private`. `no-duplicate-awaits` and `no-duplicate-voids` are + covered by `unicorn/no-unnecessary-await` and + `typescript/no-meaningless-void-operator`, and `no-enum` by explicit-types. -`scripts/` stays flat; don't add `scripts/rules/`. If the plugin ever needs -more files, they sit flat beside it and share the prefix -(`project-lint-modules.ts`, ...); `scripts/project-lint.ts` keeps the plugin -object and the rule list. +`scripts/` stays flat; don't add `scripts/rules/`. All rules live in the one +`scripts/project-lint.ts` (no file splitting), next to the plugin object and +the rule list. ```ts import type { DummyRuleMap } from "vite-plus/lint"; -- 2.52.0 From d0cf8b93cba38c96241a8d26cf80435eef3abd6b Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Mon, 19 Oct 2026 23:36:54 +0900 Subject: [PATCH 12/20] State the agreed reason for turning default-case off --- scripts/project-lint.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/project-lint.ts b/scripts/project-lint.ts index e2dd491..02ddbc0 100644 --- a/scripts/project-lint.ts +++ b/scripts/project-lint.ts @@ -772,7 +772,7 @@ const rules: DummyRuleMap = { "consistent-return": "off", // TypeScript's type checking covers exhaustive switches. "typescript/switch-exhaustiveness-check": "off", - // A `default` on a switch over a union would hide a new member from TS. + // TypeScript checks completeness; a forced `default:` hides missing cases. "default-case": "off", // Off, too much churn: // Literal numbers read better than a constant per use. -- 2.52.0 From 41a9c6258af47583d5b5c519f56645d81c37a41a Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Tue, 20 Oct 2026 01:27:48 +0900 Subject: [PATCH 13/20] Bump eslint-plugin-explicit-types to 0.7.1 and eslint-plugin-import-structure to 0.10.3 --- package-lock.json | 16 ++++++++-------- package.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4349f53..4688e66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "@eslint/core": "^0.17.0", "@types/node": "^24.19.2", "@typescript-eslint/types": "^8.71.2", - "eslint-plugin-explicit-types": "^0.7.0", - "eslint-plugin-import-structure": "^0.10.2", + "eslint-plugin-explicit-types": "^0.7.1", + "eslint-plugin-import-structure": "^0.10.3", "vite-plus": "^1.2.0", "yaml": "^2.9.1" } @@ -1890,9 +1890,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-explicit-types": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-explicit-types/-/eslint-plugin-explicit-types-0.7.0.tgz", - "integrity": "sha512-TCX40NrxEKH+zXhIPvuY6e9dWjhHfAlEbkNlcyvhY7fUFG+sSIt3VMT0C/U2QGFbtVhDnV1xE7JkOlGGXzPcZQ==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-explicit-types/-/eslint-plugin-explicit-types-0.7.1.tgz", + "integrity": "sha512-iUM3s5BNHgB/GztHMtdB5EVKAlNcHAQnFbOQT2xckXFQLLh6dFjH0YGJ/lZ3ypaIs1kVU205NCrR207kEA+edg==", "dev": true, "license": "MIT", "dependencies": { @@ -1903,9 +1903,9 @@ } }, "node_modules/eslint-plugin-import-structure": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-import-structure/-/eslint-plugin-import-structure-0.10.2.tgz", - "integrity": "sha512-jvx4sccxEdURGXrDtBJZtCHiKqELkHRVlLrMU5bVZIjglN0U464Joopcfn6JvmNB2zyIh2FkolaJQJaKkvy6Xw==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-import-structure/-/eslint-plugin-import-structure-0.10.3.tgz", + "integrity": "sha512-wt5R/G/bH8M2dXu1SvT0E4sclO59BF71hK0B7Tv/IEZIRMHyIjfuwlUqeWqHCSlmowA2N5b7NAsf9M5VNw9ZQA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 1b5c593..6e81898 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,8 @@ "@eslint/core": "^0.17.0", "@types/node": "^24.19.2", "@typescript-eslint/types": "^8.71.2", - "eslint-plugin-explicit-types": "^0.7.0", - "eslint-plugin-import-structure": "^0.10.2", + "eslint-plugin-explicit-types": "^0.7.1", + "eslint-plugin-import-structure": "^0.10.3", "vite-plus": "^1.2.0", "yaml": "^2.9.1" } -- 2.52.0 From 13efaccb17204b363dad9fbb2a8c85227b7711ab Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Tue, 20 Oct 2026 01:53:32 +0900 Subject: [PATCH 14/20] Show lowercase profile names in posted comments --- src/post/post.test.ts | 16 ++++++++-------- src/profiles/profiles.test.ts | 6 +++--- src/profiles/profiles.ts | 18 ++++++++++++++++-- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/post/post.test.ts b/src/post/post.test.ts index 0570247..74a92fd 100644 --- a/src/post/post.test.ts +++ b/src/post/post.test.ts @@ -147,7 +147,7 @@ test("posts a successful issue result and removes runtime credentials", async () const [request] = requests; expect(request?.url).toStrictEqual(`${context.apiUrl}/repos/owner/repo/issues/7/comments`); expect(requestBody(request?.init)).toStrictEqual({ - body: "done\n\n> Profile: `CODEX` ยท `gpt-test`", + body: "done\n\n> Profile: `codex` ยท `gpt-test`", }); expect(request?.init?.headers).toStrictEqual({ Authorization: "token reviewer-secret", @@ -178,11 +178,11 @@ test("retries a rejected inline review without anchors", async () => { ); expect(bodies[0]).toMatchObject({ - comments: [{ body: "Finding\n\n> Profile: `CODEX` ยท `gpt-test`" }], + comments: [{ body: "Finding\n\n> Profile: `codex` ยท `gpt-test`" }], }); expect(bodies[1]).toMatchObject({ comments: [], - body: "โœ… Approved\nReady\n\n> Profile: `CODEX` ยท `gpt-test`", + body: "โœ… Approved\nReady\n\n> Profile: `codex` ยท `gpt-test`", }); assert(await missing(result.runtimeDir)); }); @@ -205,9 +205,9 @@ test("reports each tried profile and keeps the action failed", async () => { expect(bodies).toStrictEqual([ { body: - "AI bot failed.\n\n> Tried: `CODEX` ยท `gpt-test` (sign-in failed), " + - "`CLAUDE` ยท `claude-test` (usage exhausted), " + - "`OPENCODE` ยท `openrouter/test` (failed)", + "AI bot failed.\n\n> Tried: `codex` ยท `gpt-test` (sign-in failed), " + + "`claude` ยท `claude-test` (usage exhausted), " + + "`opencode` ยท `openrouter/test` (failed)", }, ]); assert(!JSON.stringify(bodies).includes("reviewer-secret")); @@ -235,7 +235,7 @@ test("drops failure entries that are not known labels", async () => { expect(bodies).toStrictEqual([ { - body: "AI bot failed.\n\n> Tried: `CLAUDE` ยท `claude-test` (sign-in failed)", + body: "AI bot failed.\n\n> Tried: `claude` ยท `claude-test` (sign-in failed)", }, ]); }); @@ -315,7 +315,7 @@ test("reports an attributed failure when a pull request review is missing", asyn expect(bodies).toStrictEqual([ { - body: "AI bot failed.\n\n> Tried: `CODEX` ยท `gpt-test` (failed)", + body: "AI bot failed.\n\n> Tried: `codex` ยท `gpt-test` (failed)", }, ]); assert(await missing(result.runtimeDir)); diff --git a/src/profiles/profiles.test.ts b/src/profiles/profiles.test.ts index 5441a91..6879598 100644 --- a/src/profiles/profiles.test.ts +++ b/src/profiles/profiles.test.ts @@ -12,7 +12,7 @@ import { test("formats profile attribution as a Markdown quote", () => { expect(profileFooter({ type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" })).toBe( - "> Profile: `OPENCODE` ยท `openrouter/qwen/qwen3.8-27b`", + "> Profile: `opencode` ยท `openrouter/qwen/qwen3.8-27b`", ); }); @@ -105,8 +105,8 @@ test("falls through after a sign-in failure and records each try", async () => { { type: "CLAUDE", model: "claude-sonnet-5", reason: "USAGE_EXHAUSTED" }, ]); expect(failureFooter(failures)).toStrictEqual( - "> Tried: `CODEX` ยท `gpt-5.6-terra` (sign-in failed), " + - "`CLAUDE` ยท `claude-sonnet-5` (usage exhausted)", + "> Tried: `codex` ยท `gpt-5.6-terra` (sign-in failed), " + + "`claude` ยท `claude-sonnet-5` (usage exhausted)", ); }); diff --git a/src/profiles/profiles.ts b/src/profiles/profiles.ts index 2d25a17..67cb85e 100644 --- a/src/profiles/profiles.ts +++ b/src/profiles/profiles.ts @@ -8,8 +8,20 @@ export interface BotProfile extends ProfileIdentity { token?: string; } +// The lowercase name a profile type reads as in posted comments. +function profileName(type: ProfileIdentity["type"]): string { + switch (type) { + case "CLAUDE": + return "claude"; + case "CODEX": + return "codex"; + case "OPENCODE": + return "opencode"; + } +} + export function profileFooter(profile: ProfileIdentity): string { - return `> Profile: \`${profile.type}\` ยท \`${profile.model}\``; + return `> Profile: \`${profileName(profile.type)}\` ยท \`${profile.model}\``; } export function withProfile(body: string, profile: ProfileIdentity): string { @@ -66,7 +78,9 @@ export function failureFooter(failures: ProfileFailure[]): string { return "> Profile: none"; } return `> Tried: ${failures - .map(({ type, model, reason }) => `\`${type}\` ยท \`${model}\` (${toMessage(reason)})`) + .map( + ({ type, model, reason }) => `\`${profileName(type)}\` ยท \`${model}\` (${toMessage(reason)})`, + ) .join(", ")}`; } -- 2.52.0 From bcb0e0cc70ebefb921be4f94bda4f69dd8b927d8 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Tue, 20 Oct 2026 01:53:32 +0900 Subject: [PATCH 15/20] Accept lowercase profile types and anchor sides when reading --- src/post/post.ts | 7 ++++++- src/runtime/credentials.test.ts | 11 +++++++++++ src/runtime/credentials.ts | 13 ++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/post/post.ts b/src/post/post.ts index 3886e5c..90ae232 100644 --- a/src/post/post.ts +++ b/src/post/post.ts @@ -28,6 +28,11 @@ const VERDICTS = new Map([ ]); const PROFILE_TYPES = new Set(["CLAUDE", "CODEX", "OPENCODE"]); const FAILURE_REASONS = new Set(["SIGN_IN_FAILED", "USAGE_EXHAUSTED", "FAILED"]); +// Anchors written before the UPPER_SNAKE switch spell sides in lowercase. +const OLD_SIDES = new Map([ + ["new", "NEW"], + ["old", "OLD"], +]); const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value); const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value); const failureComment = (failures: ProfileFailure[]): string => @@ -56,7 +61,7 @@ function parseAnchors(text: string, sourcePath: string): Anchor[] { const path = field(entry, "path"); const line = field(entry, "line"); const givenSide = field(entry, "side"); - const side = givenSide === undefined ? "NEW" : givenSide; + const side = givenSide === undefined ? "NEW" : (OLD_SIDES.get(givenSide) ?? givenSide); const body = field(entry, "body"); if (typeof path !== "string" || path === "") { throw new TypeError(`${at}.path: expected a repository path`); diff --git a/src/runtime/credentials.test.ts b/src/runtime/credentials.test.ts index ef9b43d..2508e5d 100644 --- a/src/runtime/credentials.test.ts +++ b/src/runtime/credentials.test.ts @@ -65,3 +65,14 @@ test("rejects malformed central credentials", async () => { ), ).rejects.toThrow("profiles[0].token"); }); + +test("reads lowercase profile types from older credentials", async () => { + const credentials = await parseCredentials( + JSON.stringify({ + "reviewer-token": ENCRYPTED, + profiles: [{ type: "codex" }, { type: "claude", token: ENCRYPTED }], + }), + MASTER_KEY, + ); + expect(credentials.profiles.map(({ type }) => type)).toStrictEqual(["CODEX", "CLAUDE"]); +}); diff --git a/src/runtime/credentials.ts b/src/runtime/credentials.ts index 3cf4fd0..3e9634a 100644 --- a/src/runtime/credentials.ts +++ b/src/runtime/credentials.ts @@ -78,6 +78,17 @@ export async function encryptCredential( return `v1:${encodeBase64Url(nonce)}:${encodeBase64Url(new Uint8Array(ciphertext))}`; } +// profiles.json files written before the UPPER_SNAKE switch spell types in lowercase. +const OLD_PROFILE_TYPES = new Map([ + ["claude", "CLAUDE"], + ["codex", "CODEX"], + ["opencode", "OPENCODE"], +]); + +function currentSpelling(type: unknown): unknown { + return OLD_PROFILE_TYPES.get(type) ?? type; +} + async function parseProfile(entry: unknown, index: number, masterKey: string): Promise { const at = `profiles[${index}]`; if (!isMapping(entry)) { @@ -88,7 +99,7 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P throw new Error(`${at}.${key}: unknown field`); } } - const type = field(entry, "type"); + const type = currentSpelling(field(entry, "type")); const model = field(entry, "model"); const token = field(entry, "token"); if (type !== "CLAUDE" && type !== "CODEX" && type !== "OPENCODE") { -- 2.52.0 From 98138fc7a19dd0403d2fee4af1bc8202be9d877a Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Tue, 20 Oct 2026 01:53:32 +0900 Subject: [PATCH 16/20] Read named capture groups by name --- src/processes/isolation.ts | 4 ++-- src/run/index.ts | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/processes/isolation.ts b/src/processes/isolation.ts index 31deedf..b63d8d3 100644 --- a/src/processes/isolation.ts +++ b/src/processes/isolation.ts @@ -6,8 +6,8 @@ import { isNotFound } from "#common"; import type { RuntimeProfile } from "#runtime"; export function isRunnableProfileProcess(status: string, uid: number): boolean { - const owner = /^Uid:\s+(?\d+)/mu.exec(status)?.[1]; - const state = /^State:\s+(?\w)/mu.exec(status)?.[1]; + const owner = /^Uid:\s+(?\d+)/mu.exec(status)?.groups?.["uid"]; + const state = /^State:\s+(?\w)/mu.exec(status)?.groups?.["state"]; return owner !== undefined && Number(owner) === uid && state !== "Z"; } diff --git a/src/run/index.ts b/src/run/index.ts index 5d6e22b..bb2a3c8 100644 --- a/src/run/index.ts +++ b/src/run/index.ts @@ -211,10 +211,11 @@ async function renderPrompt(profile: RuntimeProfile): Promise { ]); const template = await readFile(new URL("../../prompt.md", import.meta.url), "utf8"); return { - prompt: template.replaceAll( - /\$\{(?\w+)\}/gu, - (match: string, name: string) => values.get(name) ?? match, - ), + prompt: template.replaceAll(/\$\{(?\w+)\}/gu, (match: string, ...args: unknown[]) => { + const groups = args.at(-1); + const name = isObject(groups) ? field(groups, "name") : undefined; + return typeof name === "string" ? (values.get(name) ?? match) : match; + }), reviewPath, anchorsPath, }; -- 2.52.0 From df09c9c7410e8e4da9c8cfbc2a8f2831fb4b9535 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Tue, 20 Oct 2026 01:53:32 +0900 Subject: [PATCH 17/20] Rewrap the testing module comment --- src/testing/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/testing/index.ts b/src/testing/index.ts index b1ab073..66752c8 100644 --- a/src/testing/index.ts +++ b/src/testing/index.ts @@ -1,7 +1,7 @@ // Test support: the current user's IDs, a fresh temporary directory, the -// inherited environment, and a plain command runner. It imports no source module, so the repository-level -// tests in tests/, which reach src/ only through `#testing`, never touch -// internals. +// inherited environment, and a plain command runner. It imports no source +// module, so the repository-level tests in tests/, which reach src/ only +// through `#testing`, never touch internals. import { spawn } from "node:child_process"; import { once } from "node:events"; import { mkdtemp } from "node:fs/promises"; -- 2.52.0 From ca619888b2af14a0ee8593bfe9f23c0ed86b5c30 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Tue, 20 Oct 2026 01:53:33 +0900 Subject: [PATCH 18/20] Drop Node override entries that repeat global rules --- scripts/project-lint.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/scripts/project-lint.ts b/scripts/project-lint.ts index 02ddbc0..270b3b7 100644 --- a/scripts/project-lint.ts +++ b/scripts/project-lint.ts @@ -805,12 +805,6 @@ const rules: DummyRuleMap = { const nodeRules: DummyRuleMap = { // Vite and the Oxlint plugin host read a default export. "import/no-default-export": "off", - // Node code: tooling imports Node's built-ins. - "import/no-nodejs-modules": "off", - // Node code: a CLI exits with a status. - "unicorn/no-process-exit": "off", - // Node code: a CLI reports through the console. - "no-console": "off", }; /** -- 2.52.0 From a429e6ffd0d20992f5a1a1cb775ec1a7415cd59e Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Tue, 20 Oct 2026 09:27:50 +0900 Subject: [PATCH 19/20] Apply batch 4 lint decisions and deno fmt settings --- .agents/memory/lint-config.md | 19 ++++ scripts/project-lint.ts | 178 ++++++++++++++++++++++++------- src/agents/auth.test.ts | 119 +++++++++++++-------- src/agents/auth.ts | 33 +++--- src/agents/index.ts | 6 +- src/agents/opencode.test.ts | 19 ++-- src/agents/opencode.ts | 4 +- src/common/json.test.ts | 4 +- src/post/post.test.ts | 67 +++++++----- src/post/post.ts | 114 ++++++++++++++------ src/processes/child.test.ts | 12 ++- src/processes/child.ts | 5 +- src/processes/isolation.test.ts | 175 ++++++++++++++++++------------- src/processes/isolation.ts | 20 +++- src/profiles/index.ts | 7 +- src/profiles/profiles.test.ts | 52 ++++++---- src/profiles/profiles.ts | 7 +- src/run/index.ts | 117 ++++++++++++++++----- src/runtime/credentials.test.ts | 29 ++++-- src/runtime/credentials.ts | 56 ++++++++-- src/runtime/runtime.test.ts | 179 ++++++++++++++++++-------------- src/runtime/runtime.ts | 39 +++++-- src/testing/index.ts | 9 +- tests/entrypoints.test.ts | 4 +- tests/workflow.test.ts | 88 ++++++++++++---- vite.config.ts | 5 + 26 files changed, 937 insertions(+), 430 deletions(-) diff --git a/.agents/memory/lint-config.md b/.agents/memory/lint-config.md index 751ed86..3d6d40a 100644 --- a/.agents/memory/lint-config.md +++ b/.agents/memory/lint-config.md @@ -79,5 +79,24 @@ overrides: projectOverrides, `project/prefer-ascii` in `projectOverrides`: posted Gitea text shows emoji verdict marks and a middle dot. +## Rule decisions (2026-10-20, all repos) + +- `unicorn/number-literal-case` is off: Oxfmt lowercases hex digits back. +- `typescript/consistent-return` is off too: it misfires on complete + switches, like `consistent-return`. +- On with defaults, no offs: `prefer-object-spread`, `unicorn/prefer-spread`, + `unicorn/no-useless-spread`, `typescript/no-misused-spread`, `no-plusplus` + (`x += 1`), `unicorn/no-array-method-this-argument`, + `typescript/method-signature-style` ("property"), + `typescript/unified-signatures`. +- `unicorn/import-style` uses its default: `import nodePath from "node:path"` + and `nodePath.join(...)` (`nodePath`, since `path` is a common variable). +- `no-template-curly-in-string` is on; only `tests/workflow.test.ts`, which + checks literal Gitea `${{ }}` expressions, is exempt in `projectOverrides`. +- Rule defaults are preferred over option tweaks unless a tweak is needed. +- `fmt` matches `deno fmt`: `printWidth: 80`, `quoteProps: "preserve"`, + `embeddedLanguageFormatting: "off"`. `max-lines-per-function` is 124 after + the narrower width. + A new category hit is fixed in the code; an off or exception is added only for a real collision or churn, with its reason. Rules: [[project-lint-plugin]]. diff --git a/scripts/project-lint.ts b/scripts/project-lint.ts index 270b3b7..af4f09a 100644 --- a/scripts/project-lint.ts +++ b/scripts/project-lint.ts @@ -62,13 +62,18 @@ const noDisableCommentsRule: ProjectRule = { type: "problem", docs: { description: "Disallow lint disable and enable comments" }, schema: [], - messages: { directive: "Lint disable comments are not allowed; fix the source." }, + messages: { + directive: "Lint disable comments are not allowed; fix the source.", + }, }, create(context) { return { Program(_node: TSESTree.Program): void { for (const comment of context.sourceCode.getAllComments()) { - if (DIRECTIVE_PATTERN.test(comment.value) && !comment.value.includes(RULE_NAME)) { + if ( + DIRECTIVE_PATTERN.test(comment.value) && + !comment.value.includes(RULE_NAME) + ) { context.report({ loc: comment.loc, messageId: "directive" }); } } @@ -90,7 +95,8 @@ const preferAsciiRule: ProjectRule = { docs: { description: "Require the source to be ASCII" }, schema: [], messages: { - nonAscii: "Non-ASCII character '{{character}}' (U+{{codePoint}}) is not allowed.", + nonAscii: + "Non-ASCII character '{{character}}' (U+{{codePoint}}) is not allowed.", }, }, create(context) { @@ -107,7 +113,13 @@ const preferAsciiRule: ProjectRule = { end: sourceCode.getLocFromIndex(index + character.length), }, messageId: "nonAscii", - data: { character, codePoint: codePoint.toString(16).toUpperCase().padStart(4, "0") }, + data: { + character, + codePoint: codePoint + .toString(16) + .toUpperCase() + .padStart(4, "0"), + }, }); } index += character.length; @@ -123,7 +135,10 @@ const noDuplicateTypeofsRule: ProjectRule = { type: "problem", docs: { description: "Disallow `typeof` applied twice in a row" }, schema: [], - messages: { doubled: 'Multiple `typeof`s always return `"string"`, possibly not intended.' }, + messages: { + doubled: + 'Multiple `typeof`s always return `"string"`, possibly not intended.', + }, }, create(context) { return { @@ -146,7 +161,9 @@ const noEmptyYieldRule: ProjectRule = { type: "problem", docs: { description: "Disallow `yield` without an expression" }, schema: [], - messages: { empty: "Empty `yield` is not allowed, possibly missing the expression." }, + messages: { + empty: "Empty `yield` is not allowed, possibly missing the expression.", + }, }, create(context) { return { @@ -160,14 +177,21 @@ const noEmptyYieldRule: ProjectRule = { }; /** The globals `no-nan` sees through, as in `globalThis.Number.NaN`. */ -const GLOBAL_NAMES: ReadonlySet = new Set(["globalThis", "self", "window"]); +const GLOBAL_NAMES: ReadonlySet = new Set([ + "globalThis", + "self", + "window", +]); /** The name a member expression's property spells, computed or not. */ function propertyName(node: TSESTree.MemberExpression): string | null { if (node.property.type === AST_NODE_TYPES.Identifier && !node.computed) { return node.property.name; } - if (node.property.type === AST_NODE_TYPES.Literal && typeof node.property.value === "string") { + if ( + node.property.type === AST_NODE_TYPES.Literal && + typeof node.property.value === "string" + ) { return node.property.value; } return null; @@ -211,12 +235,17 @@ const noNanRule: ProjectRule = { type: "problem", docs: { description: "Disallow `NaN` as a value" }, schema: [], - messages: { nan: "Using `NaN` as a value is usually an error and not intended." }, + messages: { + nan: "Using `NaN` as a value is usually an error and not intended.", + }, }, create(context) { return { Identifier(node: TSESTree.Identifier): void { - if (node.name === "NaN" && node.parent.type !== AST_NODE_TYPES.MemberExpression) { + if ( + node.name === "NaN" && + node.parent.type !== AST_NODE_TYPES.MemberExpression + ) { context.report({ node, messageId: "nan" }); } }, @@ -233,7 +262,9 @@ const noNanRule: ProjectRule = { const noUnsafeNumberRule: ProjectRule = { meta: { type: "problem", - docs: { description: "Disallow number literals outside the safe integer range" }, + docs: { + description: "Disallow number literals outside the safe integer range", + }, schema: [], messages: { unsafe: @@ -245,7 +276,8 @@ const noUnsafeNumberRule: ProjectRule = { Literal(node: TSESTree.Literal): void { if ( typeof node.value === "number" && - (node.value < Number.MIN_SAFE_INTEGER || node.value > Number.MAX_SAFE_INTEGER) + (node.value < Number.MIN_SAFE_INTEGER || + node.value > Number.MAX_SAFE_INTEGER) ) { context.report({ node, messageId: "unsafe" }); } @@ -275,11 +307,15 @@ interface TrailingContinue { function trailingContinue(node: LoopStatement): TrailingContinue | null { if (node.body.type === AST_NODE_TYPES.BlockStatement) { const last = node.body.body.at(-1); - return last?.type === AST_NODE_TYPES.ContinueStatement && last.label === null + return last?.type === AST_NODE_TYPES.ContinueStatement && + last.label === null ? { statement: last, removable: true } : null; } - if (node.body.type === AST_NODE_TYPES.ContinueStatement && node.body.label === null) { + if ( + node.body.type === AST_NODE_TYPES.ContinueStatement && + node.body.label === null + ) { return { statement: node.body, removable: false }; } return null; @@ -293,11 +329,14 @@ function trailingContinue(node: LoopStatement): TrailingContinue | null { const noUselessContinueRule: ProjectRule = { meta: { type: "suggestion", - docs: { description: "Disallow an unlabeled `continue` at the end of a loop" }, + docs: { + description: "Disallow an unlabeled `continue` at the end of a loop", + }, fixable: "code", schema: [], messages: { - useless: "The statement `continue` at the end of the loop and without a label is useless.", + useless: + "The statement `continue` at the end of the loop and without a label is useless.", }, }, create(context) { @@ -372,7 +411,8 @@ function serialize(value: unknown): string { if (typeof value !== "object" || value === null) { return JSON.stringify(value) ?? "undefined"; } - const unordered: boolean = "type" in value && UNORDERED_TYPES.has(String(value.type)); + const unordered: boolean = + "type" in value && UNORDERED_TYPES.has(String(value.type)); const parts: string[] = Object.entries(value) .filter(([key]: [string, unknown]) => !POSITION_KEYS.has(key)) .map(([key, child]: [string, unknown]) => { @@ -396,7 +436,9 @@ function declarationsOf( const found: T[] = []; for (const statement of statements) { const declaration: TSESTree.Node | null = - statement.type === AST_NODE_TYPES.ExportNamedDeclaration ? statement.declaration : statement; + statement.type === AST_NODE_TYPES.ExportNamedDeclaration + ? statement.declaration + : statement; if (declaration !== null && matches(declaration)) { found.push(declaration); } @@ -405,12 +447,16 @@ function declarationsOf( } /** Whether a node is an interface declaration. */ -function isInterface(node: TSESTree.Node): node is TSESTree.TSInterfaceDeclaration { +function isInterface( + node: TSESTree.Node, +): node is TSESTree.TSInterfaceDeclaration { return node.type === AST_NODE_TYPES.TSInterfaceDeclaration; } /** Whether a node is a type alias declaration. */ -function isTypeAlias(node: TSESTree.Node): node is TSESTree.TSTypeAliasDeclaration { +function isTypeAlias( + node: TSESTree.Node, +): node is TSESTree.TSTypeAliasDeclaration { return node.type === AST_NODE_TYPES.TSTypeAliasDeclaration; } @@ -442,21 +488,33 @@ function typeAliasShape(node: TSESTree.TSTypeAliasDeclaration): string { const noDuplicateInterfacesRule: ProjectRule = { meta: { type: "suggestion", - docs: { description: "Disallow interfaces with the same name or shape in one block" }, + docs: { + description: + "Disallow interfaces with the same name or shape in one block", + }, schema: [], messages: { - sameShape: "Found multiple interfaces with the same shape, possibly mergeable.", - sameName: "Found multiple interfaces named '{{name}}', possibly not intended.", + sameShape: + "Found multiple interfaces with the same shape, possibly mergeable.", + sameName: + "Found multiple interfaces named '{{name}}', possibly not intended.", }, }, create(context) { function check(statements: TSESTree.Node[]): void { - const nodes: TSESTree.TSInterfaceDeclaration[] = declarationsOf(statements, isInterface); + const nodes: TSESTree.TSInterfaceDeclaration[] = declarationsOf( + statements, + isInterface, + ); for (const node of duplicateGroups(nodes, interfaceShape).flat()) { context.report({ node, messageId: "sameShape" }); } for (const node of duplicateGroups(nodes, interfaceName).flat()) { - context.report({ node, messageId: "sameName", data: { name: node.id.name } }); + context.report({ + node, + messageId: "sameName", + data: { name: node.id.name }, + }); } } return { @@ -474,13 +532,21 @@ const noDuplicateInterfacesRule: ProjectRule = { const noDuplicateTypesRule: ProjectRule = { meta: { type: "suggestion", - docs: { description: "Disallow type aliases with the same type in one block" }, + docs: { + description: "Disallow type aliases with the same type in one block", + }, schema: [], - messages: { sameShape: "Found multiple types with the same shape, possibly mergeable." }, + messages: { + sameShape: + "Found multiple types with the same shape, possibly mergeable.", + }, }, create(context) { function check(statements: TSESTree.Node[]): void { - const nodes: TSESTree.TSTypeAliasDeclaration[] = declarationsOf(statements, isTypeAlias); + const nodes: TSESTree.TSTypeAliasDeclaration[] = declarationsOf( + statements, + isTypeAlias, + ); for (const node of duplicateGroups(nodes, typeAliasShape).flat()) { context.report({ node, messageId: "sameShape" }); } @@ -516,12 +582,15 @@ const noModifierPrivateRule: ProjectRule = { type: "suggestion", docs: { description: "Disallow the private modifier" }, schema: [], - messages: { private: "Use a # private name instead of the private modifier." }, + messages: { + private: "Use a # private name instead of the private modifier.", + }, }, create(context) { function check(node: AccessibilityCarrier): void { const isConstructor: boolean = - node.type === AST_NODE_TYPES.MethodDefinition && node.kind === "constructor"; + node.type === AST_NODE_TYPES.MethodDefinition && + node.kind === "constructor"; if (node.accessibility === "private" && !isConstructor) { context.report({ node, messageId: "private" }); } @@ -580,7 +649,12 @@ const PATH_SPECIFIER_PATTERN: RegExp = /^[./#]/u; * `.mts`, or `.mjs` package subpaths. `.cts`, `.cjs`, `.tsx`, and `.jsx` are * not among them. */ -const SCRIPT_EXTENSIONS: ReadonlySet = new Set([".ts", ".js", ".mts", ".mjs"]); +const SCRIPT_EXTENSIONS: ReadonlySet = new Set([ + ".ts", + ".js", + ".mts", + ".mjs", +]); /** * Whether `source` names an asset rather than a script module: a query string @@ -600,7 +674,11 @@ function isAssetSource(source: string): boolean { } } const lastSegment: string = segments.at(-1) ?? ""; - if (lastSegment === "." || lastSegment === ".." || !lastSegment.includes(".")) { + if ( + lastSegment === "." || + lastSegment === ".." || + !lastSegment.includes(".") + ) { return false; } const extension: string = lastSegment.slice(lastSegment.lastIndexOf(".")); @@ -621,7 +699,10 @@ function isAssetSource(source: string): boolean { const noAssetImportsRule: ProjectRule = { meta: { type: "problem", - docs: { description: "Disallow importing assets instead of .ts, .js, .mts, or .mjs modules" }, + docs: { + description: + "Disallow importing assets instead of .ts, .js, .mts, or .mjs modules", + }, schema: [], messages: { extension: "Only .ts, .js, .mts and .mjs modules may be imported.", @@ -653,7 +734,10 @@ const noAssetImportsRule: ProjectRule = { ExportNamedDeclaration: checkDeclaration, ExportAllDeclaration: checkDeclaration, ImportExpression(node: TSESTree.ImportExpression): void { - if (node.source.type === AST_NODE_TYPES.Literal && typeof node.source.value === "string") { + if ( + node.source.type === AST_NODE_TYPES.Literal && + typeof node.source.value === "string" + ) { checkSource(node.source); } if (node.options !== null) { @@ -676,7 +760,12 @@ const rules: DummyRuleMap = { "id-match": [ "error", "^_*[^_]*_*$|^[^a-z]*$", - { properties: false, classFields: false, onlyDeclarations: false, ignoreDestructuring: true }, + { + properties: false, + classFields: false, + onlyDeclarations: false, + ignoreDestructuring: true, + }, ], // On Node, these come from an explicit `node:` import instead of the global scope. "no-restricted-globals": [ @@ -694,12 +783,14 @@ const rules: DummyRuleMap = { "one-var": ["error", "never"], "no-use-before-define": ["error", { functions: false }], "func-style": ["error", "declaration", { allowArrowFunctions: true }], - "unicorn/import-style": ["error", { styles: { "node:path": { named: true } } }], // Each limit is the smallest value the current code passes. complexity: ["error", 26], "max-depth": ["error", 6], - "max-lines": ["error", { max: 2048, skipBlankLines: false, skipComments: false }], - "max-lines-per-function": ["error", 98], + "max-lines": [ + "error", + { max: 2048, skipBlankLines: false, skipComments: false }, + ], + "max-lines-per-function": ["error", 124], "max-params": ["error", 4], "max-statements": ["error", 32], "import/max-dependencies": ["error", { max: 13 }], @@ -757,8 +848,8 @@ const rules: DummyRuleMap = { "import/no-named-export": "off", // Collides with `typescript/consistent-type-imports`. "import/consistent-type-specifier-style": "off", - // Collides with literal Gitea Actions `${{ }}` expressions in workflow strings. - "no-template-curly-in-string": "off", + // Collides with Oxfmt, which lowercases hex digits back. + "unicorn/number-literal-case": "off", // Node code, everywhere: this action runs on Node. // Node built-ins are the action's runtime. "import/no-nodejs-modules": "off", @@ -770,6 +861,8 @@ const rules: DummyRuleMap = { "no-await-in-loop": "off", // Not type-aware; TypeScript's `noImplicitReturns` covers it. "consistent-return": "off", + // Misfires on complete switches, like `consistent-return`; TypeScript covers it. + "typescript/consistent-return": "off", // TypeScript's type checking covers exhaustive switches. "typescript/switch-exhaustiveness-check": "off", // TypeScript checks completeness; a forced `default:` hides missing cases. @@ -833,6 +926,11 @@ export const projectOverrides: OxlintOverride[] = [ ], rules: { "project/prefer-ascii": "off" }, }, + // The workflow test checks literal Gitea Actions `${{ }}` expressions. + { + files: ["tests/workflow.test.ts"], + rules: { "no-template-curly-in-string": "off" }, + }, ]; /** diff --git a/src/agents/auth.test.ts b/src/agents/auth.test.ts index 5188b4c..cf4bd86 100644 --- a/src/agents/auth.test.ts +++ b/src/agents/auth.test.ts @@ -1,4 +1,11 @@ -import { mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { + mkdir, + readFile, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; import { expect, test } from "vite-plus/test"; import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing"; @@ -19,7 +26,10 @@ test("retries once after an invalid refresh token", async () => { async () => { const resolved = await Promise.resolve({ code: 1, - error: (attempts += 1) === 1 ? "ERROR: invalid_refresh_token" : "still failed", + error: + (attempts += 1) === 1 + ? "ERROR: invalid_refresh_token" + : "still failed", }); return resolved; }, @@ -111,15 +121,19 @@ test("recognizes a rejected Claude OAuth token", () => { expect( isClaudeAuthFailure({ apiErrorStatus: 401, - result: "Failed to authenticate. API Error: 401 OAuth access token is invalid.", + result: + "Failed to authenticate. API Error: 401 OAuth access token is invalid.", }), ).toBe(true); expect( isClaudeAuthFailure({ - result: "OAuth token has expired. Please obtain a new token or refresh your existing token.", + result: + "OAuth token has expired. Please obtain a new token or refresh your existing token.", }), ).toBe(true); - expect(isClaudeAuthFailure({ apiErrorStatus: 429, result: "API Error: 429" })).toBe(false); + expect( + isClaudeAuthFailure({ apiErrorStatus: 429, result: "API Error: 429" }), + ).toBe(false); }); test("selects API-key login only for a configured Codex token", () => { @@ -138,47 +152,62 @@ const AUTH_TOKENS = { }; const AUTH = JSON.stringify({ auth_mode: "chatgpt", tokens: AUTH_TOKENS }); -test.skipIf(IS_WINDOWS)("stages and persists only a regular private Codex login", async () => { - const root = await makeTempDir(); - try { - const canonical = `${root}/persistent/auth.json`; - await mkdir(`${root}/persistent`); - await writeFile(canonical, AUTH, { mode: 0o600 }); - const home = `${root}/profile`; - await mkdir(home); +test.skipIf(IS_WINDOWS)( + "stages and persists only a regular private Codex login", + async () => { + const root = await makeTempDir(); + try { + const canonical = `${root}/persistent/auth.json`; + await mkdir(`${root}/persistent`); + await writeFile(canonical, AUTH, { mode: 0o600 }); + const home = `${root}/profile`; + await mkdir(home); - const staged = await stageCodexAuth(canonical, home, currentUid(), currentGid()); - expect(await readFile(staged, "utf8")).toStrictEqual(AUTH); - const stagedStat = await stat(staged); - expect(stagedStat.mode & 0o777).toBe(0o600); + const staged = await stageCodexAuth( + canonical, + home, + currentUid(), + currentGid(), + ); + expect(await readFile(staged, "utf8")).toStrictEqual(AUTH); + const stagedStat = await stat(staged); + expect(stagedStat.mode & 0o777).toBe(0o600); - const refreshed = JSON.stringify({ - auth_mode: "chatgpt", - tokens: { ...AUTH_TOKENS, access_token: "refreshed" }, - }); - await writeFile(staged, refreshed); - await persistCodexAuth(staged, canonical); - expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed); - const canonicalStat = await stat(canonical); - expect(canonicalStat.mode & 0o777).toBe(0o600); - } finally { - await rm(root, { recursive: true }); - } -}); + const refreshed = JSON.stringify({ + auth_mode: "chatgpt", + tokens: { ...AUTH_TOKENS, access_token: "refreshed" }, + }); + await writeFile(staged, refreshed); + await persistCodexAuth(staged, canonical); + expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed); + const canonicalStat = await stat(canonical); + expect(canonicalStat.mode & 0o777).toBe(0o600); + } finally { + await rm(root, { recursive: true }); + } + }, +); -test.skipIf(IS_WINDOWS)("rejects a symlink or malformed Codex login", async () => { - const root = await makeTempDir(); - try { - const canonical = `${root}/auth.json`; - const malformed = `${root}/malformed.json`; - const linked = `${root}/linked.json`; - await writeFile(canonical, AUTH, { mode: 0o600 }); - await writeFile(malformed, '{"tokens":{}}', { mode: 0o600 }); - await symlink(canonical, linked); +test.skipIf(IS_WINDOWS)( + "rejects a symlink or malformed Codex login", + async () => { + const root = await makeTempDir(); + try { + const canonical = `${root}/auth.json`; + const malformed = `${root}/malformed.json`; + const linked = `${root}/linked.json`; + await writeFile(canonical, AUTH, { mode: 0o600 }); + await writeFile(malformed, '{"tokens":{}}', { mode: 0o600 }); + await symlink(canonical, linked); - await expect(persistCodexAuth(linked, canonical)).rejects.toThrow("regular file"); - await expect(persistCodexAuth(malformed, canonical)).rejects.toThrow("invalid Codex login"); - } finally { - await rm(root, { recursive: true }); - } -}); + await expect(persistCodexAuth(linked, canonical)).rejects.toThrow( + "regular file", + ); + await expect(persistCodexAuth(malformed, canonical)).rejects.toThrow( + "invalid Codex login", + ); + } finally { + await rm(root, { recursive: true }); + } + }, +); diff --git a/src/agents/auth.ts b/src/agents/auth.ts index 22a2868..19c7d12 100644 --- a/src/agents/auth.ts +++ b/src/agents/auth.ts @@ -10,7 +10,7 @@ import { rm, writeFile, } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import nodePath from "node:path"; import { field, isNotFound, isObject } from "#common"; @@ -26,7 +26,9 @@ interface CodexPersistent { export type CodexAuthentication = CodexApiKey | CodexPersistent; export function codexAuthentication(token?: string): CodexAuthentication { - return token === undefined ? { kind: "persistent" } : { kind: "api-key", token }; + return token === undefined + ? { kind: "persistent" } + : { kind: "api-key", token }; } // Codex logs revoked, expired, or reused ChatGPT sign-ins and rejected API keys @@ -95,10 +97,12 @@ async function readCodexAuth(path: string): Promise { typeof authMode !== "string" || authMode === "" || !isObject(tokens) || - !["access_token", "refresh_token", "id_token", "account_id"].every((name) => { - const token = field(tokens, name); - return typeof token === "string" && token !== ""; - }) + !["access_token", "refresh_token", "id_token", "account_id"].every( + (name) => { + const token = field(tokens, name); + return typeof token === "string" && token !== ""; + }, + ) ) { throw new Error("invalid Codex login"); } @@ -111,13 +115,15 @@ export async function stageCodexAuth( uid: number, gid: number, ): Promise { - const codexHome = join(profileHome, ".codex"); + const codexHome = nodePath.join(profileHome, ".codex"); await mkdir(codexHome, { recursive: true, mode: 0o700 }); await chmod(codexHome, 0o700); await chown(codexHome, uid, gid); - const stagedPath = join(codexHome, "auth.json"); + const stagedPath = nodePath.join(codexHome, "auth.json"); try { - await writeFile(stagedPath, await readCodexAuth(canonicalPath), { mode: 0o600 }); + await writeFile(stagedPath, await readCodexAuth(canonicalPath), { + mode: 0o600, + }); await chmod(stagedPath, 0o600); await chown(stagedPath, uid, gid); } catch (error) { @@ -130,15 +136,18 @@ export async function stageCodexAuth( // Creates an empty private file under a fresh name. async function makeTempFile(directory: string): Promise { - const path = join(directory, `.tmp-${randomUUID()}`); + const path = nodePath.join(directory, `.tmp-${randomUUID()}`); const handle = await open(path, "wx", 0o600); await handle.close(); return path; } -export async function persistCodexAuth(stagedPath: string, canonicalPath: string): Promise { +export async function persistCodexAuth( + stagedPath: string, + canonicalPath: string, +): Promise { const text = await readCodexAuth(stagedPath); - const directory = dirname(canonicalPath); + const directory = nodePath.dirname(canonicalPath); await mkdir(directory, { recursive: true, mode: 0o700 }); const temporary = await makeTempFile(directory); try { diff --git a/src/agents/index.ts b/src/agents/index.ts index 4a37ace..ce33c46 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -8,7 +8,11 @@ export { retryInvalidToken, stageCodexAuth, } from "./auth.ts"; -export type { ClaudeFailureFields, CodexAuthentication, CommandResult } from "./auth.ts"; +export type { + ClaudeFailureFields, + CodexAuthentication, + CommandResult, +} from "./auth.ts"; export { collectOpenCodeOutput, openCodeArgs, diff --git a/src/agents/opencode.test.ts b/src/agents/opencode.test.ts index 70781e7..4c58926 100644 --- a/src/agents/opencode.test.ts +++ b/src/agents/opencode.test.ts @@ -10,7 +10,9 @@ import { } from "./opencode.ts"; test("builds a non-interactive auto-approved OpenCode command", () => { - expect(openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this")).toStrictEqual([ + expect( + openCodeArgs("openrouter/qwen/qwen3.8-27b", "Review this"), + ).toStrictEqual([ "run", "--format", "json", @@ -70,7 +72,10 @@ test("streams OpenCode output while collecting it", async () => { }, ); - expect(result).toStrictEqual({ stdout: "first\nsecond\n", stderr: "warning\n" }); + expect(result).toStrictEqual({ + stdout: "first\nsecond\n", + stderr: "warning\n", + }); expect(shown).toStrictEqual(["out:first\n", "err:warning\n", "out:second\n"]); }); @@ -94,12 +99,14 @@ test("reports a rejected OpenRouter key as a sign-in failure", () => { assert.instanceOf(error, AgentAuthError); expect(error.message).toBe("opencode sign-in failed"); assert.instanceOf(error.cause, Error); - expect(error.cause.message).toBe("opencode APIError (API error 401): User not found."); + expect(error.cause.message).toBe( + "opencode APIError (API error 401): User not found.", + ); }); test("surfaces an OpenCode error event instead of empty output", () => { - expect(() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded"))).toThrow( - "API error 429", - ); + expect(() => + parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded")), + ).toThrow("API error 429"); expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBeNull(); }); diff --git a/src/agents/opencode.ts b/src/agents/opencode.ts index a8bf54e..a8f02b6 100644 --- a/src/agents/opencode.ts +++ b/src/agents/opencode.ts @@ -96,7 +96,9 @@ export function parseOpenCodeEvents(output: string): string { } } if (result === null) { - throw openCodeError(output) ?? new Error("OpenCode produced no text output"); + throw ( + openCodeError(output) ?? new Error("OpenCode produced no text output") + ); } return result; } diff --git a/src/common/json.test.ts b/src/common/json.test.ts index ed7880d..6987b34 100644 --- a/src/common/json.test.ts +++ b/src/common/json.test.ts @@ -3,7 +3,9 @@ import { expect, test } from "vite-plus/test"; import { field, isMapping, isObject } from "./json.ts"; test("reads a named field of a parsed object", () => { - const value: unknown = JSON.parse('{"login":"bot","user":{"login":"nested"}}'); + const value: unknown = JSON.parse( + '{"login":"bot","user":{"login":"nested"}}', + ); expect(field(value, "login")).toBe("bot"); expect(field(field(value, "user"), "login")).toBe("nested"); expect(field(value, "missing")).toBeUndefined(); diff --git a/src/post/post.test.ts b/src/post/post.test.ts index 74a92fd..8b657ee 100644 --- a/src/post/post.test.ts +++ b/src/post/post.test.ts @@ -145,7 +145,9 @@ test("posts a successful issue result and removes runtime credentials", async () expect(requests.length).toBe(1); const [request] = requests; - expect(request?.url).toStrictEqual(`${context.apiUrl}/repos/owner/repo/issues/7/comments`); + expect(request?.url).toStrictEqual( + `${context.apiUrl}/repos/owner/repo/issues/7/comments`, + ); expect(requestBody(request?.init)).toStrictEqual({ body: "done\n\n> Profile: `codex` ยท `gpt-test`", }); @@ -157,9 +159,11 @@ test("posts a successful issue result and removes runtime credentials", async () }); test("retries a rejected inline review without anchors", async () => { - const result = await fixture({ ok: true, body: "ignored final response" }, "Approved\nReady", [ - { path: "run.ts", line: 1, side: "NEW", body: "Finding" }, - ]); + const result = await fixture( + { ok: true, body: "ignored final response" }, + "Approved\nReady", + [{ path: "run.ts", line: 1, side: "NEW", body: "Finding" }], + ); const bodies: unknown[] = []; await postRuntime( @@ -198,9 +202,9 @@ test("reports each tried profile and keeps the action failed", async () => { }); const bodies: unknown[] = []; - await expect(postRuntime(result.manifestPath, context, recordingFetch(bodies))).rejects.toThrow( - "agent phase failed", - ); + await expect( + postRuntime(result.manifestPath, context, recordingFetch(bodies)), + ).rejects.toThrow("agent phase failed"); expect(bodies).toStrictEqual([ { @@ -229,9 +233,9 @@ test("drops failure entries that are not known labels", async () => { ); const bodies: unknown[] = []; - await expect(postRuntime(result.manifestPath, context, recordingFetch(bodies))).rejects.toThrow( - "agent phase failed", - ); + await expect( + postRuntime(result.manifestPath, context, recordingFetch(bodies)), + ).rejects.toThrow("agent phase failed"); expect(bodies).toStrictEqual([ { @@ -245,9 +249,9 @@ test("reports a generic failure when the runner wrote no outcome", async () => { await rm(result.outcomeFile); const bodies: unknown[] = []; - await expect(postRuntime(result.manifestPath, context, recordingFetch(bodies))).rejects.toThrow( - "agent phase failed", - ); + await expect( + postRuntime(result.manifestPath, context, recordingFetch(bodies)), + ).rejects.toThrow("agent phase failed"); expect(bodies).toStrictEqual([{ body: "AI bot failed.\n\n> Profile: none" }]); assert(await missing(result.runtimeDir)); @@ -258,7 +262,9 @@ test("removes runtime credentials when posting fails", async () => { await expect( postRuntime(result.manifestPath, context, async () => { - const resolved = await Promise.resolve(new Response("failed", { status: 500 })); + const resolved = await Promise.resolve( + new Response("failed", { status: 500 }), + ); return resolved; }), ).rejects.toThrow("500"); @@ -281,22 +287,27 @@ test("preserves a Gitea transport failure without retrying a comment", async () assert(await missing(result.runtimeDir)); }); -test.skipIf(IS_WINDOWS)("refuses to follow an agent artifact symlink", async () => { - const result = await fixture({ ok: true, body: "done" }); - await symlink(result.reviewerFile, result.reviewPath); - let requests = 0; +test.skipIf(IS_WINDOWS)( + "refuses to follow an agent artifact symlink", + async () => { + const result = await fixture({ ok: true, body: "done" }); + await symlink(result.reviewerFile, result.reviewPath); + let requests = 0; - await expect( - postRuntime(result.manifestPath, context, async () => { - requests += 1; - const resolved = await Promise.resolve(new Response(null, { status: 200 })); - return resolved; - }), - ).rejects.toThrow("regular private file"); + await expect( + postRuntime(result.manifestPath, context, async () => { + requests += 1; + const resolved = await Promise.resolve( + new Response(null, { status: 200 }), + ); + return resolved; + }), + ).rejects.toThrow("regular private file"); - expect(requests).toBe(1); - assert(await missing(result.runtimeDir)); -}); + expect(requests).toBe(1); + assert(await missing(result.runtimeDir)); + }, +); test("reports an attributed failure when a pull request review is missing", async () => { const result = await fixture({ ok: true, body: "done" }); diff --git a/src/post/post.ts b/src/post/post.ts index 90ae232..a6f8a34 100644 --- a/src/post/post.ts +++ b/src/post/post.ts @@ -1,10 +1,19 @@ import type { Stats } from "node:fs"; import { lstat, readFile, rm } from "node:fs/promises"; -import { join } from "node:path"; +import nodePath from "node:path"; import { field, isNotFound, isObject } from "#common"; -import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "#profiles"; -import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "#runtime"; +import { + failureFooter, + type FailureReason, + type ProfileFailure, + withProfile, +} from "#profiles"; +import { + readRuntimeManifest, + type RuntimeManifest, + type RuntimeOutcome, +} from "#runtime"; export interface PostContext { apiUrl: string; @@ -27,14 +36,20 @@ const VERDICTS = new Map([ ["Changes requested", ["REQUEST_CHANGES", "๐Ÿ›‘"]], ]); const PROFILE_TYPES = new Set(["CLAUDE", "CODEX", "OPENCODE"]); -const FAILURE_REASONS = new Set(["SIGN_IN_FAILED", "USAGE_EXHAUSTED", "FAILED"]); +const FAILURE_REASONS = new Set([ + "SIGN_IN_FAILED", + "USAGE_EXHAUSTED", + "FAILED", +]); // Anchors written before the UPPER_SNAKE switch spell sides in lowercase. const OLD_SIDES = new Map([ ["new", "NEW"], ["old", "OLD"], ]); -const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.has(value); -const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value); +const isProfileType = (value: unknown): value is ProfileFailure["type"] => + PROFILE_TYPES.has(value); +const isFailureReason = (value: unknown): value is FailureReason => + FAILURE_REASONS.has(value); const failureComment = (failures: ProfileFailure[]): string => `AI bot failed.\n\n${failureFooter(failures)}`; @@ -61,7 +76,8 @@ function parseAnchors(text: string, sourcePath: string): Anchor[] { const path = field(entry, "path"); const line = field(entry, "line"); const givenSide = field(entry, "side"); - const side = givenSide === undefined ? "NEW" : (OLD_SIDES.get(givenSide) ?? givenSide); + const side = + givenSide === undefined ? "NEW" : (OLD_SIDES.get(givenSide) ?? givenSide); const body = field(entry, "body"); if (typeof path !== "string" || path === "") { throw new TypeError(`${at}.path: expected a repository path`); @@ -95,7 +111,9 @@ async function readOutcome(path: string): Promise { const type = field(entry, "type"); const model = field(entry, "model"); const reason = field(entry, "reason"); - return isProfileType(type) && typeof model === "string" && isFailureReason(reason) + return isProfileType(type) && + typeof model === "string" && + isFailureReason(reason) ? [{ type, model, reason }] : []; }), @@ -127,7 +145,9 @@ async function readAgentArtifact( path: string, name: string, ): Promise { - const profile = manifest.profiles.find((entry) => join(entry.home, name) === path); + const profile = manifest.profiles.find( + (entry) => nodePath.join(entry.home, name) === path, + ); if (profile === undefined) { throw new Error(`${name}: outside profile home`); } @@ -177,13 +197,18 @@ export async function postRuntime( throw new GiteaRequestError(`${path}: ${detail}`, { cause: error }); } if (!response.ok) { - throw new GiteaRequestError(`${path}: ${response.status} ${await response.text()}`); + throw new GiteaRequestError( + `${path}: ${response.status} ${await response.text()}`, + ); } }; const postComment = async (body: string): Promise => { - await request(`repos/${context.repository}/issues/${context.issueIndex}/comments`, { - body: stripAnsi(body), - }); + await request( + `repos/${context.repository}/issues/${context.issueIndex}/comments`, + { + body: stripAnsi(body), + }, + ); }; let outcome: RuntimeOutcome | null = null; @@ -199,7 +224,11 @@ export async function postRuntime( } try { - const review = await readAgentArtifact(manifest, outcome.reviewPath, "review.md"); + const review = await readAgentArtifact( + manifest, + outcome.reviewPath, + "review.md", + ); if (review === null) { if (context.eventName === "pull_request") { throw new Error("agent did not write a pull request review"); @@ -210,21 +239,34 @@ export async function postRuntime( const [verdict = "", ...rest] = review.split("\n"); const [event, mark] = VERDICTS.get(verdict.trim()) ?? ["COMMENT", "๐Ÿ’ฌ"]; - const anchorText = await readAgentArtifact(manifest, outcome.anchorsPath, "anchors.json"); - const anchors = anchorText === null ? [] : parseAnchors(anchorText, outcome.anchorsPath); + const anchorText = await readAgentArtifact( + manifest, + outcome.anchorsPath, + "anchors.json", + ); + const anchors = + anchorText === null + ? [] + : parseAnchors(anchorText, outcome.anchorsPath); const postReview = async (entries: Anchor[]): Promise => { - await request(`repos/${context.repository}/pulls/${context.issueIndex}/reviews`, { - body: stripAnsi( - withProfile([`${mark} ${verdict.trim()}`, ...rest].join("\n"), outcome.profile), - ), - event, - comments: entries.map(({ path, line, side, body }) => ({ - path, - body: stripAnsi(withProfile(body, outcome.profile)), - new_position: side === "NEW" ? line : 0, - old_position: side === "OLD" ? line : 0, - })), - }); + await request( + `repos/${context.repository}/pulls/${context.issueIndex}/reviews`, + { + body: stripAnsi( + withProfile( + [`${mark} ${verdict.trim()}`, ...rest].join("\n"), + outcome.profile, + ), + ), + event, + comments: entries.map(({ path, line, side, body }) => ({ + path, + body: stripAnsi(withProfile(body, outcome.profile)), + new_position: side === "NEW" ? line : 0, + old_position: side === "OLD" ? line : 0, + })), + }, + ); }; await postReview(anchors).catch(async (error: unknown) => { if (anchors.length === 0) { @@ -239,14 +281,18 @@ export async function postRuntime( if (error instanceof GiteaRequestError) { throw error; } - await postComment(failureComment([{ ...outcome.profile, reason: "FAILED" }])); + await postComment( + failureComment([{ ...outcome.profile, reason: "FAILED" }]), + ); throw error; } } finally { - await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => { - if (!isNotFound(error)) { - throw error; - } - }); + await rm(manifest.runtimeDir, { recursive: true }).catch( + (error: unknown) => { + if (!isNotFound(error)) { + throw error; + } + }, + ); } } diff --git a/src/processes/child.test.ts b/src/processes/child.test.ts index 98056c1..7e7d990 100644 --- a/src/processes/child.test.ts +++ b/src/processes/child.test.ts @@ -6,8 +6,12 @@ import { isNotFound } from "#common"; import { exitCode } from "./child.ts"; test("reports the exit code", async () => { - expect(await exitCode(spawn("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" }))).toBe(0); - expect(await exitCode(spawn("/bin/sh", ["-c", "exit 3"], { stdio: "ignore" }))).toBe(3); + expect( + await exitCode(spawn("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" })), + ).toBe(0); + expect( + await exitCode(spawn("/bin/sh", ["-c", "exit 3"], { stdio: "ignore" })), + ).toBe(3); }); test("reports a signal as 128 plus its number", async () => { @@ -16,7 +20,9 @@ test("reports a signal as 128 plus its number", async () => { }); test("resolves on exit while a grandchild still holds the output open", async () => { - const child = spawn("/bin/sh", ["-c", "sleep 2 & exit 5"], { stdio: ["ignore", "pipe", "pipe"] }); + const child = spawn("/bin/sh", ["-c", "sleep 2 & exit 5"], { + stdio: ["ignore", "pipe", "pipe"], + }); const started = Date.now(); expect(await exitCode(child)).toBe(5); expect(Date.now() - started).toBeLessThan(1500); diff --git a/src/processes/child.ts b/src/processes/child.ts index 7a09a09..b8465a1 100644 --- a/src/processes/child.ts +++ b/src/processes/child.ts @@ -23,7 +23,10 @@ export function exitCode(child: ChildProcess): Promise { } // Writes a chunk, waiting for the stream to drain when its buffer is full. -export async function echo(stream: Writable, chunk: string | Uint8Array): Promise { +export async function echo( + stream: Writable, + chunk: string | Uint8Array, +): Promise { if (!stream.write(chunk)) { await once(stream, "drain"); } diff --git a/src/processes/isolation.test.ts b/src/processes/isolation.test.ts index b580a5b..6933661 100644 --- a/src/processes/isolation.test.ts +++ b/src/processes/isolation.test.ts @@ -1,5 +1,13 @@ import { spawnSync, type SpawnSyncReturns } from "node:child_process"; -import { chmod, chown, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { + chmod, + chown, + mkdir, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import process from "node:process"; import { expect, test } from "vite-plus/test"; @@ -7,7 +15,11 @@ import { isNotFound } from "#common"; import type { RuntimeProfile } from "#runtime"; import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "#testing"; -import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from "./isolation.ts"; +import { + isRunnableProfileProcess, + killProfileProcesses, + readProfileFile, +} from "./isolation.ts"; const profile = (uid: number, home: string): RuntimeProfile => ({ index: uid - 20_000, @@ -41,77 +53,96 @@ test("zombie process entries are already quiescent", () => { expect(isRunnableProfileProcess(status, 20_010)).toBe(false); }); -test.skipIf(IS_WINDOWS)("refuses to read a profile result symlink as root", async () => { - const root = await makeTempDir(); - try { - const secret = `${root}/reviewer-token`; - const result = `${root}/result.md`; - await writeFile(secret, "reviewer-secret", { mode: 0o600 }); - await symlink(secret, result); +test.skipIf(IS_WINDOWS)( + "refuses to read a profile result symlink as root", + async () => { + const root = await makeTempDir(); + try { + const secret = `${root}/reviewer-token`; + const result = `${root}/result.md`; + await writeFile(secret, "reviewer-secret", { mode: 0o600 }); + await symlink(secret, result); - await expect(readProfileFile(profile(currentUid(), root), result)).rejects.toThrow( - "regular private file", - ); - } finally { - await rm(root, { recursive: true }); - } -}); - -test.skipIf(!IS_ROOT)("a profile process can read only its own private files", async () => { - const root = await makeTempDir(); - await chmod(root, 0o711); - try { - const homeA = `${root}/a`; - const homeB = `${root}/b`; - await mkdir(homeA, { mode: 0o700 }); - await mkdir(homeB, { mode: 0o700 }); - await chown(homeA, 20_000, 20_000); - await chown(homeB, 20_001, 20_001); - await writeFile(`${homeA}/token`, "own", { mode: 0o600 }); - await chown(`${homeA}/token`, 20_000, 20_000); - await writeFile(`${homeB}/token`, "sibling", { mode: 0o600 }); - await chown(`${homeB}/token`, 20_001, 20_001); - await writeFile(`${root}/reviewer`, "root", { mode: 0o600 }); - - const selected = profile(20_000, homeA); - const own = runAs(selected, "/bin/cat", [`${homeA}/token`]); - const sibling = runAs(selected, "/bin/cat", [`${homeB}/token`]); - const reviewer = runAs(selected, "/bin/cat", [`${root}/reviewer`]); - - expect(own.stdout).toBe("own"); - expect(own.status).toBe(0); - expect(sibling.status).not.toBe(0); - expect(reviewer.status).not.toBe(0); - } finally { - await rm(root, { recursive: true }); - } -}); - -test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async () => { - const selected = profile(20_010, "/tmp"); - const launched = runAs(selected, "/bin/sh", ["-c", "sleep 60 >/dev/null 2>&1 & echo $!"]); - const pid = Number(launched.stdout.trim()); - expect(Number.isInteger(pid)).toBe(true); - - await killProfileProcesses(selected.uid); - - const status = await readFile(`/proc/${pid}/status`, "utf8").catch((error: unknown) => { - if (isNotFound(error)) { - return null; + await expect( + readProfileFile(profile(currentUid(), root), result), + ).rejects.toThrow("regular private file"); + } finally { + await rm(root, { recursive: true }); } - throw error; - }); - expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true); -}); + }, +); -test.skipIf(!IS_ROOT)("the agent process cannot read its coordinator environment", () => { - const selected = profile(20_020, "/tmp"); - const result = runAs( - selected, - "/usr/bin/cat", - [`/proc/${process.pid}/environ`], - new Map([["PARENT_SECRET", "must-not-cross-boundary"]]), - ); +test.skipIf(!IS_ROOT)( + "a profile process can read only its own private files", + async () => { + const root = await makeTempDir(); + await chmod(root, 0o711); + try { + const homeA = `${root}/a`; + const homeB = `${root}/b`; + await mkdir(homeA, { mode: 0o700 }); + await mkdir(homeB, { mode: 0o700 }); + await chown(homeA, 20_000, 20_000); + await chown(homeB, 20_001, 20_001); + await writeFile(`${homeA}/token`, "own", { mode: 0o600 }); + await chown(`${homeA}/token`, 20_000, 20_000); + await writeFile(`${homeB}/token`, "sibling", { mode: 0o600 }); + await chown(`${homeB}/token`, 20_001, 20_001); + await writeFile(`${root}/reviewer`, "root", { mode: 0o600 }); - expect(result.status).not.toBe(0); -}); + const selected = profile(20_000, homeA); + const own = runAs(selected, "/bin/cat", [`${homeA}/token`]); + const sibling = runAs(selected, "/bin/cat", [`${homeB}/token`]); + const reviewer = runAs(selected, "/bin/cat", [`${root}/reviewer`]); + + expect(own.stdout).toBe("own"); + expect(own.status).toBe(0); + expect(sibling.status).not.toBe(0); + expect(reviewer.status).not.toBe(0); + } finally { + await rm(root, { recursive: true }); + } + }, +); + +test.skipIf(!IS_ROOT)( + "cleanup leaves no runnable daemonized process", + async () => { + const selected = profile(20_010, "/tmp"); + const launched = runAs(selected, "/bin/sh", [ + "-c", + "sleep 60 >/dev/null 2>&1 & echo $!", + ]); + const pid = Number(launched.stdout.trim()); + expect(Number.isInteger(pid)).toBe(true); + + await killProfileProcesses(selected.uid); + + const status = await readFile(`/proc/${pid}/status`, "utf8").catch( + (error: unknown) => { + if (isNotFound(error)) { + return null; + } + throw error; + }, + ); + expect( + status === null || !isRunnableProfileProcess(status, selected.uid), + ).toBe(true); + }, +); + +test.skipIf(!IS_ROOT)( + "the agent process cannot read its coordinator environment", + () => { + const selected = profile(20_020, "/tmp"); + const result = runAs( + selected, + "/usr/bin/cat", + [`/proc/${process.pid}/environ`], + new Map([["PARENT_SECRET", "must-not-cross-boundary"]]), + ); + + expect(result.status).not.toBe(0); + }, +); diff --git a/src/processes/isolation.ts b/src/processes/isolation.ts index b63d8d3..2ffbb65 100644 --- a/src/processes/isolation.ts +++ b/src/processes/isolation.ts @@ -18,20 +18,29 @@ async function profileProcessIds(uid: number): Promise { .map((entry) => entry.name); const statuses = await Promise.all( pids.map(async (pid) => { - const status = await readFile(`/proc/${pid}/status`, "utf8").catch(() => null); + const status = await readFile(`/proc/${pid}/status`, "utf8").catch( + () => null, + ); return status; }), ); return pids .filter((_pid, index) => { const status = statuses[index]; - return status !== null && status !== undefined && isRunnableProfileProcess(status, uid); + return ( + status !== null && + status !== undefined && + isRunnableProfileProcess(status, uid) + ); }) .map(Number); } // Kills the UID's processes every 10 ms until none is left, for up to 100 rounds. -export async function killProfileProcesses(uid: number, attempt = 0): Promise { +export async function killProfileProcesses( + uid: number, + attempt = 0, +): Promise { if (attempt === 100) { throw new Error(`profile UID ${uid} still owns processes after cleanup`); } @@ -52,7 +61,10 @@ export async function killProfileProcesses(uid: number, attempt = 0): Promise { +export async function readProfileFile( + profile: RuntimeProfile, + path: string, +): Promise { const stat = await lstat(path); if ( !stat.isFile() || diff --git a/src/profiles/index.ts b/src/profiles/index.ts index 69ad6a9..71870da 100644 --- a/src/profiles/index.ts +++ b/src/profiles/index.ts @@ -9,4 +9,9 @@ export { sanitizedEnvironment, withProfile, } from "./profiles.ts"; -export type { BotProfile, FailureReason, ProfileFailure, ProfileIdentity } from "./profiles.ts"; +export type { + BotProfile, + FailureReason, + ProfileFailure, + ProfileIdentity, +} from "./profiles.ts"; diff --git a/src/profiles/profiles.test.ts b/src/profiles/profiles.test.ts index 6879598..63b8e0e 100644 --- a/src/profiles/profiles.test.ts +++ b/src/profiles/profiles.test.ts @@ -11,9 +11,9 @@ import { } from "./profiles.ts"; test("formats profile attribution as a Markdown quote", () => { - expect(profileFooter({ type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" })).toBe( - "> Profile: `opencode` ยท `openrouter/qwen/qwen3.8-27b`", - ); + expect( + profileFooter({ type: "OPENCODE", model: "openrouter/qwen/qwen3.8-27b" }), + ).toBe("> Profile: `opencode` ยท `openrouter/qwen/qwen3.8-27b`"); }); test("falls through only after usage exhaustion", async () => { @@ -57,22 +57,24 @@ test("recognizes Claude's weekly limit message", async () => { test("recognizes provider API exhaustion status", async () => { const results = await Promise.all( - ["API Error: 402 Insufficient funds", "HTTP error: 429"].map(async (message) => { - const result = await runProfiles( - [ - { type: "CLAUDE", model: "claude-sonnet-5", token: "first" }, - { type: "CODEX", model: "gpt-5.6-terra" }, - ], - async (profile) => { - if (profile.type === "CLAUDE") { - throw new Error(message); - } - const resolved = await Promise.resolve("fallback"); - return resolved; - }, - ); - return result; - }), + ["API Error: 402 Insufficient funds", "HTTP error: 429"].map( + async (message) => { + const result = await runProfiles( + [ + { type: "CLAUDE", model: "claude-sonnet-5", token: "first" }, + { type: "CODEX", model: "gpt-5.6-terra" }, + ], + async (profile) => { + if (profile.type === "CLAUDE") { + throw new Error(message); + } + const resolved = await Promise.resolve("fallback"); + return resolved; + }, + ); + return result; + }, + ), ); expect(results).toStrictEqual(["fallback", "fallback"]); @@ -126,10 +128,14 @@ test("throws the last sign-in failure when every profile fails", async () => { ).rejects.toThrow( expect.toSatisfy( (error: unknown) => - error instanceof AgentAuthError && error.message.includes("CLAUDE sign-in failed"), + error instanceof AgentAuthError && + error.message.includes("CLAUDE sign-in failed"), ), ); - expect(failures.map(({ reason }) => reason)).toStrictEqual(["SIGN_IN_FAILED", "SIGN_IN_FAILED"]); + expect(failures.map(({ reason }) => reason)).toStrictEqual([ + "SIGN_IN_FAILED", + "SIGN_IN_FAILED", + ]); }); test("preserves an ordinary agent failure", async () => { @@ -237,7 +243,9 @@ test("exposes an OpenRouter key only to the OpenCode profile", () => { tokenFile: "/runtime/profile-2/token", }; - expect(agentEnvironment(new Map([["PATH", "/bin"]]), profile, "selected")).toStrictEqual( + expect( + agentEnvironment(new Map([["PATH", "/bin"]]), profile, "selected"), + ).toStrictEqual( new Map([ ["PATH", "/bin"], ["HOME", "/runtime/profile-2"], diff --git a/src/profiles/profiles.ts b/src/profiles/profiles.ts index 67cb85e..bb8399c 100644 --- a/src/profiles/profiles.ts +++ b/src/profiles/profiles.ts @@ -79,7 +79,8 @@ export function failureFooter(failures: ProfileFailure[]): string { } return `> Tried: ${failures .map( - ({ type, model, reason }) => `\`${profileName(type)}\` ยท \`${model}\` (${toMessage(reason)})`, + ({ type, model, reason }) => + `\`${profileName(type)}\` ยท \`${model}\` (${toMessage(reason)})`, ) .join(", ")}`; } @@ -100,7 +101,9 @@ export async function runProfiles( if (reason === "FAILED" || index === profiles.length - 1) { throw error; } - console.error(`${profile.type} ${toMessage(reason)}; trying next profile`); + console.error( + `${profile.type} ${toMessage(reason)}; trying next profile`, + ); } } throw new Error("profiles must contain at least one profile"); diff --git a/src/run/index.ts b/src/run/index.ts index bb2a3c8..6282193 100644 --- a/src/run/index.ts +++ b/src/run/index.ts @@ -25,7 +25,12 @@ import { stageCodexAuth, } from "#agents"; import { field, isObject } from "#common"; -import { echo, exitCode, killProfileProcesses, readProfileFile } from "#processes"; +import { + echo, + exitCode, + killProfileProcesses, + readProfileFile, +} from "#processes"; import { AgentAuthError, agentEnvironment, @@ -35,7 +40,11 @@ import { sanitizedEnvironment, withProfile, } from "#profiles"; -import { readRuntimeManifest, type RuntimeOutcome, type RuntimeProfile } from "#runtime"; +import { + readRuntimeManifest, + type RuntimeOutcome, + type RuntimeProfile, +} from "#runtime"; interface AttemptResult { body: string; @@ -107,7 +116,10 @@ async function postAuthorComment(body: string): Promise { async function runRoot(command: string, args: string[]): Promise { const code = await exitCode( - spawn(command, args, { env: Object.fromEntries(PROCESS_ENV), stdio: "ignore" }), + spawn(command, args, { + env: Object.fromEntries(PROCESS_ENV), + stdio: "ignore", + }), ); if (code !== 0) { throw new Error(`${command} ${args[0]} exited with ${code}`); @@ -126,8 +138,13 @@ async function configureGitAuthor(): Promise { await runRoot("git", ["config", "user.email", email]); } -async function readProfileToken(profile: RuntimeProfile): Promise { - const token = profile.tokenFile === null ? null : await readFile(profile.tokenFile, "utf8"); +async function readProfileToken( + profile: RuntimeProfile, +): Promise { + const token = + profile.tokenFile === null + ? null + : await readFile(profile.tokenFile, "utf8"); return token; } @@ -191,7 +208,9 @@ function formatComment(comment: unknown): string { async function renderPrompt(profile: RuntimeProfile): Promise { const reviewPath = await makeProfileFile(profile, "review.md"); const anchorsPath = await makeProfileFile(profile, "anchors.json"); - const commentsResponse = await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`); + const commentsResponse = await gitea( + `repos/${REPO}/issues/${INDEX}/comments?limit=100`, + ); const comments: unknown = await commentsResponse.json(); if (!Array.isArray(comments)) { throw new TypeError("comments: unexpected Gitea response"); @@ -200,7 +219,10 @@ async function renderPrompt(profile: RuntimeProfile): Promise { const codeRules = await rulesResponse.text(); const values = new Map([ ["COMMENT", env("COMMENT")], - ["ISSUE_COMMENTS", comments.map((comment: unknown) => formatComment(comment)).join("\n")], + [ + "ISSUE_COMMENTS", + comments.map((comment: unknown) => formatComment(comment)).join("\n"), + ], ["CODE_RULES", codeRules], ["EVENT_NAME", EVENT], ["GITEA_API_URL", API], @@ -209,19 +231,29 @@ async function renderPrompt(profile: RuntimeProfile): Promise { ["REVIEW_PATH", reviewPath], ["ANCHORS_PATH", anchorsPath], ]); - const template = await readFile(new URL("../../prompt.md", import.meta.url), "utf8"); + const template = await readFile( + new URL("../../prompt.md", import.meta.url), + "utf8", + ); return { - prompt: template.replaceAll(/\$\{(?\w+)\}/gu, (match: string, ...args: unknown[]) => { - const groups = args.at(-1); - const name = isObject(groups) ? field(groups, "name") : undefined; - return typeof name === "string" ? (values.get(name) ?? match) : match; - }), + prompt: template.replaceAll( + /\$\{(?\w+)\}/gu, + (match: string, ...args: unknown[]) => { + const groups = args.at(-1); + const name = isObject(groups) ? field(groups, "name") : undefined; + return typeof name === "string" ? (values.get(name) ?? match) : match; + }, + ), reviewPath, anchorsPath, }; } -async function runClaude(profile: RuntimeProfile, token: string, prompt: string): Promise { +async function runClaude( + profile: RuntimeProfile, + token: string, + prompt: string, +): Promise { const claude = spawn( "claude", [ @@ -236,7 +268,9 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string) ], { cwd: WORKSPACE, - env: Object.fromEntries(agentEnvironment(PROCESS_ENV, profile, token).set("IS_SANDBOX", "1")), + env: Object.fromEntries( + agentEnvironment(PROCESS_ENV, profile, token).set("IS_SANDBOX", "1"), + ), uid: profile.uid, gid: profile.gid, stdio: ["ignore", "pipe", "pipe"], @@ -253,14 +287,20 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string) console.error(part); } })(); - for await (const line of createInterface({ input: claude.stdout, crlfDelay: Infinity })) { + for await (const line of createInterface({ + input: claude.stdout, + crlfDelay: Infinity, + })) { if (line !== "") { const event: unknown = JSON.parse(line); if (isObject(event)) { const content = field(field(event, "message"), "content"); for (const part of Array.isArray(content) ? content : []) { if (isObject(part)) { - const text = field(part, "thinking") ?? field(part, "text") ?? field(part, "name"); + const text = + field(part, "thinking") ?? + field(part, "text") ?? + field(part, "name"); if (typeof text === "string" && text !== "") { console.log(text); } @@ -275,7 +315,8 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string) isError: typeof isError === "boolean" ? isError : undefined, result: typeof answer === "string" ? answer : undefined, subtype: String(subtype), - apiErrorStatus: typeof apiStatus === "number" ? apiStatus : undefined, + apiErrorStatus: + typeof apiStatus === "number" ? apiStatus : undefined, }; } } @@ -283,14 +324,18 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string) } const [code] = await Promise.all([status, copyError]); if (result?.isError === true) { - const failure = new Error(result.result ?? `claude ended with ${result.subtype}`); + const failure = new Error( + result.result ?? `claude ended with ${result.subtype}`, + ); if (isClaudeAuthFailure(result)) { throw new AgentAuthError("claude sign-in failed", { cause: failure }); } throw failure; } if (result?.result === undefined) { - throw new Error(error.trim() || `claude ended with ${result?.subtype ?? "no result"}`); + throw new Error( + error.trim() || `claude ended with ${result?.subtype ?? "no result"}`, + ); } if (code !== 0) { throw new Error(error.trim() || `claude exited with ${code}`); @@ -320,7 +365,10 @@ async function codexDeviceLogin( const drained = Promise.all([collect(login.stdout), collect(login.stderr)]); await sleep(3000); while (shown.trim() === "") { - const exited = await Promise.race([status.then(() => true), sleep(100, false)]); + const exited = await Promise.race([ + status.then(() => true), + sleep(100, false), + ]); if (exited) { throw new AgentAuthError("AI bot login produced no instructions"); } @@ -353,7 +401,10 @@ async function codexApiLogin( } } -async function makeProfileFile(profile: RuntimeProfile, name: string): Promise { +async function makeProfileFile( + profile: RuntimeProfile, + name: string, +): Promise { const path = `${profile.home}/${name}`; await writeFile(path, "", { mode: 0o600 }); await chmod(path, 0o600); @@ -366,7 +417,8 @@ async function runCodex( prompt: string, authentication: CodexAuthentication, ): Promise { - const token = authentication.kind === "api-key" ? authentication.token : undefined; + const token = + authentication.kind === "api-key" ? authentication.token : undefined; const environment = agentEnvironment(PROCESS_ENV, profile, token); if (authentication.kind === "api-key") { await codexApiLogin(profile, environment, authentication.token); @@ -422,7 +474,9 @@ async function runCodex( await codexDeviceLogin(profile, environment); }); if (result.code !== 0) { - const failure = new Error(result.error.trim() || `codex exited with ${result.code}`); + const failure = new Error( + result.error.trim() || `codex exited with ${result.code}`, + ); if (isCodexAuthFailure(result.error)) { throw new AgentAuthError("codex sign-in failed", { cause: failure }); } @@ -457,14 +511,18 @@ async function runOpenCode( const [code, streams] = await Promise.all([status, collected]); const error = streams.stderr.trim(); if (code !== 0) { - throw openCodeError(streams.stdout) ?? new Error(error || `opencode exited with ${code}`); + throw ( + openCodeError(streams.stdout) ?? + new Error(error || `opencode exited with ${code}`) + ); } return parseOpenCodeEvents(streams.stdout); } async function runProfile(profile: RuntimeProfile): Promise { const token = await readProfileToken(profile); - const authentication = profile.type === "CODEX" ? codexAuthentication(token ?? undefined) : null; + const authentication = + profile.type === "CODEX" ? codexAuthentication(token ?? undefined) : null; let stagedCodexAuth: string | null = null; let body: string | null = null; let resultPath: string | null = null; @@ -472,7 +530,12 @@ async function runProfile(profile: RuntimeProfile): Promise { let anchorsPath: string | null = null; try { if (authentication?.kind === "persistent") { - stagedCodexAuth = await stageCodexAuth(CODEX_AUTH, profile.home, profile.uid, profile.gid); + stagedCodexAuth = await stageCodexAuth( + CODEX_AUTH, + profile.home, + profile.uid, + profile.gid, + ); } await prepareProfile(profile); const rendered = await renderPrompt(profile); diff --git a/src/runtime/credentials.test.ts b/src/runtime/credentials.test.ts index 2508e5d..ff8d382 100644 --- a/src/runtime/credentials.test.ts +++ b/src/runtime/credentials.test.ts @@ -1,6 +1,10 @@ import { expect, test } from "vite-plus/test"; -import { decryptCredential, encryptCredential, parseCredentials } from "./credentials.ts"; +import { + decryptCredential, + encryptCredential, + parseCredentials, +} from "./credentials.ts"; const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"; @@ -12,7 +16,9 @@ test("decrypts a known AES-256-GCM credential", async () => { }); test("encrypts a credential with a fresh supplied nonce", async () => { - expect(await encryptCredential("secret-token", MASTER_KEY, NONCE)).toStrictEqual(ENCRYPTED); + expect( + await encryptCredential("secret-token", MASTER_KEY, NONCE), + ).toStrictEqual(ENCRYPTED); }); test("rejects a credential encrypted by another master key", async () => { @@ -21,7 +27,10 @@ test("rejects a credential encrypted by another master key", async () => { test("rejects a credential with a nonstandard nonce", async () => { await expect( - decryptCredential("v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", MASTER_KEY), + decryptCredential( + "v1:AA:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA", + MASTER_KEY, + ), ).rejects.toThrow("nonce must be 12 bytes"); }); @@ -57,10 +66,15 @@ test("decrypts the central reviewer token and ordered profiles", async () => { }); test("rejects malformed central credentials", async () => { - await expect(parseCredentials('{"profiles": []}', MASTER_KEY)).rejects.toThrow("reviewer-token"); + await expect( + parseCredentials('{"profiles": []}', MASTER_KEY), + ).rejects.toThrow("reviewer-token"); await expect( parseCredentials( - JSON.stringify({ "reviewer-token": ENCRYPTED, profiles: [{ type: "CLAUDE" }] }), + JSON.stringify({ + "reviewer-token": ENCRYPTED, + profiles: [{ type: "CLAUDE" }], + }), MASTER_KEY, ), ).rejects.toThrow("profiles[0].token"); @@ -74,5 +88,8 @@ test("reads lowercase profile types from older credentials", async () => { }), MASTER_KEY, ); - expect(credentials.profiles.map(({ type }) => type)).toStrictEqual(["CODEX", "CLAUDE"]); + expect(credentials.profiles.map(({ type }) => type)).toStrictEqual([ + "CODEX", + "CLAUDE", + ]); }); diff --git a/src/runtime/credentials.ts b/src/runtime/credentials.ts index 3e9634a..3861918 100644 --- a/src/runtime/credentials.ts +++ b/src/runtime/credentials.ts @@ -23,15 +23,24 @@ export interface BotCredentials { function decodeBase64Url(value: string): Uint8Array { const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); - return Uint8Array.from(atob(padded), (character) => character.codePointAt(0) ?? 0); + return Uint8Array.from( + atob(padded), + (character) => character.codePointAt(0) ?? 0, + ); } function encodeBase64Url(value: Uint8Array): string { - const binary = Array.from(value, (byte) => String.fromCodePoint(byte)).join(""); - return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); + const binary = Array.from(value, (byte) => String.fromCodePoint(byte)).join( + "", + ); + return btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replaceAll("=", ""); } -const buffer = (value: Uint8Array): ArrayBuffer => Uint8Array.from(value).buffer; +const buffer = (value: Uint8Array): ArrayBuffer => + Uint8Array.from(value).buffer; async function importMasterKey( encoded: string, @@ -41,13 +50,28 @@ async function importMasterKey( if (bytes.length !== 32) { throw new Error("master-key must be 32 bytes encoded as base64url"); } - const key = await webcrypto.subtle.importKey("raw", buffer(bytes), "AES-GCM", false, [usage]); + const key = await webcrypto.subtle.importKey( + "raw", + buffer(bytes), + "AES-GCM", + false, + [usage], + ); return key; } -export async function decryptCredential(encrypted: string, masterKey: string): Promise { - const [version, encodedNonce = "", encodedCiphertext = "", extra = ""] = encrypted.split(":"); - if (version !== "v1" || encodedNonce === "" || encodedCiphertext === "" || extra !== "") { +export async function decryptCredential( + encrypted: string, + masterKey: string, +): Promise { + const [version, encodedNonce = "", encodedCiphertext = "", extra = ""] = + encrypted.split(":"); + if ( + version !== "v1" || + encodedNonce === "" || + encodedCiphertext === "" || + extra !== "" + ) { throw new Error("credential must use v1::"); } const nonce = decodeBase64Url(encodedNonce); @@ -89,7 +113,11 @@ function currentSpelling(type: unknown): unknown { return OLD_PROFILE_TYPES.get(type) ?? type; } -async function parseProfile(entry: unknown, index: number, masterKey: string): Promise { +async function parseProfile( + entry: unknown, + index: number, + masterKey: string, +): Promise { const at = `profiles[${index}]`; if (!isMapping(entry)) { throw new TypeError(`${at}: expected a mapping`); @@ -123,7 +151,10 @@ async function parseProfile(entry: unknown, index: number, masterKey: string): P }; } -export async function parseCredentials(text: string, masterKey: string): Promise { +export async function parseCredentials( + text: string, + masterKey: string, +): Promise { const value: unknown = JSON.parse(text); if (!isMapping(value)) { throw new TypeError("credentials must be a mapping"); @@ -147,5 +178,8 @@ export async function parseCredentials(text: string, masterKey: string): Promise return profile; }), ); - return { reviewerToken: await decryptCredential(reviewerToken, masterKey), profiles: parsed }; + return { + reviewerToken: await decryptCredential(reviewerToken, masterKey), + profiles: parsed, + }; } diff --git a/src/runtime/runtime.test.ts b/src/runtime/runtime.test.ts index 55b6e6c..7e97b09 100644 --- a/src/runtime/runtime.test.ts +++ b/src/runtime/runtime.test.ts @@ -1,5 +1,12 @@ import { spawnSync } from "node:child_process"; -import { chmod, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { + chmod, + readdir, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; import process from "node:process"; import { fileURLToPath } from "node:url"; import { assert, expect, test } from "vite-plus/test"; @@ -12,91 +19,111 @@ const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA"; // Runs `command` as the profile's user, from the root directory. -function runAs(profile: RuntimeProfile, command: string, path: string): boolean { - const result = spawnSync(command, [path], { cwd: "/", uid: profile.uid, gid: profile.gid }); +function runAs( + profile: RuntimeProfile, + command: string, + path: string, +): boolean { + const result = spawnSync(command, [path], { + cwd: "/", + uid: profile.uid, + gid: profile.gid, + }); return result.status === 0; } -async function expectPrivate(path: string, uid: number, mode: number): Promise { +async function expectPrivate( + path: string, + uid: number, + mode: number, +): Promise { const info = await stat(path); expect(info.uid).toStrictEqual(uid); expect(info.mode & 0o777).toBe(mode); } -test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credentials", async () => { - const root = await makeTempDir(); - await chmod(root, 0o711); - const action = await makeTempDir(); - try { - await writeFile( - `${action}/profiles.json`, - JSON.stringify({ - "reviewer-token": ENCRYPTED, - profiles: [{ type: "CLAUDE", token: ENCRYPTED }, { type: "CODEX" }], - }), - ); +test.skipIf(!IS_ROOT)( + "prepares root-only reviewer and isolated profile credentials", + async () => { + const root = await makeTempDir(); + await chmod(root, 0o711); + const action = await makeTempDir(); + try { + await writeFile( + `${action}/profiles.json`, + JSON.stringify({ + "reviewer-token": ENCRYPTED, + profiles: [{ type: "CLAUDE", token: ENCRYPTED }, { type: "CODEX" }], + }), + ); - const manifest = await prepareRuntime(action, root, MASTER_KEY); - const serialized = await readFile(manifest.manifestPath, "utf8"); - assert(!serialized.includes("secret-token")); - expect(await readFile(manifest.reviewerFile, "utf8")).toBe("secret-token"); - await expectPrivate(manifest.reviewerFile, 0, 0o600); + const manifest = await prepareRuntime(action, root, MASTER_KEY); + const serialized = await readFile(manifest.manifestPath, "utf8"); + assert(!serialized.includes("secret-token")); + expect(await readFile(manifest.reviewerFile, "utf8")).toBe( + "secret-token", + ); + await expectPrivate(manifest.reviewerFile, 0, 0o600); - const [claude, codex] = manifest.profiles; - assert(claude !== undefined && codex !== undefined); - expect(claude.uid).not.toStrictEqual(codex.uid); - await expectPrivate(claude.home, claude.uid, 0o700); - assert(claude.tokenFile !== null); - expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token"); - await expectPrivate(claude.tokenFile, claude.uid, 0o600); - const codexHome = await stat(codex.home); - expect(codexHome.uid).toStrictEqual(codex.uid); - expect(codex.tokenFile).toBe(null); + const [claude, codex] = manifest.profiles; + assert(claude !== undefined && codex !== undefined); + expect(claude.uid).not.toStrictEqual(codex.uid); + await expectPrivate(claude.home, claude.uid, 0o700); + assert(claude.tokenFile !== null); + expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token"); + await expectPrivate(claude.tokenFile, claude.uid, 0o600); + const codexHome = await stat(codex.home); + expect(codexHome.uid).toStrictEqual(codex.uid); + expect(codex.tokenFile).toBe(null); - const ownToken = spawnSync("/bin/cat", [claude.tokenFile], { - cwd: "/", - uid: claude.uid, - gid: claude.gid, - encoding: "utf8", - }); - expect(ownToken.status).toBe(0); - expect(ownToken.stdout).toBe("secret-token"); - expect(runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false); - expect(runAs(claude, "/bin/ls", codex.home)).toBe(false); - } finally { - await rm(root, { recursive: true }); - await rm(action, { recursive: true }); - } -}); + const ownToken = spawnSync("/bin/cat", [claude.tokenFile], { + cwd: "/", + uid: claude.uid, + gid: claude.gid, + encoding: "utf8", + }); + expect(ownToken.status).toBe(0); + expect(ownToken.stdout).toBe("secret-token"); + expect(runAs(claude, "/bin/cat", manifest.reviewerFile)).toBe(false); + expect(runAs(claude, "/bin/ls", codex.home)).toBe(false); + } finally { + await rm(root, { recursive: true }); + await rm(action, { recursive: true }); + } + }, +); -test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fails", async () => { - const root = await makeTempDir(); - const action = await makeTempDir(); - try { - await writeFile( - `${action}/profiles.json`, - JSON.stringify({ - "reviewer-token": ENCRYPTED, - profiles: [{ type: "CLAUDE", token: ENCRYPTED }], - }), - ); - const result = spawnSync( - process.execPath, - [fileURLToPath(new URL("../prepare/index.ts", import.meta.url))], - { - env: Object.fromEntries([ - ["ACTION_PATH", action], - ["RUNNER_TEMP", root], - ["BOT_AGENTS_MASTER_KEY", MASTER_KEY], - ["GITHUB_OUTPUT", action], - ]), - }, - ); +test.skipIf(!IS_ROOT)( + "removes prepared credentials when output publication fails", + async () => { + const root = await makeTempDir(); + const action = await makeTempDir(); + try { + await writeFile( + `${action}/profiles.json`, + JSON.stringify({ + "reviewer-token": ENCRYPTED, + profiles: [{ type: "CLAUDE", token: ENCRYPTED }], + }), + ); + const result = spawnSync( + process.execPath, + [fileURLToPath(new URL("../prepare/index.ts", import.meta.url))], + { + env: Object.fromEntries([ + ["ACTION_PATH", action], + ["RUNNER_TEMP", root], + ["BOT_AGENTS_MASTER_KEY", MASTER_KEY], + ["GITHUB_OUTPUT", action], + ]), + }, + ); - expect(result.status).not.toBe(0); - expect(await readdir(root)).toStrictEqual([]); - } finally { - await rm(root, { recursive: true }); - await rm(action, { recursive: true }); - } -}); + expect(result.status).not.toBe(0); + expect(await readdir(root)).toStrictEqual([]); + } finally { + await rm(root, { recursive: true }); + await rm(action, { recursive: true }); + } + }, +); diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index cf09236..bab5d32 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -1,5 +1,13 @@ -import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { + chmod, + chown, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import nodePath from "node:path"; import { field, isMapping } from "#common"; import type { ProfileFailure, ProfileIdentity } from "#profiles"; @@ -39,7 +47,12 @@ export interface RuntimeFailure { export type RuntimeOutcome = RuntimeSuccess | RuntimeFailure; -async function writePrivate(path: string, value: string, uid: number, gid: number): Promise { +async function writePrivate( + path: string, + value: string, + uid: number, + gid: number, +): Promise { await writeFile(path, value, { mode: 0o600 }); await chmod(path, 0o600); await chown(path, uid, gid); @@ -51,25 +64,27 @@ export async function prepareRuntime( masterKey: string, ): Promise { const credentials = await parseCredentials( - await readFile(join(actionPath, "profiles.json"), "utf8"), + await readFile(nodePath.join(actionPath, "profiles.json"), "utf8"), masterKey, ); - const runtimeDir = await mkdtemp(join(runtimeRoot, "bot-agents-")); + const runtimeDir = await mkdtemp(nodePath.join(runtimeRoot, "bot-agents-")); try { await chmod(runtimeDir, 0o711); - const reviewerFile = join(runtimeDir, "reviewer-token"); + const reviewerFile = nodePath.join(runtimeDir, "reviewer-token"); await writePrivate(reviewerFile, credentials.reviewerToken, 0, 0); // One profile at a time, in order, as each claims the next UID. const profiles: RuntimeProfile[] = []; for (const [index, profile] of credentials.profiles.entries()) { const uid = FIRST_PROFILE_UID + index; - const home = join(runtimeDir, `profile-${index}`); + const home = nodePath.join(runtimeDir, `profile-${index}`); await mkdir(home, { mode: 0o700 }); await chmod(home, 0o700); await chown(home, uid, uid); const tokenFile = - profile.token === undefined || profile.token === "" ? null : join(home, "token"); + profile.token === undefined || profile.token === "" + ? null + : nodePath.join(home, "token"); if (tokenFile !== null && profile.token !== undefined) { await writePrivate(tokenFile, profile.token, uid, uid); } @@ -84,12 +99,12 @@ export async function prepareRuntime( }); } - const manifestPath = join(runtimeDir, "manifest.json"); + const manifestPath = nodePath.join(runtimeDir, "manifest.json"); const manifest: RuntimeManifest = { runtimeDir, manifestPath, reviewerFile, - outcomeFile: join(runtimeDir, "outcome.json"), + outcomeFile: nodePath.join(runtimeDir, "outcome.json"), profiles, }; await writePrivate(manifestPath, JSON.stringify(manifest), 0, 0); @@ -111,7 +126,9 @@ function isRuntimeManifest(value: unknown): value is RuntimeManifest { ); } -export async function readRuntimeManifest(path: string): Promise { +export async function readRuntimeManifest( + path: string, +): Promise { const value: unknown = JSON.parse(await readFile(path, "utf8")); if (!isRuntimeManifest(value)) { throw new Error(`${path}: invalid runtime manifest`); diff --git a/src/testing/index.ts b/src/testing/index.ts index 66752c8..20db225 100644 --- a/src/testing/index.ts +++ b/src/testing/index.ts @@ -6,7 +6,7 @@ import { spawn } from "node:child_process"; import { once } from "node:events"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import nodePath from "node:path"; import process from "node:process"; import { text } from "node:stream/consumers"; @@ -25,7 +25,7 @@ export const IS_ROOT: boolean = currentUid() === 0; export const IS_WINDOWS: boolean = process.platform === "win32"; export async function makeTempDir(): Promise { - const path = await mkdtemp(join(tmpdir(), "bot-agents-test-")); + const path = await mkdtemp(nodePath.join(tmpdir(), "bot-agents-test-")); return path; } @@ -56,7 +56,10 @@ export async function runCommand( env: Object.fromEntries(env), stdio: ["ignore", "ignore", "pipe"], }); - const [stderr, closed] = await Promise.all([text(child.stderr), once(child, "close")]); + const [stderr, closed] = await Promise.all([ + text(child.stderr), + once(child, "close"), + ]); const exitCode: unknown = closed[0]; return { success: exitCode === 0, stderr }; } diff --git a/tests/entrypoints.test.ts b/tests/entrypoints.test.ts index ed7af66..c7fb75d 100644 --- a/tests/entrypoints.test.ts +++ b/tests/entrypoints.test.ts @@ -14,7 +14,9 @@ const PHASES: [phase: string, firstVariable: string][] = [ ]; test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => { - const path = fileURLToPath(new URL(`../src/${phase}/index.ts`, import.meta.url)); + const path = fileURLToPath( + new URL(`../src/${phase}/index.ts`, import.meta.url), + ); const result = await runCommand( process.execPath, [path], diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index 9564744..45c9afc 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -9,13 +9,18 @@ import { inheritedEnvironment, makeTempDir, runCommand } from "#testing"; type Mapping = Map; const mapping = (value: unknown, at: string): Mapping => { - assert(typeof value === "object" && value !== null && !Array.isArray(value), at); + assert( + typeof value === "object" && value !== null && !Array.isArray(value), + at, + ); return new Map(Object.entries(value)); }; const sequence = (value: unknown, at: string): Mapping[] => { assert(Array.isArray(value), at); - return value.map((entry: unknown, index) => mapping(entry, `${at}[${index}]`)); + return value.map((entry: unknown, index) => + mapping(entry, `${at}[${index}]`), + ); }; async function yaml(path: string): Promise { @@ -49,8 +54,13 @@ test("all committed YAML parses", async () => { test("the action exposes only the master key and scopes it to preparation", async () => { const action = await yaml("action.yml"); - expect([...mapping(action.get("inputs"), "inputs").keys()]).toStrictEqual(["master-key"]); - const steps = sequence(mapping(action.get("runs"), "runs").get("steps"), "runs.steps"); + expect([...mapping(action.get("inputs"), "inputs").keys()]).toStrictEqual([ + "master-key", + ]); + const steps = sequence( + mapping(action.get("runs"), "runs").get("steps"), + "runs.steps", + ); for (const step of steps) { const run = step.get("run"); if (typeof run === "string") { @@ -62,13 +72,21 @@ test("the action exposes only the master key and scopes it to preparation", asyn const agent = findStep(steps, "id", "agent"); const post = findStep(steps, "id", "post"); findStep(steps, "id", "source"); - expect(mapping(checkout.get("with"), "checkout.with").get("token")).toBe("${{ gitea.token }}"); - expect(mapping(prepare.get("env"), "prepare.env").get("BOT_AGENTS_MASTER_KEY")).toBe( - "${{ inputs.master-key }}", + expect(mapping(checkout.get("with"), "checkout.with").get("token")).toBe( + "${{ gitea.token }}", + ); + expect( + mapping(prepare.get("env"), "prepare.env").get("BOT_AGENTS_MASTER_KEY"), + ).toBe("${{ inputs.master-key }}"); + expect( + JSON.stringify(Object.fromEntries(agent)).includes("BOT_AGENTS_MASTER_KEY"), + ).toBe(false); + expect( + JSON.stringify(Object.fromEntries(post)).includes("BOT_AGENTS_MASTER_KEY"), + ).toBe(false); + expect(mapping(agent.get("env"), "agent.env").get("GITEA_TOKEN")).toBe( + "${{ gitea.token }}", ); - expect(JSON.stringify(Object.fromEntries(agent)).includes("BOT_AGENTS_MASTER_KEY")).toBe(false); - expect(JSON.stringify(Object.fromEntries(post)).includes("BOT_AGENTS_MASTER_KEY")).toBe(false); - expect(mapping(agent.get("env"), "agent.env").get("GITEA_TOKEN")).toBe("${{ gitea.token }}"); assert(String(post.get("if")).includes("always()")); for (const step of [prepare, agent, post]) { assert(String(step.get("run")).includes("steps.source.outputs.path")); @@ -83,7 +101,11 @@ test("the source protection script stages immutable action files", async () => { environment.set("ACTION_PATH", process.cwd()); environment.set("GITHUB_OUTPUT", outputFile); environment.set("RUNNER_TEMP", temp); - const result = await runCommand("/bin/bash", ["scripts/protect-source.sh"], environment); + const result = await runCommand( + "/bin/bash", + ["scripts/protect-source.sh"], + environment, + ); assert(result.success, result.stderr); const output = await readFile(outputFile, "utf8"); @@ -116,9 +138,9 @@ test("the source protection script stages immutable action files", async () => { test("module-relative runtime assets exist", async () => { const module = new URL("../src/run/index.ts", import.meta.url); const source = await readFile(module, "utf8"); - const assets = [...source.matchAll(/new URL\("(?[^"]+)", import\.meta\.url\)/gu)].map( - ([, asset = ""]) => new URL(asset, module), - ); + const assets = [ + ...source.matchAll(/new URL\("(?[^"]+)", import\.meta\.url\)/gu), + ].map(([, asset = ""]) => new URL(asset, module)); const stats = await Promise.all( assets.map(async (asset) => { const info = await stat(asset); @@ -132,7 +154,10 @@ test("module-relative runtime assets exist", async () => { test("the repository uses its trusted main action", async () => { const workflow = await yaml(".gitea/workflows/agents.yml"); - const job = mapping(mapping(workflow.get("jobs"), "jobs").get("agent"), "jobs.agent"); + const job = mapping( + mapping(workflow.get("jobs"), "jobs").get("agent"), + "jobs.agent", + ); const permissions = mapping(job.get("permissions"), "jobs.agent.permissions"); expect(Object.fromEntries(permissions)).toStrictEqual({ code: "write", @@ -140,17 +165,30 @@ test("the repository uses its trusted main action", async () => { "pull-requests": "write", }); const steps = sequence(job.get("steps"), "jobs.agent.steps"); - expect(steps.findIndex((step) => step.get("uses") === "./")).toStrictEqual(-1); - const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main"); + expect(steps.findIndex((step) => step.get("uses") === "./")).toStrictEqual( + -1, + ); + const trusted = findStep( + steps, + "uses", + "https://git.capsulizers.com/commons/bot-agents@main", + ); expect(mapping(trusted.get("with"), "trusted.with").get("master-key")).toBe( "${{ secrets.BOT_AGENTS_MASTER_KEY }}", ); - expect(JSON.stringify(Object.fromEntries(workflow)).includes("gitea.actor != 'bot'")).toBe(true); + expect( + JSON.stringify(Object.fromEntries(workflow)).includes( + "gitea.actor != 'bot'", + ), + ).toBe(true); }); test("CI checks every phase and the root isolation suite", async () => { const workflow = await yaml(".gitea/workflows/check.yml"); - const job = mapping(mapping(workflow.get("jobs"), "jobs").get("check"), "jobs.check"); + const job = mapping( + mapping(workflow.get("jobs"), "jobs").get("check"), + "jobs.check", + ); const commands = sequence(job.get("steps"), "jobs.check.steps") .map((step) => { const run = step.get("run"); @@ -169,7 +207,8 @@ test("uses the standard Skills installer without custom discovery", async () => assert(runner.includes("skills add obra/superpowers")); const removed = await stat("superpowers.ts").then( () => false, - (error: unknown) => error instanceof Error && "code" in error && error.code === "ENOENT", + (error: unknown) => + error instanceof Error && "code" in error && error.code === "ENOENT", ); assert(removed, "superpowers.ts should be removed"); }); @@ -191,8 +230,13 @@ test("profile preparation does not receive provider credentials", async () => { }); test("central profiles fall back from Codex to Claude to OpenCode", async () => { - const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8")); - const profiles = sequence(mapping(credentials, "profiles.json").get("profiles"), "profiles"); + const credentials: unknown = JSON.parse( + await readFile("profiles.json", "utf8"), + ); + const profiles = sequence( + mapping(credentials, "profiles.json").get("profiles"), + "profiles", + ); expect(profiles.map((profile) => profile.get("type"))).toStrictEqual([ "CODEX", "CLAUDE", diff --git a/vite.config.ts b/vite.config.ts index c6a5295..5ed8611 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,11 @@ import { defineConfig } from "vite-plus"; import projectLint, { projectOverrides } from "./scripts/project-lint.ts"; export default defineConfig({ + fmt: { + printWidth: 80, + quoteProps: "preserve", + embeddedLanguageFormatting: "off", + }, lint: { plugins: ["typescript", "unicorn", "oxc", "import"], jsPlugins: [ -- 2.52.0 From 6503d3bedac4812c33d21d822328b0bc74bcbcbf Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Tue, 20 Oct 2026 09:56:29 +0900 Subject: [PATCH 20/20] Bump explicit-types and import-structure lint plugins --- package-lock.json | 16 ++++++++-------- package.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4688e66..811bfa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "@eslint/core": "^0.17.0", "@types/node": "^24.19.2", "@typescript-eslint/types": "^8.71.2", - "eslint-plugin-explicit-types": "^0.7.1", - "eslint-plugin-import-structure": "^0.10.3", + "eslint-plugin-explicit-types": "^0.7.2", + "eslint-plugin-import-structure": "^0.10.4", "vite-plus": "^1.2.0", "yaml": "^2.9.1" } @@ -1890,9 +1890,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-explicit-types": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-explicit-types/-/eslint-plugin-explicit-types-0.7.1.tgz", - "integrity": "sha512-iUM3s5BNHgB/GztHMtdB5EVKAlNcHAQnFbOQT2xckXFQLLh6dFjH0YGJ/lZ3ypaIs1kVU205NCrR207kEA+edg==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-explicit-types/-/eslint-plugin-explicit-types-0.7.2.tgz", + "integrity": "sha512-ffHVVuyBH2esZwwRItcz2sDnL5xvSRDAoLjSqmQbevT6ETcjtfThtLoWoc+cv5pvtxa6YiXEK1P2D+x/yUBc1g==", "dev": true, "license": "MIT", "dependencies": { @@ -1903,9 +1903,9 @@ } }, "node_modules/eslint-plugin-import-structure": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-import-structure/-/eslint-plugin-import-structure-0.10.3.tgz", - "integrity": "sha512-wt5R/G/bH8M2dXu1SvT0E4sclO59BF71hK0B7Tv/IEZIRMHyIjfuwlUqeWqHCSlmowA2N5b7NAsf9M5VNw9ZQA==", + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-import-structure/-/eslint-plugin-import-structure-0.10.4.tgz", + "integrity": "sha512-BxRKczGLm0+CrfxFz4mJwEaVZjbY6XGQ6QIyzlzTCdH6uMqi+iL6LIAJz8neHSpUgFWF+TvrBiAzSgNTcOkAgA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 6e81898..decd5e6 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,8 @@ "@eslint/core": "^0.17.0", "@types/node": "^24.19.2", "@typescript-eslint/types": "^8.71.2", - "eslint-plugin-explicit-types": "^0.7.1", - "eslint-plugin-import-structure": "^0.10.3", + "eslint-plugin-explicit-types": "^0.7.2", + "eslint-plugin-import-structure": "^0.10.4", "vite-plus": "^1.2.0", "yaml": "^2.9.1" } -- 2.52.0