Lint with every Oxlint category and list only the offs and options
Check / check (pull_request) Successful in 51s

This commit is contained in:
Danny Kim
2026-10-19 11:26:48 +09:00
parent c8ffb9cc6f
commit d09806daad
19 changed files with 293 additions and 157 deletions
+116 -70
View File
@@ -1,7 +1,7 @@
/** /**
* `project` — lint rules this repository holds its own source to, on top of * `project` — this repository's changes to the Oxlint categories in
* the Oxlint categories in `vite.config.ts`. Its rules of its own are * `vite.config.ts`. Its rules of its own are `no-disable-comments` and
* `no-disable-comments` and `no-asset-imports`. * `no-asset-imports`.
* *
* Loaded by `vite.config.ts` as an Oxlint `jsPlugins` entry. * 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. */ /** The source code both hosts hand a rule, with the comment list. */
interface ProjectSourceCode extends TextSourceCode { interface ProjectSourceCode extends TextSourceCode {
getAllComments(): TSESTree.Comment[]; getAllComments: () => TSESTree.Comment[];
} }
/** ESLint's default rule types, reporting TypeScript-ESTree nodes. */ /** 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 * This repository's changes to the Oxlint categories in `vite.config.ts`,
* sit outside the Oxlint categories, plus the template's extras. Built-in * which turn every non-nursery category on: its own rules, the category
* Oxlint rule ids sit here too, so `vite.config.ts` lists no rule by name. * rules it turns off, and options that differ from a rule's defaults.
*/ */
const rules: DummyRuleMap = { 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 // The explicit-types template's `camelcase` stand-in: no `_` inside a name
// once leading and trailing ones are trimmed, unless it has no lowercase // once leading and trailing ones are trimmed, unless it has no lowercase
// ASCII letter (UPPER_SNAKE). // ASCII letter (UPPER_SNAKE).
@@ -174,31 +179,6 @@ const rules: DummyRuleMap = {
"^_*[^_]*_*$|^[^a-z]*$", "^_*[^_]*_*$|^[^a-z]*$",
{ properties: false, classFields: false, onlyDeclarations: false, ignoreDestructuring: true }, { 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. // On Node, these come from an explicit `node:` import instead of the global scope.
"no-restricted-globals": [ "no-restricted-globals": [
"error", "error",
@@ -209,45 +189,111 @@ const rules: DummyRuleMap = {
"setImmediate", "setImmediate",
"clearImmediate", "clearImmediate",
], ],
// The explicit-types template's extras. // Single capital type parameters, which explicit-types requires, may be one letter.
"import/no-cycle": "error", "id-length": ["error", { exceptionPatterns: ["^[A-Z]$"] }],
// `../` is banned in every file; anything above a file needs a `#` alias. "one-var": ["error", "never"],
"import/no-relative-parent-imports": "error", "no-use-before-define": ["error", { functions: false }],
"typescript/consistent-type-imports": "error", "func-style": ["error", "declaration", { allowArrowFunctions: true }],
"typescript/no-import-type-side-effects": "error", "unicorn/import-style": ["error", { styles: { "node:path": { named: true } } }],
"typescript/no-non-null-assertion": "error", // Each limit is the smallest value the current code passes.
// No disable comments at all. complexity: ["error", 26],
"project/no-disable-comments": "error", "max-depth": ["error", 6],
"project/no-asset-imports": "error", "max-lines": ["error", { max: 2048, skipBlankLines: false, skipComments: false }],
"unicorn/no-abusive-eslint-disable": "error", "max-lines-per-function": ["error", 96],
// Size and complexity limits match Deno's: this repo's `deno lint` ran the "max-params": ["error", 4],
// `recommended` set with no config file, so no size or complexity rule "max-statements": ["error", 32],
// applied and none applies here. Each Oxlint limit rule, from a category or "import/max-dependencies": ["error", { max: 13 }],
// not, is off: "unicorn/max-nested-calls": ["error", { max: 4 }],
// - `complexity`: Deno had no `max-complexity`.
// - `max-lines`: Deno had no `max-file-lines`. // Collides with another rule, a plugin config, or Oxfmt.
// - `max-params`: Deno had no `max-params`. // Collides with `#private`: members are public unless marked so.
// - `id-length`: Deno had no `max-identifier-length`. "typescript/explicit-member-accessibility": "off",
// - `no-nested-ternary`: Deno had no `max-nest-ternaries`. // Collides with `typescript/prefer-readonly-parameter-types` being off.
// - `max-lines-per-function`, `max-classes-per-file`, `max-depth`, "typescript/prefer-readonly": "off",
// `max-nested-callbacks`, `max-statements`, `import/max-dependencies`, // Collides with Node's and the web's mutable types (`Response`, `Stats`).
// `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.
"typescript/prefer-readonly-parameter-types": "off", "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",
}; };
/** /**
+10 -8
View File
@@ -19,10 +19,10 @@ test("retries once after an invalid refresh token", async () => {
() => () =>
Promise.resolve({ Promise.resolve({
code: 1, 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(); return Promise.resolve();
}, },
); );
@@ -39,14 +39,14 @@ test("does not retry an unrelated failure", async () => {
let relogins = 0; let relogins = 0;
await retryInvalidToken( await retryInvalidToken(
() => { () => {
attempts++; attempts += 1;
return Promise.resolve({ return Promise.resolve({
code: 1, code: 1,
error: "rate limited", error: "rate limited",
}); });
}, },
() => { () => {
relogins++; relogins += 1;
return Promise.resolve(); return Promise.resolve();
}, },
); );
@@ -87,11 +87,11 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => {
const result = await retryInvalidToken( const result = await retryInvalidToken(
() => () =>
Promise.resolve({ Promise.resolve({
code: attempts++ > 0 ? 0 : 1, code: (attempts += 1) > 1 ? 0 : 1,
error: attempts === 1 ? REVOKED_CODEX.join("\n") : "", error: attempts === 1 ? REVOKED_CODEX.join("\n") : "",
}), }),
() => { () => {
relogins++; relogins += 1;
return Promise.resolve(); 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()); const staged = await stageCodexAuth(canonical, home, currentUid(), currentGid());
expect(await readFile(staged, "utf8")).toStrictEqual(AUTH); 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({ const refreshed = JSON.stringify({
auth_mode: "chatgpt", 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 writeFile(staged, refreshed);
await persistCodexAuth(staged, canonical); await persistCodexAuth(staged, canonical);
expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed); 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 { } finally {
await rm(root, { recursive: true }); await rm(root, { recursive: true });
} }
+3 -1
View File
@@ -121,7 +121,9 @@ export async function stageCodexAuth(
await chmod(stagedPath, 0o600); await chmod(stagedPath, 0o600);
await chown(stagedPath, uid, gid); await chown(stagedPath, uid, gid);
} catch (error) { } catch (error) {
if (!isNotFound(error)) throw error; if (!isNotFound(error)) {
throw error;
}
} }
return stagedPath; return stagedPath;
} }
+12 -4
View File
@@ -49,7 +49,9 @@ export async function collectOpenCodeOutput(
export function openCodeError(output: string): Error | null { export function openCodeError(output: string): Error | null {
let failure: Error | null = null; let failure: Error | null = null;
for (const line of output.split("\n")) { for (const line of output.split("\n")) {
if (line.trim() === "") continue; if (line.trim() === "") {
continue;
}
let event: unknown; let event: unknown;
try { try {
event = JSON.parse(line); event = JSON.parse(line);
@@ -80,12 +82,18 @@ export function openCodeError(output: string): Error | null {
export function parseOpenCodeEvents(output: string): string { export function parseOpenCodeEvents(output: string): string {
let result: string | null = null; let result: string | null = null;
for (const line of output.split("\n")) { for (const line of output.split("\n")) {
if (line.trim() === "") continue; if (line.trim() === "") {
continue;
}
const event: unknown = JSON.parse(line); const event: unknown = JSON.parse(line);
const part = field(event, "part"); 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"); const text = field(part, "text");
if (typeof text === "string" && text.trim() !== "") result = text; if (typeof text === "string" && text.trim() !== "") {
result = text;
}
} }
if (result === null) { if (result === null) {
throw openCodeError(output) ?? new Error("OpenCode produced no text output"); throw openCodeError(output) ?? new Error("OpenCode produced no text output");
+3 -1
View File
@@ -8,7 +8,9 @@ export { type Fetcher, type PostContext, postRuntime } from "./post.ts";
function required(name: string): string { function required(name: string): string {
const value = process.env[name]; 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; return value;
} }
+12 -4
View File
@@ -45,9 +45,15 @@ async function writeArtifacts(
review?: string, review?: string,
anchors?: unknown, anchors?: unknown,
): Promise<void> { ): Promise<void> {
if (!outcome.ok) return; if (!outcome.ok) {
if (review !== undefined) await writePrivate(outcome.reviewPath, review); return;
if (anchors !== undefined) await writePrivate(outcome.anchorsPath, JSON.stringify(anchors)); }
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 { function manifestFor(runtimeDir: string, profileHome: string): RuntimeManifest {
@@ -106,7 +112,9 @@ const missing = async (path: string): Promise<boolean> => {
await stat(path); await stat(path);
return false; return false;
} catch (error) { } catch (error) {
if (isNotFound(error)) return true; if (isNotFound(error)) {
return true;
}
throw error; throw error;
} }
}; };
+23 -10
View File
@@ -22,12 +22,12 @@ interface Anchor {
const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu"); const ANSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*[A-Za-z]`, "gu");
const stripAnsi = (text: string): string => text.replace(ANSI, ""); const stripAnsi = (text: string): string => text.replace(ANSI, "");
const VERDICTS: Map<string, [event: string, mark: string]> = new Map([ const VERDICTS = new Map<string, [event: string, mark: string]>([
["Approved", ["APPROVED", "✅"]], ["Approved", ["APPROVED", "✅"]],
["Changes requested", ["REQUEST_CHANGES", "🛑"]], ["Changes requested", ["REQUEST_CHANGES", "🛑"]],
]); ]);
const PROFILE_TYPES: Set<unknown> = new Set(["claude", "codex", "opencode"]); const PROFILE_TYPES = new Set<unknown>(["claude", "codex", "opencode"]);
const FAILURE_REASONS: Set<unknown> = new Set(["sign-in failed", "usage exhausted", "failed"]); const FAILURE_REASONS = new Set<unknown>(["sign-in failed", "usage exhausted", "failed"]);
const isProfileType = (value: unknown): value is ProfileFailure["type"] => PROFILE_TYPES.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 isFailureReason = (value: unknown): value is FailureReason => FAILURE_REASONS.has(value);
const failureComment = (failures: ProfileFailure[]): string => const failureComment = (failures: ProfileFailure[]): string =>
@@ -40,7 +40,9 @@ class GiteaRequestError extends Error {}
function parseAnchors(text: string, sourcePath: string): Anchor[] { function parseAnchors(text: string, sourcePath: string): Anchor[] {
const entries: unknown = JSON.parse(text); 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) => { return entries.map((entry: unknown, index) => {
const at = `${sourcePath}[${index}]`; const at = `${sourcePath}[${index}]`;
if (!isObject(entry)) { if (!isObject(entry)) {
@@ -116,12 +118,16 @@ async function readAgentArtifact(
name: string, name: string,
): Promise<string | null> { ): Promise<string | null> {
const profile = manifest.profiles.find((entry) => join(entry.home, name) === path); 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; let stat: Stats;
try { try {
stat = await lstat(path); stat = await lstat(path);
} catch (error) { } catch (error) {
if (isNotFound(error)) return null; if (isNotFound(error)) {
return null;
}
throw error; throw error;
} }
if ( if (
@@ -143,7 +149,8 @@ export async function postRuntime(
): Promise<void> { ): Promise<void> {
const manifest = await readRuntimeManifest(manifestPath); const manifest = await readRuntimeManifest(manifestPath);
try { 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<void> => { const request = async (path: string, body: unknown): Promise<void> => {
let response: Response; let response: Response;
try { try {
@@ -208,20 +215,26 @@ export async function postRuntime(
})), })),
}); });
await postReview(anchors).catch(async (error: unknown) => { await postReview(anchors).catch(async (error: unknown) => {
if (anchors.length === 0) throw error; if (anchors.length === 0) {
throw error;
}
console.error( console.error(
`inline comments rejected: ${error instanceof Error ? error.message : String(error)}`, `inline comments rejected: ${error instanceof Error ? error.message : String(error)}`,
); );
await postReview([]); await postReview([]);
}); });
} catch (error) { } catch (error) {
if (error instanceof GiteaRequestError) throw error; if (error instanceof GiteaRequestError) {
throw error;
}
await postComment(failureComment([{ ...outcome.profile, reason: "failed" }])); await postComment(failureComment([{ ...outcome.profile, reason: "failed" }]));
throw error; throw error;
} }
} finally { } finally {
await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => { await rm(manifest.runtimeDir, { recursive: true }).catch((error: unknown) => {
if (!isNotFound(error)) throw error; if (!isNotFound(error)) {
throw error;
}
}); });
} }
} }
+3 -1
View File
@@ -5,7 +5,9 @@ import { prepareRuntime } from "#runtime";
const env = (name: string): string => { const env = (name: string): string => {
const value = process.env[name]; 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; return value;
}; };
+3 -1
View File
@@ -96,7 +96,9 @@ test.skipIf(!IS_ROOT)("cleanup leaves no runnable daemonized process", async ()
await killProfileProcesses(selected.uid); await killProfileProcesses(selected.uid);
const status = await readFile(`/proc/${pid}/status`, "utf8").catch((error: unknown) => { const status = await readFile(`/proc/${pid}/status`, "utf8").catch((error: unknown) => {
if (isNotFound(error)) return null; if (isNotFound(error)) {
return null;
}
throw error; throw error;
}); });
expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true); expect(status === null || !isRunnableProfileProcess(status, selected.uid)).toBe(true);
+8 -4
View File
@@ -6,8 +6,8 @@ import { isNotFound } from "#common";
import type { RuntimeProfile } from "#runtime"; import type { RuntimeProfile } from "#runtime";
export function isRunnableProfileProcess(status: string, uid: number): boolean { export function isRunnableProfileProcess(status: string, uid: number): boolean {
const owner = /^Uid:\s+(\d+)/mu.exec(status)?.[1]; const owner = /^Uid:\s+(?<uid>\d+)/mu.exec(status)?.[1];
const state = /^State:\s+(\w)/mu.exec(status)?.[1]; const state = /^State:\s+(?<state>\w)/mu.exec(status)?.[1];
return owner !== undefined && Number(owner) === uid && state !== "Z"; return owner !== undefined && Number(owner) === uid && state !== "Z";
} }
@@ -33,12 +33,16 @@ export async function killProfileProcesses(uid: number, attempt = 0): Promise<vo
throw new Error(`profile UID ${uid} still owns processes after cleanup`); throw new Error(`profile UID ${uid} still owns processes after cleanup`);
} }
const ids = await profileProcessIds(uid); const ids = await profileProcessIds(uid);
if (ids.length === 0) return; if (ids.length === 0) {
return;
}
for (const pid of ids) { for (const pid of ids) {
try { try {
process.kill(pid, "SIGKILL"); process.kill(pid, "SIGKILL");
} catch (error) { } catch (error) {
if (!isNotFound(error)) throw error; if (!isNotFound(error)) {
throw error;
}
} }
} }
await sleep(10); await sleep(10);
+22 -19
View File
@@ -62,7 +62,9 @@ test("recognizes provider API exhaustion status", async () => {
{ type: "codex", model: "gpt-5.6-terra" }, { type: "codex", model: "gpt-5.6-terra" },
], ],
(profile) => { (profile) => {
if (profile.type === "claude") throw new Error(message); if (profile.type === "claude") {
throw new Error(message);
}
return Promise.resolve("fallback"); 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") { if (profile.type === "codex") {
throw new AgentAuthError("codex sign-in failed"); 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"); return Promise.resolve("fallback");
}, },
failures, failures,
@@ -116,7 +120,8 @@ test("throws the last sign-in failure when every profile fails", async () => {
), ),
).rejects.toThrow( ).rejects.toThrow(
expect.toSatisfy( 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"]); 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", () => { test("exposes only the selected profile credential to the agent", () => {
const base = new Map( const base = new Map([
Object.entries({ ["PATH", "/usr/bin"],
PATH: "/usr/bin", ["HOME", "/home/bot"],
HOME: "/home/bot", ["UNRELATED_SECRET", "must-not-reach-agent"],
UNRELATED_SECRET: "must-not-reach-agent", ["BOT_AGENTS_MASTER_KEY", "master"],
BOT_AGENTS_MASTER_KEY: "master", ["PROFILES", "contains every secret"],
PROFILES: "contains every secret", ["REVIEWER_TOKEN", "reviewer"],
REVIEWER_TOKEN: "reviewer", ["CLAUDE_CODE_OAUTH_TOKEN", "old-claude"],
CLAUDE_CODE_OAUTH_TOKEN: "old-claude", ["ANTHROPIC_API_KEY", "old-anthropic-key"],
ANTHROPIC_API_KEY: "old-anthropic-key", ["ANTHROPIC_AUTH_TOKEN", "old-anthropic-token"],
ANTHROPIC_AUTH_TOKEN: "old-anthropic-token", ["OPENAI_API_KEY", "old-openai"],
OPENAI_API_KEY: "old-openai", ["CODEX_API_KEY", "old-codex-key"],
CODEX_API_KEY: "old-codex-key", ["CODEX_ACCESS_TOKEN", "old-codex-token"],
CODEX_ACCESS_TOKEN: "old-codex-token", ]);
}),
);
const profile = { const profile = {
index: 0, index: 0,
type: "claude" as const, type: "claude" as const,
+16 -6
View File
@@ -38,12 +38,16 @@ function isUsageExhausted(error: unknown): boolean {
} }
function failureReason(error: unknown): FailureReason { 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"; return isUsageExhausted(error) ? "usage exhausted" : "failed";
} }
export function failureFooter(failures: ProfileFailure[]): string { export function failureFooter(failures: ProfileFailure[]): string {
if (failures.length === 0) return "> Profile: none"; if (failures.length === 0) {
return "> Profile: none";
}
return `> Tried: ${failures return `> Tried: ${failures
.map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`) .map(({ type, model, reason }) => `\`${type}\` · \`${model}\` (${reason})`)
.join(", ")}`; .join(", ")}`;
@@ -66,7 +70,9 @@ async function attemptFrom<T, P extends ProfileIdentity>(
} catch (error) { } catch (error) {
const reason = failureReason(error); const reason = failureReason(error);
failures.push({ type: profile.type, model: profile.model, reason }); 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`); console.error(`${profile.type} ${reason}; trying next profile`);
return attemptFrom(profiles, attempt, failures, index + 1); return attemptFrom(profiles, attempt, failures, index + 1);
} }
@@ -85,7 +91,7 @@ export function runProfiles<T, P extends ProfileIdentity>(
export function sanitizedEnvironment( export function sanitizedEnvironment(
environment: ReadonlyMap<string, string>, environment: ReadonlyMap<string, string>,
): Map<string, string> { ): Map<string, string> {
const result: Map<string, string> = new Map(); const result = new Map<string, string>();
for (const name of [ for (const name of [
"PATH", "PATH",
"HOME", "HOME",
@@ -113,7 +119,9 @@ export function sanitizedEnvironment(
"NODE_EXTRA_CA_CERTS", "NODE_EXTRA_CA_CERTS",
]) { ]) {
const value = environment.get(name); const value = environment.get(name);
if (value !== undefined) result.set(name, value); if (value !== undefined) {
result.set(name, value);
}
} }
return result; return result;
} }
@@ -125,7 +133,9 @@ export function agentEnvironment(
): Map<string, string> { ): Map<string, string> {
const result = sanitizedEnvironment(environment); const result = sanitizedEnvironment(environment);
result.set("HOME", profile.home); 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 !== "") { if (token !== undefined && token !== "") {
const tokenName = { const tokenName = {
claude: "CLAUDE_CODE_OAUTH_TOKEN", claude: "CLAUDE_CODE_OAUTH_TOKEN",
+23 -13
View File
@@ -58,12 +58,14 @@ interface RenderedPrompt {
const env = (name: string): string => { const env = (name: string): string => {
const value = process.env[name]; 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; return value;
}; };
function definedEnvironment(): Map<string, string> { function definedEnvironment(): Map<string, string> {
const result: Map<string, string> = new Map(); const result = new Map<string, string>();
for (const [name, value] of Object.entries(process.env)) { for (const [name, value] of Object.entries(process.env)) {
if (value !== undefined) { if (value !== undefined) {
result.set(name, value); result.set(name, value);
@@ -107,11 +109,14 @@ async function runRoot(command: string, args: string[]): Promise<void> {
const code = await exitCode( 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}`); if (code !== 0) {
throw new Error(`${command} ${args[0]} exited with ${code}`);
}
} }
async function configureGitAuthor(): Promise<void> { async function configureGitAuthor(): Promise<void> {
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 login = field(user, "login");
const email = field(user, "email"); const email = field(user, "email");
if (typeof login !== "string" || typeof email !== "string") { if (typeof login !== "string" || typeof email !== "string") {
@@ -140,7 +145,9 @@ async function runAgentCommand(
stdio: "ignore", stdio: "ignore",
}); });
const code = await exitCode(child); 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<void> { async function prepareProfile(profile: RuntimeProfile): Promise<void> {
@@ -172,16 +179,15 @@ function formatComment(comment: unknown): string {
async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> { async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
const reviewPath = await makeProfileFile(profile, "review.md"); const reviewPath = await makeProfileFile(profile, "review.md");
const anchorsPath = await makeProfileFile(profile, "anchors.json"); const anchorsPath = await makeProfileFile(profile, "anchors.json");
const comments: unknown = await ( const commentsResponse = await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`);
await gitea(`repos/${REPO}/issues/${INDEX}/comments?limit=100`) const comments: unknown = await commentsResponse.json();
).json();
if (!Array.isArray(comments)) { if (!Array.isArray(comments)) {
throw new TypeError("comments: unexpected Gitea response"); throw new TypeError("comments: unexpected Gitea response");
} }
const values: Map<string, string> = new Map([ const values = new Map<string, string>([
["COMMENT", env("COMMENT")], ["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", await (await gitea(RULES_PATH)).text()], ["CODE_RULES", await gitea(RULES_PATH).then((response) => response.text())],
["EVENT_NAME", EVENT], ["EVENT_NAME", EVENT],
["GITEA_API_URL", API], ["GITEA_API_URL", API],
["GITEA_REPOSITORY", REPO], ["GITEA_REPOSITORY", REPO],
@@ -192,7 +198,7 @@ async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
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 { return {
prompt: template.replaceAll( prompt: template.replaceAll(
/\$\{(\w+)\}/gu, /\$\{(?<name>\w+)\}/gu,
(match: string, name: string) => values.get(name) ?? match, (match: string, name: string) => values.get(name) ?? match,
), ),
reviewPath, reviewPath,
@@ -367,7 +373,9 @@ async function runCodex(
gid: profile.gid, gid: profile.gid,
stdio: "ignore", 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 outputPath = await makeProfileFile(profile, "result.md");
const attempt = async (): Promise<CommandResult> => { const attempt = async (): Promise<CommandResult> => {
@@ -478,7 +486,9 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
await persistCodexAuth(stagedCodexAuth, CODEX_AUTH); 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) { if (body === null || reviewPath === null || anchorsPath === null) {
throw new Error("agent attempt produced no result"); throw new Error("agent attempt produced no result");
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import { decryptCredential, encryptCredential, parseCredentials } from "./creden
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const OTHER_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"; 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"; const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
test("decrypts a known AES-256-GCM credential", async () => { test("decrypts a known AES-256-GCM credential", async () => {
+6 -2
View File
@@ -47,7 +47,9 @@ export async function decryptCredential(encrypted: string, masterKey: string): P
throw new Error("credential must use v1:<nonce>:<ciphertext>"); throw new Error("credential must use v1:<nonce>:<ciphertext>");
} }
const nonce = decodeBase64Url(encodedNonce); 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( const plaintext = await webcrypto.subtle.decrypt(
{ name: "AES-GCM", iv: buffer(nonce) }, { name: "AES-GCM", iv: buffer(nonce) },
await importMasterKey(masterKey, "decrypt"), await importMasterKey(masterKey, "decrypt"),
@@ -61,7 +63,9 @@ export async function encryptCredential(
masterKey: string, masterKey: string,
nonce: Uint8Array = webcrypto.getRandomValues(new Uint8Array(12)), nonce: Uint8Array = webcrypto.getRandomValues(new Uint8Array(12)),
): Promise<string> { ): Promise<string> {
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( const ciphertext = await webcrypto.subtle.encrypt(
{ name: "AES-GCM", iv: buffer(nonce) }, { name: "AES-GCM", iv: buffer(nonce) },
await importMasterKey(masterKey, "encrypt"), await importMasterKey(masterKey, "encrypt"),
+8 -7
View File
@@ -49,7 +49,8 @@ test.skipIf(!IS_ROOT)("prepares root-only reviewer and isolated profile credenti
assert(claude.tokenFile !== null); assert(claude.tokenFile !== null);
expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token"); expect(await readFile(claude.tokenFile, "utf8")).toBe("secret-token");
await expectPrivate(claude.tokenFile, claude.uid, 0o600); 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); expect(codex.tokenFile).toBe(null);
const ownToken = spawnSync("/bin/cat", [claude.tokenFile], { const ownToken = spawnSync("/bin/cat", [claude.tokenFile], {
@@ -83,12 +84,12 @@ test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fail
process.execPath, process.execPath,
[fileURLToPath(new URL("../prepare/index.ts", import.meta.url))], [fileURLToPath(new URL("../prepare/index.ts", import.meta.url))],
{ {
env: { env: Object.fromEntries([
ACTION_PATH: action, ["ACTION_PATH", action],
RUNNER_TEMP: root, ["RUNNER_TEMP", root],
BOT_AGENTS_MASTER_KEY: MASTER_KEY, ["BOT_AGENTS_MASTER_KEY", MASTER_KEY],
GITHUB_OUTPUT: action, ["GITHUB_OUTPUT", action],
}, ]),
}, },
); );
+4 -2
View File
@@ -35,9 +35,11 @@ export interface CommandResult {
// This process's environment as a map from variable name to value. // This process's environment as a map from variable name to value.
export function inheritedEnvironment(): Map<string, string> { export function inheritedEnvironment(): Map<string, string> {
const result: Map<string, string> = new Map(); const result = new Map<string, string>();
for (const [name, value] of Object.entries(process.env)) { 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; return result;
} }
+3 -2
View File
@@ -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); const result = await runCommand("/bin/bash", ["scripts/protect-source.sh"], environment);
assert(result.success, result.stderr); 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 = [ const files = [
"src/prepare/index.ts", "src/prepare/index.ts",
"src/run/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 () => { test("module-relative runtime assets exist", async () => {
const module = new URL("../src/run/index.ts", import.meta.url); const module = new URL("../src/run/index.ts", import.meta.url);
const source = await readFile(module, "utf8"); const source = await readFile(module, "utf8");
const assets = [...source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/gu)].map( const assets = [...source.matchAll(/new URL\("(?<asset>[^"]+)", import\.meta\.url\)/gu)].map(
([, asset = ""]) => new URL(asset, module), ([, asset = ""]) => new URL(asset, module),
); );
const stats = await Promise.all(assets.map((asset) => stat(asset))); const stats = await Promise.all(assets.map((asset) => stat(asset)));
+17 -1
View File
@@ -12,13 +12,29 @@ export default defineConfig({
"eslint-plugin-import-structure", "eslint-plugin-import-structure",
"./scripts/project-lint.ts", "./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: { rules: {
...explicitTypes.configs.concrete.rules, ...explicitTypes.configs.concrete.rules,
...explicitTypes.configs.naming.rules,
...explicitTypes.configs.absence.rules, ...explicitTypes.configs.absence.rules,
...importStructure.configs.modules.rules, ...importStructure.configs.modules.rules,
...projectLint.configs.project.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: { options: {
typeAware: true, typeAware: true,
typeCheck: true, typeCheck: true,