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,