From 6ad859be6271525cc11dfef730c4379fa94c1796 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Sun, 18 Oct 2026 03:05:15 +0900 Subject: [PATCH] Set the agreed tsconfig strictness flags and drop exactOptionalPropertyTypes --- .agents/memory/ts-only-files-and-imports.md | 15 +++++--- AGENTS.md | 9 +++-- src/auth.ts | 6 +-- src/post.ts | 28 +++++++------- src/profiles.ts | 4 +- src/run.ts | 25 ++++++------ src/runtime.ts | 10 ++--- tests/entrypoints.test.ts | 2 +- tests/workflow.test.ts | 42 ++++++++++----------- tsconfig.json | 6 +-- 10 files changed, 76 insertions(+), 71 deletions(-) diff --git a/.agents/memory/ts-only-files-and-imports.md b/.agents/memory/ts-only-files-and-imports.md index 8e1e707..e4711f8 100644 --- a/.agents/memory/ts-only-files-and-imports.md +++ b/.agents/memory/ts-only-files-and-imports.md @@ -6,11 +6,16 @@ The user's rule, from the Deno days and restated for Node (2026-10-12): "No JS, not even JSX/TSX, only TS." Source, scripts, tests, and configs are all plain `.ts`. Build output in a gitignored folder is the only JS. -`tsconfig.json`: no `allowJs`, no `jsx`, and the template's strict flags -(`verbatimModuleSyntax`, `erasableSyntaxOnly`, `allowImportingTsExtensions`, -`isolatedDeclarations` in packages, `exactOptionalPropertyTypes`, -`noUncheckedIndexedAccess`). If a tool or framework seems to need a JS or TSX -file, stop and flag it instead of adding one. +`tsconfig.json`: no `allowJs`, no `jsx`, and exactly this strictness set +(user decision, 2026-10-18): `strict` (written out), `noUncheckedIndexedAccess`, +`noPropertyAccessFromIndexSignature`, `noImplicitReturns`, `noUnusedLocals`, +`noUnusedParameters`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, +`erasableSyntaxOnly`, plus the mechanical `verbatimModuleSyntax`, +`allowImportingTsExtensions`, and `noEmit` (packages also keep `declaration` +and `isolatedDeclarations`). `exactOptionalPropertyTypes` is off everywhere: +write `{ key: maybeUndefined }` rather than leaving the key out to please it. +If a tool or framework seems to need a JS or TSX file, stop and flag it +instead of adding one. ## No asset imports (2026-10-14, widened 2026-10-16 and 2026-10-17) diff --git a/AGENTS.md b/AGENTS.md index 94fa3f4..4f223d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,12 @@ explicit permission. The action's TypeScript runs directly on the job image's Node.js (24.2 or newer), which strips the types itself: there is no build step and no runtime dependency. Keep the sources to erasable syntax and `.ts` imports, which -`tsconfig.json` enforces. Checks use Vite+: `vp check` formats, lints, and -type-checks, and `vp test` runs Vitest. Unit tests sit next to the code as -`src/*.test.ts`; repository-level tests live in `tests/`. +`tsconfig.json` enforces. Its strictness set is fixed and leaves +`exactOptionalPropertyTypes` off +([.agents/memory/ts-only-files-and-imports.md](.agents/memory/ts-only-files-and-imports.md)). +Checks use Vite+: `vp check` formats, lints, and type-checks, and `vp test` +runs Vitest. Unit tests sit next to the code as `src/*.test.ts`; +repository-level tests live in `tests/`. A `#` alias in `package.json` `imports` may point anywhere and owns its target: an alias to a folder's `index.ts` owns that folder and everything diff --git a/src/auth.ts b/src/auth.ts index 148f8a7..2cde002 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -75,11 +75,11 @@ async function readCodexAuth(path: string): Promise { } catch { throw new Error("invalid Codex login"); } - const tokens = isObject(value) ? value.tokens : undefined; + const tokens = isObject(value) ? value["tokens"] : undefined; if ( !isObject(value) || - typeof value.auth_mode !== "string" || - value.auth_mode === "" || + typeof value["auth_mode"] !== "string" || + value["auth_mode"] === "" || !isObject(tokens) || !["access_token", "refresh_token", "id_token", "account_id"].every( (name) => typeof tokens[name] === "string" && tokens[name] !== "", diff --git a/src/post.ts b/src/post.ts index fa0087c..956bdad 100644 --- a/src/post.ts +++ b/src/post.ts @@ -70,9 +70,9 @@ async function readOutcome(path: string): Promise { throw new Error("invalid agent outcome"); } const outcome = value; - if (outcome.ok === false) { + if (outcome["ok"] === false) { // Only known labels reach the comment, never raw agent output. - const failures: unknown[] = Array.isArray(outcome.failures) ? outcome.failures : []; + const failures: unknown[] = Array.isArray(outcome["failures"]) ? outcome["failures"] : []; return { ok: false, failures: failures.flatMap((entry: unknown) => { @@ -84,26 +84,26 @@ async function readOutcome(path: string): Promise { }; } if ( - outcome.ok !== true || - typeof outcome.body !== "string" || - typeof outcome.reviewPath !== "string" || - typeof outcome.anchorsPath !== "string" || - !isObject(outcome.profile) + outcome["ok"] !== true || + typeof outcome["body"] !== "string" || + typeof outcome["reviewPath"] !== "string" || + typeof outcome["anchorsPath"] !== "string" || + !isObject(outcome["profile"]) ) { throw new Error("invalid agent outcome"); } - const profile = outcome.profile; - if (!isProfileType(profile.type) || typeof profile.model !== "string") { + const profile = outcome["profile"]; + if (!isProfileType(profile["type"]) || typeof profile["model"] !== "string") { throw new Error("invalid agent outcome"); } return { ok: true, - body: outcome.body, - reviewPath: outcome.reviewPath, - anchorsPath: outcome.anchorsPath, + body: outcome["body"], + reviewPath: outcome["reviewPath"], + anchorsPath: outcome["anchorsPath"], profile: { - type: profile.type, - model: profile.model, + type: profile["type"], + model: profile["model"], }, }; } diff --git a/src/profiles.ts b/src/profiles.ts index c936c83..8f38d54 100644 --- a/src/profiles.ts +++ b/src/profiles.ts @@ -110,8 +110,8 @@ export function agentEnvironment( token?: string, ): Record { const result = sanitizedEnvironment(environment); - result.HOME = profile.home; - if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`; + result["HOME"] = profile.home; + if (profile.type === "codex") result["CODEX_HOME"] = `${profile.home}/.codex`; if (token !== undefined && token !== "") { const tokenName = { claude: "CLAUDE_CODE_OAUTH_TOKEN", diff --git a/src/run.ts b/src/run.ts index 1092ca0..ec1a0ae 100644 --- a/src/run.ts +++ b/src/run.ts @@ -85,7 +85,7 @@ async function gitea(path: string, body?: unknown): Promise { Authorization: `token ${AUTHOR_TOKEN}`, "Content-Type": "application/json", }, - body: body === undefined ? null : JSON.stringify(body), + body: body === undefined ? undefined : JSON.stringify(body), }); if (!response.ok) { throw new Error(`${path}: ${response.status} ${await response.text()}`); @@ -104,11 +104,11 @@ async function runRoot(command: string, args: string[]): Promise { async function configureGitAuthor(): Promise { const user: unknown = await (await gitea("user")).json(); - if (!isObject(user) || typeof user.login !== "string" || typeof user.email !== "string") { + if (!isObject(user) || typeof user["login"] !== "string" || typeof user["email"] !== "string") { throw new Error("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", user["login"]]); + await runRoot("git", ["config", "user.email", user["email"]]); } async function readProfileToken(profile: RuntimeProfile): Promise { @@ -151,7 +151,7 @@ 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 = isObject(user) ? user["login"] : undefined; return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`; } @@ -228,24 +228,23 @@ 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 = isObject(event["message"]) ? event["message"]["content"] : undefined; for (const part of Array.isArray(content) ? content : []) { if (isObject(part)) { - const text = part.thinking ?? part.text ?? part.name; + const text = part["thinking"] ?? part["text"] ?? part["name"]; if (typeof text === "string" && text !== "") { console.log(text); } } } - if (event.type === "result") { + if (event["type"] === "result") { const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event; result = { - ...(typeof isError === "boolean" && { is_error: isError }), - ...(typeof answer === "string" && { result: answer }), + is_error: typeof isError === "boolean" ? isError : undefined, + result: typeof answer === "string" ? answer : undefined, subtype: String(subtype), - ...((typeof apiStatus === "number" || apiStatus === null) && { - api_error_status: apiStatus, - }), + api_error_status: + typeof apiStatus === "number" || apiStatus === null ? apiStatus : undefined, }; } } diff --git a/src/runtime.ts b/src/runtime.ts index 926c0b3..342cca2 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -101,11 +101,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 value["runtimeDir"] === "string" && + typeof value["manifestPath"] === "string" && + typeof value["reviewerFile"] === "string" && + typeof value["outcomeFile"] === "string" && + Array.isArray(value["profiles"]) ); } diff --git a/tests/entrypoints.test.ts b/tests/entrypoints.test.ts index 82a1213..06ac374 100644 --- a/tests/entrypoints.test.ts +++ b/tests/entrypoints.test.ts @@ -16,7 +16,7 @@ const PHASES: [phase: string, firstVariable: string][] = [ test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => { const path = fileURLToPath(new URL(`../src/${phase}.ts`, import.meta.url)); - const result = await runCommand(process.execPath, [path], { PATH: process.env.PATH ?? "" }); + const result = await runCommand(process.execPath, [path], { 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 d4e800f..ae3cb6a 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -48,11 +48,11 @@ 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(Object.keys(mapping(action["inputs"], "inputs"))).toStrictEqual(["master-key"]); + const steps = sequence(mapping(action["runs"], "runs")["steps"], "runs.steps"); for (const step of steps) { - if (typeof step.run === "string") { - expect(step.run.includes("\n"), String(step.name)).toBe(false); + if (typeof step["run"] === "string") { + expect(step["run"].includes("\n"), String(step["name"])).toBe(false); } } const checkout = findStep(steps, "uses", "actions/checkout@v4"); @@ -60,16 +60,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["with"], "checkout.with")["token"]).toBe("${{ gitea.token }}"); + expect(mapping(prepare["env"], "prepare.env")["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(mapping(agent["env"], "agent.env")["GITEA_TOKEN"]).toBe("${{ gitea.token }}"); + assert(String(post["if"]).includes("always()")); for (const step of [prepare, agent, post]) { - assert(String(step.run).includes("steps.source.outputs.path")); + assert(String(step["run"]).includes("steps.source.outputs.path")); } }); @@ -118,17 +118,17 @@ 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"); + const job = mapping(mapping(workflow["jobs"], "jobs")["agent"], "jobs.agent"); + const permissions = mapping(job["permissions"], "jobs.agent.permissions"); expect(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["steps"], "jobs.agent.steps"); + expect(steps.findIndex((step) => step["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["with"], "trusted.with")["master-key"]).toBe( "${{ secrets.BOT_AGENTS_MASTER_KEY }}", ); expect(JSON.stringify(workflow).includes("gitea.actor != 'bot'")).toBe(true); @@ -136,9 +136,9 @@ test("the repository uses its trusted main action", async () => { 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["jobs"], "jobs")["check"], "jobs.check"); + const commands = sequence(job["steps"], "jobs.check.steps") + .map((step) => (typeof step["run"] === "string" ? step["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. @@ -170,8 +170,8 @@ 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")["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:")); }); diff --git a/tsconfig.json b/tsconfig.json index e2f18b1..e858ef7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,14 +9,12 @@ "erasableSyntaxOnly": true, "verbatimModuleSyntax": true, "strict": true, - "exactOptionalPropertyTypes": true, "noUncheckedIndexedAccess": true, + "noPropertyAccessFromIndexSignature": true, "noImplicitOverride": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "noUnusedLocals": true, - "noUnusedParameters": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false + "noUnusedParameters": true } }