23 Commits

Author SHA1 Message Date
Danny Kim a2de4a5d6d Update the root-only test count in the note description
Check / check (pull_request) Successful in 38s
2026-10-17 23:16:23 +09:00
Danny Kim 0f4c2c274a Stop installing Deno in the action 2026-10-17 23:16:23 +09:00
Danny Kim 8d654d69a4 Spawn child processes directly and drop the Deno.Command shim 2026-10-17 23:16:23 +09:00
Danny Kim 8b5c249248 Note the planned modules plugin and fix a sentence case in the alias notes
Check / check (pull_request) Successful in 38s
2026-10-17 23:14:27 +09:00
Danny Kim 5af53b0882 Wrap the long doc comment lines in the project lint plugin 2026-10-17 23:14:27 +09:00
Danny Kim eba9b9c57c Keep main's one-line guards where no rule needs braces
Check / check (pull_request) Successful in 37s
2026-10-17 18:52:49 +09:00
Danny Kim 3cbe4171ea Describe the Deno size limits in the notes 2026-10-17 18:50:24 +09:00
Danny Kim cd1864e1a9 Restore helper layout in auth, isolation, runtime, and profiles from main 2026-10-17 18:49:28 +09:00
Danny Kim 0c5059e3b4 Restore post.ts from main 2026-10-17 18:48:15 +09:00
Danny Kim c6fc183ad6 Restore run.ts from main 2026-10-17 18:46:52 +09:00
Danny Kim 1f54518320 Match Deno size limits 2026-10-17 18:46:52 +09:00
Danny Kim 999a089374 Prune notes that don't apply to bot-agents and keep aliases on direct src subfolders
Check / check (pull_request) Successful in 38s
2026-10-17 18:12:47 +09:00
Danny Kim 166476dee9 State alias rule v2 in AGENTS.md
Check / check (pull_request) Successful in 37s
2026-10-17 16:02:20 +09:00
Danny Kim c778c00068 Allow .mts and .mjs imports in no-asset-imports
Check / check (pull_request) Successful in 42s
2026-10-17 15:59:45 +09:00
Danny Kim 6b87ff66b9 Fix the quoted old rule name in the alias notes
Check / check (pull_request) Successful in 2m30s
2026-10-17 15:46:05 +09:00
Danny Kim d2d3cbffdb Switch to the renamed @cunarist/eslint-plugin-modules
Check / check (pull_request) Successful in 2m10s
2026-10-17 15:43:47 +09:00
Danny Kim bfa8269801 Route tests through a #testing alias, ban ../ imports, and document the alias ownership rule
Check / check (pull_request) Successful in 46s
2026-10-17 14:45:52 +09:00
Danny Kim 15fe481181 Prune migration notes and align the notes with the current lint setup
Check / check (pull_request) Successful in 39s
2026-10-16 23:41:04 +09:00
Danny Kim 662e946fad Drop the scripts lint override and accept Oxfmt's standard package.json order 2026-10-16 23:41:04 +09:00
Danny Kim ffb7cb9a75 Rename no-non-ts-imports to no-asset-imports, allow .js subpaths, and compare node types by enum member 2026-10-16 23:41:04 +09:00
Danny Kim 2e09945a6a Document that scripts is flat with no subfolders or index files
Check / check (pull_request) Successful in 39s
2026-10-16 23:19:03 +09:00
Danny Kim 934a3cfd03 Remove Deno lint references from the project lint plugin comments
Check / check (pull_request) Successful in 38s
2026-10-16 23:15:37 +09:00
Danny Kim b170f25c7d Document that package.json aliases may target only direct subfolders of src
Agents / agent (pull_request) Has been skipped
Check / check (pull_request) Successful in 1m18s
2026-10-16 22:08:23 +09:00
47 changed files with 287 additions and 480 deletions
+4 -4
View File
@@ -1,5 +1,5 @@
--- ---
description: Child processes use plain node:child_process spawn (explicit stdio, uid/gid, whole env); src/processes/child.ts has exitCode (128+signo) and echo; no execFile maxBuffer description: Child processes use plain node:child_process spawn (explicit stdio, uid/gid, whole env); src/child.ts has exitCode (128+signo) and echo; no execFile maxBuffer
--- ---
Every call site calls `spawn` from `node:child_process` itself, with Every call site calls `spawn` from `node:child_process` itself, with
@@ -15,7 +15,7 @@ everything explicit:
- No `execFile`/`exec`: their `maxBuffer` (1 MiB) kills a chatty child. Read - No `execFile`/`exec`: their `maxBuffer` (1 MiB) kills a chatty child. Read
piped streams incrementally instead. piped streams incrementally instead.
`src/processes/child.ts` holds what the call sites share: `src/child.ts` holds what the call sites share:
- **`exitCode(child)`**: call it right after `spawn`. It settles on `exit` - **`exitCode(child)`**: call it right after `spawn`. It settles on `exit`
(not `close`, which a daemonized grandchild can hold open), maps a signal to (not `close`, which a daemonized grandchild can hold open), maps a signal to
@@ -28,9 +28,9 @@ Tests use `spawnSync` for short commands.
Other port details: Other port details:
- **`src/common/errors.ts`** `isNotFound` checks `ENOENT` (file) or `ESRCH` (process). - **`src/errors.ts`** `isNotFound` checks `ENOENT` (file) or `ESRCH` (process).
- Temporary files are an exclusive `wx` create with mode `0600`. - Temporary files are an exclusive `wx` create with mode `0600`.
- `src/common/json.ts` holds type guards used instead of `as` casts on parsed JSON. - `src/json.ts` holds type guards used instead of `as` casts on parsed JSON.
- `process` and `Buffer` must come from `node:` imports: - `process` and `Buffer` must come from `node:` imports:
`no-restricted-globals` in `scripts/project-lint.ts` enforces it `no-restricted-globals` in `scripts/project-lint.ts` enforces it
([[lint-parity-never-shrinks]]). ([[lint-parity-never-shrinks]]).
+1 -1
View File
@@ -15,7 +15,7 @@ when the migration PR was approved:
([[lint-overrides-policy]]). ([[lint-overrides-policy]]).
Size and complexity limits match Deno, which had none here, so every Oxlint 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 limit rule is `"off"` ([[lint-config-shape]]). `run.ts` and `post.ts` keep
main's single-file layout (2026-10-17 revert of the size splits). Sequential 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 awaits in loops became recursion or a `reduce` chain (`no-await-in-loop`) to
stay sequential. Add no other opt-out without the user. stay sequential. Add no other opt-out without the user.
@@ -1,5 +1,5 @@
--- ---
description: bot-agents migration decisions: live @main action for six repos, JSON profiles, Node 24.2+, lints with eslint-plugin-import-structure and explicit-types (concrete, absence) description: bot-agents migration decisions: live @main action for six repos, JSON profiles, Node 24.2+, no @cunarist plugins needed
--- ---
Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to
@@ -15,19 +15,13 @@ Node + Vite+ migration (PR #20, `node-migration`, 2026-10-15/16):
removed the only runtime dependency (`@std/yaml`); keep it free of runtime removed the only runtime dependency (`@std/yaml`); keep it free of runtime
dependencies (`@eslint/core` and the other devDependencies serve only the dependencies (`@eslint/core` and the other devDependencies serve only the
tooling). tooling).
- **Node 24.2+**, not 22: `src/post/index.ts` uses `import.meta.main`. There is no - **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. `engines.node`
`engines` (user decision 2026-10-18): the `node:24-bookworm` job image is `>=24.2`, which the user accepted for this repo
decides the version ([[bot-agents-runtime-no-build]]). ([[bot-agents-runtime-no-build]]).
- **Published lint plugins** (2026-10-18, after they reached npm): - **No `@cunarist/eslint-plugin-*`.** The Deno setup enabled no plugin, so
`eslint-plugin-import-structure` (`modules` config) and this repo depends on none and CI passes without them being published.
`eslint-plugin-explicit-types` (`concrete` and `absence` configs) are Planned (2026-10-17): once the plugins are on npm, add
devDependencies, enabled in `vite.config.ts`. explicit-types' `naming` `@cunarist/eslint-plugin-modules` here for its import checks.
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).
- **Stricter input checks were accepted** in the migration: Gitea `/user` and - **Stricter input checks were accepted** in the migration: Gitea `/user` and
comment responses, the runtime manifest, and the outcome's profile type are comment responses, the runtime manifest, and the outcome's profile type are
validated; some type errors became `TypeError` with the same messages. validated; some type errors became `TypeError` with the same messages.
@@ -10,9 +10,8 @@ dependency**. Hence `tsconfig.json` has `erasableSyntaxOnly` and
properties, no decorators) and relative `.ts` imports. properties, no decorators) and relative `.ts` imports.
- **Image:** the steps assume the `node:24-bookworm` job container. Node must - **Image:** the steps assume the `node:24-bookworm` job container. Node must
be 24.2+ for `import.meta.main` in `src/post/index.ts`. That image is the only Node be 24.2+ for `import.meta.main` in `post.ts`; `check.yml` prints
pin (`package.json` has no `engines`); `check.yml` prints `node --version` `node --version` to confirm the cached image's minor.
to confirm the cached image's minor.
- **Root-owned source copy:** `scripts/protect-source.sh` copies the action - **Root-owned source copy:** `scripts/protect-source.sh` copies the action
into `$RUNNER_TEMP` so an agent editing the checkout can't swap a later into `$RUNNER_TEMP` so an agent editing the checkout can't swap a later
phase. It copies exactly `src/*.ts`, `package.json` (`"type": "module"` phase. It copies exactly `src/*.ts`, `package.json` (`"type": "module"`
+4 -4
View File
@@ -2,24 +2,24 @@
description: Isolation and credential tests need root and use test.skipIf(!IS_ROOT); locally 5 skip, CI runs as root so all 66 run; verify isolation changes in node:24-bookworm as root description: Isolation and credential tests need root and use test.skipIf(!IS_ROOT); locally 5 skip, CI runs as root so all 66 run; verify isolation changes in node:24-bookworm as root
--- ---
Some tests exercise real UID isolation, process cleanup, and the prepare phase's Some tests exercise real UID isolation, process cleanup, and `prepare.ts`
credential cleanup. They need root and are written as credential cleanup. They need root and are written as
`test.skipIf(!IS_ROOT)(...)` (`IS_ROOT` from `#testing`), so they show `test.skipIf(!IS_ROOT)(...)` (`IS_ROOT` from `#testing`), so they show
as skipped instead of silently passing. as skipped instead of silently passing.
- As a normal user `vp test` passes 61 and skips 5. In CI (self-hosted - As a normal user `vp test` passes 61 and skips 5. In CI (self-hosted
runner, root) all 66 run. runner, root) all 66 run.
- When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare/index.ts`, - When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare.ts`,
or `child.ts`, run the suite as root in `node:24-bookworm` (e.g. a or `child.ts`, run the suite as root in `node:24-bookworm` (e.g. a
container with the repo mounted) before pushing; a green non-root run container with the repo mounted) before pushing; a green non-root run
proves nothing about those paths. proves nothing about those paths.
- The migration also ran an end-to-end smoke as root with fake - The migration also ran an end-to-end smoke as root with fake
`codex`/`claude`/`npx` binaries and a fake Gitea through `codex`/`claude`/`npx` binaries and a fake Gitea through
`protect-source.sh` -> `prepare/index.ts` -> `run/index.ts` -> `post/index.ts`; repeat that `protect-source.sh` -> `prepare.ts` -> `run.ts` -> `post.ts`; repeat that
for changes to the phase flow ([[bot-agents-runtime-no-build]]). for changes to the phase flow ([[bot-agents-runtime-no-build]]).
- `src/testing/index.ts` holds the shared test helpers (user IDs, temp dirs, - `src/testing/index.ts` holds the shared test helpers (user IDs, temp dirs,
`runCommand`) behind the `#testing` alias. Every test imports it as `runCommand`) behind the `#testing` alias. Every test imports it as
`#testing`. It imports no source module, so `tests/` (which may not use `#testing`. It imports no source module, so `tests/` (which may not use
`../`) never reaches internals. Tests follow `../`) never reaches internals. Tests follow
[[tests-layout-vitest]] (unit tests next to their code in `src/`, repo-level ones in [[tests-layout-vitest]] (unit tests in `src/*.test.ts`, repo-level ones in
`tests/`). `tests/`).
+10 -13
View File
@@ -11,25 +11,23 @@ unbuilt. `vp pack` inlines them, so they never reach package consumers.
One rule for every file, never tied to folder or file names (user rule One rule for every file, never tied to folder or file names (user rule
2026-10-17; it replaces the earlier "aliases only point at direct `src/` 2026-10-17; it replaces the earlier "aliases only point at direct `src/`
subfolders" rule as the general rule, and the "import-structure plugin subfolders" rule as the general rule, and the "modules plugin checks only
checks only aliased folders" scope; this repo keeps the stricter rule as a aliased folders" scope; this repo keeps the stricter rule as a local
local convention, see the last section): convention, see the last section):
- **An alias may point anywhere** and owns its target. An alias to a - **An alias may point anywhere** and owns its target. An alias to a
folder's `index.ts` owns that folder and everything below it; an alias to folder's `index.ts` owns that folder and everything below it; an alias to
any other file owns only that file. any other file owns only that file.
- **No overlap.** No file may be owned by 2 aliases: no alias inside another - **No overlap.** No file may be owned by 2 aliases: no alias inside another
alias's area, and no 2 aliases with the same target. The import-structure alias's area, and no 2 aliases with the same target. The modules plugin's
plugin's `enforce-layer-order` reports it once at the top of every file 2 `enforce-layer-order` reports it once at the top of every file 2 aliases
aliases claim. Fix by merging the aliases or moving files apart. claim. Fix by merging the aliases or moving files apart.
- **Outside an area, only the exact alias reaches it.** A deeper alias - **Outside an area, only the exact alias reaches it.** A deeper alias
specifier (`#a/one.ts`, `no-barrel-bypass`) and a relative path into the specifier (`#a/one.ts`, `no-barrel-bypass`) and a relative path into the
area (`./a/one.ts`, `no-relative-bypass`; `./b/f.ts` to a file alias, area (`./a/one.ts`, `no-relative-bypass`; `./b/f.ts` to a file alias,
`prefer-alias-import`, fixed to the alias) are bypasses. `prefer-alias-import`, fixed to the alias) are bypasses.
- **Inside an area, relative imports of siblings and subfolders are free.** - **Inside an area, relative imports of siblings and subfolders are free.**
- **A `../` import is banned in every file**, owned or not (`no-parent-import`). - **`../` is banned in every file**, owned or not (`no-parent-import`).
Runtime file URLs (`new URL("../prompt.md", import.meta.url)`) are not
imports and are allowed.
Anything above a file needs an alias. Anything above a file needs an alias.
- **Files no alias owns** need no `index.ts` and no alias, and import `./` - **Files no alias owns** need no `index.ts` and no alias, and import `./`
siblings and subfolders freely. siblings and subfolders freely.
@@ -91,14 +89,13 @@ Relative imports are written with the `.ts` extension.
## Local convention: aliases target direct `src/` subfolders ## Local convention: aliases target direct `src/` subfolders
This is a convention of this repo only, stricter than the general rule above This is a convention of this repo only, stricter than the general rule above
(which the published import-structure plugin checks and which allows more). (which the published modules plugin checks and which allows more). It is not
It is not a plugin rule. a plugin rule.
- Every `package.json` `imports` alias points only at the `index.ts` of a - Every `package.json` `imports` alias points only at the `index.ts` of a
direct subfolder of `src/`, e.g. `"#helpers": "./src/helpers/index.ts"`. direct subfolder of `src/`, e.g. `"#helpers": "./src/helpers/index.ts"`.
No alias to a single file, a deeper folder, or anything outside `src/`. No alias to a single file, a deeper folder, or anything outside `src/`.
Here the aliases are `#prepare`, `#run`, `#post`, `#agents`, `#processes`, `#runtime`, `#profiles`, Here the one alias is `"#testing": "./src/testing/index.ts"`.
`#common`, and `#testing`, in that layer order.
- Code outside `src/` (`scripts/`, `tests/`, `vite.config.ts`) gets no alias - Code outside `src/` (`scripts/`, `tests/`, `vite.config.ts`) gets no alias
of its own and imports its own files by `./` relative paths. It reaches of its own and imports its own files by `./` relative paths. It reaches
`src/` only through the aliases, since `../` is banned. `src/` only through the aliases, since `../` is banned.
+3 -3
View File
@@ -8,7 +8,7 @@ turn on whole categories, then list only what parity or a real need adds.
```ts ```ts
lint: { lint: {
plugins: ["typescript", "unicorn", "oxc", "import"], plugins: ["typescript", "unicorn", "oxc", "import"],
jsPlugins: ["eslint-plugin-explicit-types", "./scripts/project-lint.ts"], jsPlugins: ["@cunarist/eslint-plugin-explicit-types", "./scripts/project-lint.ts"],
categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" }, categories: { correctness: "error", suspicious: "error", pedantic: "error", perf: "error" },
rules: { ...explicitTypes.configs.concrete.rules, ...projectLint.configs.project.rules }, rules: { ...explicitTypes.configs.concrete.rules, ...projectLint.configs.project.rules },
ignorePatterns: ["src/generated/**"], // generated files only, see below ignorePatterns: ["src/generated/**"], // generated files only, see below
@@ -40,8 +40,8 @@ lint: {
Hand-written code is never ignored. Hand-written code is never ignored.
- **No lint disable comments**: [[lint-no-disable-comments]]. - **No lint disable comments**: [[lint-no-disable-comments]].
- **Parity:** [[lint-parity-never-shrinks]]. **Naming:** [[naming-id-match]]. - **Parity:** [[lint-parity-never-shrinks]]. **Naming:** [[naming-id-match]].
- Don't add a second import sorter; the import-structure plugin's - Don't add a second import sorter; the modules plugin's `enforce-import-order` is
`enforce-import-order` is the only one, and Oxfmt's `sortImports` stays off. the only one, and Oxfmt's `sortImports` stays off.
`perf` came last: in the template it flagged nine `.includes` on constant `perf` came last: in the template it flagged nine `.includes` on constant
arrays, fixed as `ReadonlySet` + `.has()` rather than by turning a rule off. arrays, fixed as `ReadonlySet` + `.has()` rather than by turning a rule off.
+3 -5
View File
@@ -9,21 +9,19 @@ Oxfmt's standard `package.json` sorting is accepted: there is no
replaces the earlier hand-written order (metadata, `scripts`, `exports`, replaces the earlier hand-written order (metadata, `scripts`, `exports`,
`imports`, `devDependencies`, `dependencies`). `imports`, `devDependencies`, `dependencies`).
The `imports` key order can carry meaning: the import-structure plugin's The `imports` key order can carry meaning: the modules plugin's
`enforce-layer-order` reads it as the layer order. Never reorder it casually. `enforce-layer-order` reads it as the layer order. Never reorder it casually.
## Fields ## Fields
- **Standard fields only.** App repos have no `engines` field (user decision - **Standard fields only.** `engines` has only `node` (never `engines.bun`).
2026-10-18; images pin Node, [[toolchain-node-vite-plus]]). Published
packages keep `engines` with only `node` (never `engines.bun`).
A tool-owned key (`eslint-doc-generator`) is allowed only because the tool A tool-owned key (`eslint-doc-generator`) is allowed only because the tool
reads nothing else and no other config file is allowed. reads nothing else and no other config file is allowed.
- **No `packageManager`.** npm is detected from `package-lock.json`. - **No `packageManager`.** npm is detected from `package-lock.json`.
- **No `peerDependencies`, ever** (user preference, 2026-10-14). If something - **No `peerDependencies`, ever** (user preference, 2026-10-14). If something
is needed at runtime, it goes in `dependencies`. is needed at runtime, it goes in `dependencies`.
- **`dependencies`** = what runs or is type-referenced at runtime by consumers - **`dependencies`** = what runs or is type-referenced at runtime by consumers
(the lit-robust plugin ships `parse5`, `oxc-parser`, `oxc-resolver`, and (the lit plugin ships `parse5`, `oxc-parser`, `oxc-resolver`, and
`@eslint/core` for its public types; they stay external, never bundled). `@eslint/core` for its public types; they stay external, never bundled).
- **`devDependencies`** = everything else: `vite-plus`, `eslint`, - **`devDependencies`** = everything else: `vite-plus`, `eslint`,
`@typescript-eslint/parser`, `eslint-doc-generator`, `esbuild`, `@typescript-eslint/parser`, `eslint-doc-generator`, `esbuild`,
+4 -12
View File
@@ -1,20 +1,12 @@
--- ---
description: Node.js (no engines in apps; images pin 24) with Vite+ (vp) and npm; only package.json, tsconfig.json, vite.config.ts plus a committed package-lock.json description: Node.js 22+ with Vite+ (vp) and npm; only package.json, tsconfig.json, vite.config.ts plus a committed package-lock.json
--- ---
## The stack ## The stack
- **Runtime:** Node.js. App repos have no `engines` field (user decision - **Runtime:** Node.js 22 or newer. `engines` is `{ "node": ">=22" }` and
2026-10-18): the container or runner image decides the version, so the nothing else. Containers and the publish job use Node 24 (LTS); a repo may
Containerfile base image (`node:24-alpine`) and CI's `node-version: "24"` need more where a feature demands it (bot-agents uses Node 24.2+).
are the pins. Published packages (the lint plugins) keep their own
`engines.node` floor. `npm run lit-check` runs
`node scripts/lit-check.ts`, which needs Node 22.18+ (unflagged type
stripping and `module.registerHooks`); Node 24 satisfies it. Without
`engines` or a `.node-version`, local `vp` uses its default Node
(`vp env default`). bot-agents has no Containerfile or setup step: the
self-hosted runner's `node:24-bookworm` job image runs the action and its
checks, and `src/post/index.ts` needs 24.2+ (`import.meta.main`).
- **Bun is not supported** (dropped 2026-10-15). No Bun CI legs, docs, or - **Bun is not supported** (dropped 2026-10-15). No Bun CI legs, docs, or
runtime branches. Oxlint's `RuleTester` refuses to run under Bun anyway, and runtime branches. Oxlint's `RuleTester` refuses to run under Bun anyway, and
`vp` brings its own Node, the way uv brings its own Python. `vp` brings its own Node, the way uv brings its own Python.
+5 -11
View File
@@ -6,17 +6,11 @@ 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 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. plain `.ts`. Build output in a gitignored folder is the only JS.
`tsconfig.json`: no `allowJs`, no `jsx`, and exactly this strictness set (user `tsconfig.json`: no `allowJs`, no `jsx`, and the template's strict flags
decision, 2026-10-18): `strict` (written out), `noUncheckedIndexedAccess`, (`verbatimModuleSyntax`, `erasableSyntaxOnly`, `allowImportingTsExtensions`,
`noPropertyAccessFromIndexSignature`, `noImplicitReturns`, `noUnusedLocals`, `isolatedDeclarations` in packages, `exactOptionalPropertyTypes`,
`noUnusedParameters`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `noUncheckedIndexedAccess`). If a tool or framework seems to need a JS or TSX
`erasableSyntaxOnly`, and `allowUnreachableCode: false` plus file, stop and flag it instead of adding one.
`allowUnusedLabels: false` (user decision, 2026-10-18; on in the Deno setup),
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) ## No asset imports (2026-10-14, widened 2026-10-16 and 2026-10-17)
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# The action runs on this same image; src/post/index.ts needs Node 24.2+. # The action runs on this same image; src/post.ts needs Node 24.2+.
- run: node --version - run: node --version
- run: npm ci - run: npm ci
+14 -26
View File
@@ -13,15 +13,11 @@ explicit permission.
# Runtime and checks # Runtime and checks
The action's TypeScript runs directly on the job image's Node.js (24.2 or The action's TypeScript runs directly on the job image's Node.js (24.2 or
newer; `package.json` has no `engines`, the `node:24-bookworm` image pins it), newer), which strips the types itself: there is no build step and no runtime
which strips the types itself: there is no build step and no runtime
dependency. Keep the sources to erasable syntax and `.ts` imports, which dependency. Keep the sources to erasable syntax and `.ts` imports, which
`tsconfig.json` enforces. Its strictness set is fixed and leaves `tsconfig.json` enforces. Checks use Vite+: `vp check` formats, lints, and
`exactOptionalPropertyTypes` off type-checks, and `vp test` runs Vitest. Unit tests sit next to the code as
([.agents/memory/ts-only-files-and-imports.md](.agents/memory/ts-only-files-and-imports.md)). `src/*.test.ts`; repository-level tests live in `tests/`.
Checks use Vite+: `vp check` formats, lints, and type-checks, and `vp test`
runs Vitest. Unit tests sit next to the code as `*.test.ts` in `src/`;
repository-level tests live in `tests/`.
A `#` alias in `package.json` `imports` may point anywhere and owns its 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 target: an alias to a folder's `index.ts` owns that folder and everything
@@ -29,24 +25,16 @@ below it; an alias to any other file owns only that file. No overlap: no file
may be owned by 2 aliases, so no alias inside another alias's area and no 2 may be owned by 2 aliases, so no alias inside another alias's area and no 2
aliases with the same target. Outside an area, only the exact alias reaches aliases with the same target. Outside an area, only the exact alias reaches
it; a deeper alias specifier or a relative path into the area is a bypass. it; a deeper alias specifier or a relative path into the area is a bypass.
Inside an area, relative imports of siblings and subfolders are free. A `../` Inside an area, relative imports of siblings and subfolders are free. `../`
import is banned in every file, owned or not is banned in every file, owned or not
(`import/no-relative-parent-imports`); runtime file URLs such as (`import/no-relative-parent-imports`). Files no alias owns need no `index.ts`
`new URL("../prompt.md", import.meta.url)` are paths, not imports, and stay. and no alias, and import `./` siblings and subfolders freely. The one alias
Files no alias owns need no `index.ts` and no alias, and import `./` siblings is `#testing` (`./src/testing/index.ts`): every test imports it by alias, and
and subfolders freely. Every top-level entry of `src/` is an aliased module `tests/` reaches `src/` only through it. Local convention, stricter than the
folder. The aliases, in layer order, are the three phases `#prepare`, `#run`, general rule above: in this repo, aliases point only at a direct subfolder of
and `#post`, then `#agents` (Codex, `src/` (`"#helpers": "./src/helpers/index.ts"`), and code outside `src/` uses
Claude Code and OpenCode specifics), `#processes` (child processes and the `./` relative imports. `scripts/` is flat: one file per
isolated profile users), `#runtime` (the prepared runtime and its tool, no subfolders and no `index.ts`, imported directly by file path
credentials), `#profiles`, `#common` (error and JSON checks), and `#testing`
(test helpers), each with a `src/<name>/index.ts` barrel. A phase's
`index.ts` is also its entry point, which `action.yml` runs as
`node src/<phase>/index.ts`. Tests that use the test
helpers import `#testing`, and `tests/` imports `src/` only through it. Local convention, stricter than the general rule above: in
this repo, aliases point only at a direct subfolder of `src/`
(`"#helpers": "./src/helpers/index.ts"`), and code outside `src/` uses `./`
relative imports. `scripts/` is flat: one file per tool, no subfolders and no `index.ts`, imported directly by file path
([.agents/memory/imports-aliases-barrels.md](.agents/memory/imports-aliases-barrels.md)). ([.agents/memory/imports-aliases-barrels.md](.agents/memory/imports-aliases-barrels.md)).
The only config files are `package.json`, `tsconfig.json`, and The only config files are `package.json`, `tsconfig.json`, and
+3 -3
View File
@@ -35,7 +35,7 @@ runs:
- name: Prepare credentials - name: Prepare credentials
id: prepare id: prepare
shell: bash shell: bash
run: node "${{ steps.source.outputs.path }}/src/prepare/index.ts" run: node "${{ steps.source.outputs.path }}/src/prepare.ts"
env: env:
ACTION_PATH: ${{ steps.source.outputs.path }} ACTION_PATH: ${{ steps.source.outputs.path }}
BOT_AGENTS_MASTER_KEY: ${{ inputs.master-key }} BOT_AGENTS_MASTER_KEY: ${{ inputs.master-key }}
@@ -43,7 +43,7 @@ runs:
- name: Run bot - name: Run bot
id: agent id: agent
shell: bash shell: bash
run: node "${{ steps.source.outputs.path }}/src/run/index.ts" run: node "${{ steps.source.outputs.path }}/src/run.ts"
env: env:
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }} BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
GITEA_TOKEN: ${{ gitea.token }} GITEA_TOKEN: ${{ gitea.token }}
@@ -56,7 +56,7 @@ runs:
id: post id: post
if: always() && steps.prepare.outputs.manifest != '' if: always() && steps.prepare.outputs.manifest != ''
shell: bash shell: bash
run: node "${{ steps.source.outputs.path }}/src/post/index.ts" run: node "${{ steps.source.outputs.path }}/src/post.ts"
env: env:
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }} BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
GITEA_API_URL: ${{ gitea.api_url }} GITEA_API_URL: ${{ gitea.api_url }}
+3 -28
View File
@@ -9,10 +9,11 @@
"@eslint/core": "^0.17.0", "@eslint/core": "^0.17.0",
"@types/node": "^24.19.2", "@types/node": "^24.19.2",
"@typescript-eslint/types": "^8.71.2", "@typescript-eslint/types": "^8.71.2",
"eslint-plugin-explicit-types": "^0.6.1",
"eslint-plugin-import-structure": "^0.10.2",
"vite-plus": "^1.2.0", "vite-plus": "^1.2.0",
"yaml": "^2.9.1" "yaml": "^2.9.1"
},
"engines": {
"node": ">=24.2"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -1889,32 +1890,6 @@
"dev": true, "dev": true,
"license": "MIT" "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==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint/core": "^0.17.0"
},
"engines": {
"node": ">=22.18"
}
},
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint/core": "^0.17.0"
},
"engines": {
"node": ">=22.18"
}
},
"node_modules/estree-walker": { "node_modules/estree-walker": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+3 -10
View File
@@ -4,23 +4,16 @@
"description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.", "description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.",
"type": "module", "type": "module",
"imports": { "imports": {
"#prepare": "./src/prepare/index.ts",
"#run": "./src/run/index.ts",
"#post": "./src/post/index.ts",
"#agents": "./src/agents/index.ts",
"#processes": "./src/processes/index.ts",
"#runtime": "./src/runtime/index.ts",
"#profiles": "./src/profiles/index.ts",
"#common": "./src/common/index.ts",
"#testing": "./src/testing/index.ts" "#testing": "./src/testing/index.ts"
}, },
"devDependencies": { "devDependencies": {
"@eslint/core": "^0.17.0", "@eslint/core": "^0.17.0",
"@types/node": "^24.19.2", "@types/node": "^24.19.2",
"@typescript-eslint/types": "^8.71.2", "@typescript-eslint/types": "^8.71.2",
"eslint-plugin-explicit-types": "^0.6.1",
"eslint-plugin-import-structure": "^0.10.2",
"vite-plus": "^1.2.0", "vite-plus": "^1.2.0",
"yaml": "^2.9.1" "yaml": "^2.9.1"
},
"engines": {
"node": ">=24.2"
} }
} }
+3 -3
View File
@@ -2,9 +2,9 @@
set -euo pipefail set -euo pipefail
path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX") path=$(mktemp -d "${RUNNER_TEMP}/bot-agents-source-XXXXXX")
cp -a "${ACTION_PATH}/src" "${path}/" mkdir "${path}/src"
# package.json makes Node load the TypeScript sources as ES modules and cp -a "${ACTION_PATH}"/src/*.ts "${path}/src/"
# resolves their `#` aliases. # package.json makes Node load the TypeScript sources as ES modules.
cp -a "${ACTION_PATH}/package.json" "${ACTION_PATH}/profiles.json" "${ACTION_PATH}/prompt.md" "${path}/" cp -a "${ACTION_PATH}/package.json" "${ACTION_PATH}/profiles.json" "${ACTION_PATH}/prompt.md" "${path}/"
chmod -R go-w "${path}" chmod -R go-w "${path}"
printf 'path=%s\n' "${path}" >> "${GITHUB_OUTPUT}" printf 'path=%s\n' "${path}" >> "${GITHUB_OUTPUT}"
-17
View File
@@ -1,17 +0,0 @@
// Agent command-line specifics: Codex and Claude Code sign-in handling and
// OpenCode's arguments and output.
export {
codexAuthentication,
isClaudeAuthFailure,
isCodexAuthFailure,
persistCodexAuth,
retryInvalidToken,
stageCodexAuth,
} from "./auth.ts";
export type { ClaudeFailureFields, CodexAuthentication, CommandResult } from "./auth.ts";
export {
collectOpenCodeOutput,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
} from "./opencode.ts";
+4 -5
View File
@@ -1,7 +1,5 @@
import { mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
import { import {
codexAuthentication, codexAuthentication,
@@ -11,6 +9,7 @@ import {
retryInvalidToken, retryInvalidToken,
stageCodexAuth, stageCodexAuth,
} from "./auth.ts"; } from "./auth.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
test("retries once after an invalid refresh token", async () => { test("retries once after an invalid refresh token", async () => {
let attempts = 0; let attempts = 0;
@@ -105,7 +104,7 @@ test("re-logs in after the job 9837 revoked-session stderr", async () => {
test("recognizes a rejected Claude OAuth token", () => { test("recognizes a rejected Claude OAuth token", () => {
expect( expect(
isClaudeAuthFailure({ isClaudeAuthFailure({
apiErrorStatus: 401, api_error_status: 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); ).toBe(true);
@@ -114,7 +113,7 @@ test("recognizes a rejected Claude OAuth token", () => {
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); ).toBe(true);
expect(isClaudeAuthFailure({ apiErrorStatus: 429, result: "API Error: 429" })).toBe(false); expect(isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" })).toBe(false);
}); });
test("selects API-key login only for a configured Codex token", () => { test("selects API-key login only for a configured Codex token", () => {
+11 -25
View File
@@ -12,18 +12,10 @@ import {
} from "node:fs/promises"; } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { isNotFound, isObject } from "#common"; import { isNotFound } from "./errors.ts";
import { isObject } from "./json.ts";
interface CodexApiKey { export type CodexAuthentication = { kind: "api-key"; token: string } | { kind: "persistent" };
kind: "api-key";
token: string;
}
interface CodexPersistent {
kind: "persistent";
}
export type CodexAuthentication = CodexApiKey | CodexPersistent;
export function codexAuthentication(token?: string): CodexAuthentication { export function codexAuthentication(token?: string): CodexAuthentication {
return token === undefined ? { kind: "persistent" } : { kind: "api-key", token }; return token === undefined ? { kind: "persistent" } : { kind: "api-key", token };
@@ -42,27 +34,21 @@ export function isCodexAuthFailure(error: string): boolean {
// stream-json result, e.g. "Failed to authenticate. API Error: 401 OAuth // stream-json result, e.g. "Failed to authenticate. API Error: 401 OAuth
// access token is invalid." with api_error_status 401. // access token is invalid." with api_error_status 401.
// The fields of Claude Code's stream-json `result` event that tell a sign-in // The fields of Claude Code's stream-json `result` event that tell a sign-in
// failure apart, as `apiErrorStatus` and `result`. // failure apart, under their own names.
export interface ClaudeFailureFields { export type ClaudeFailureFields = Partial<Record<"api_error_status", number | null>> & {
apiErrorStatus?: number;
result?: string; result?: string;
} };
export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean { export function isClaudeAuthFailure(result: ClaudeFailureFields): boolean {
return ( return (
result.apiErrorStatus === 401 || result.api_error_status === 401 ||
/failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/iu.test( /failed to authenticate|oauth (?:access )?token (?:is invalid|has expired)|invalid api key|authentication_error|please run \/login|not logged in/iu.test(
result.result ?? "", result.result ?? "",
) )
); );
} }
export interface CommandResult { export async function retryInvalidToken<T extends { code: number; error: string }>(
code: number;
error: string;
}
export async function retryInvalidToken<T extends CommandResult>(
attempt: () => Promise<T>, attempt: () => Promise<T>,
relogin: () => Promise<void>, relogin: () => Promise<void>,
): Promise<T> { ): Promise<T> {
@@ -89,11 +75,11 @@ async function readCodexAuth(path: string): Promise<string> {
} catch { } catch {
throw new Error("invalid Codex login"); throw new Error("invalid Codex login");
} }
const tokens = isObject(value) ? value["tokens"] : undefined; const tokens = isObject(value) ? value.tokens : undefined;
if ( if (
!isObject(value) || !isObject(value) ||
typeof value["auth_mode"] !== "string" || typeof value.auth_mode !== "string" ||
value["auth_mode"] === "" || value.auth_mode === "" ||
!isObject(tokens) || !isObject(tokens) ||
!["access_token", "refresh_token", "id_token", "account_id"].every( !["access_token", "refresh_token", "id_token", "account_id"].every(
(name) => typeof tokens[name] === "string" && tokens[name] !== "", (name) => typeof tokens[name] === "string" && tokens[name] !== "",
@@ -1,8 +1,8 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { isNotFound } from "#common"; import { isNotFound } from "./errors.ts";
import { exitCode } from "./child.ts"; import { exitCode } from "./child.ts";
test("reports the exit code", async () => { test("reports the exit code", async () => {
-4
View File
@@ -1,4 +0,0 @@
// 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";
@@ -1,5 +1,4 @@
import { expect, test } from "vite-plus/test"; 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 MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
@@ -1,15 +1,9 @@
import { webcrypto } from "node:crypto"; import { webcrypto } from "node:crypto";
import { isMapping } from "#common"; import { isMapping } from "./json.ts";
import type { BotProfile } from "#profiles"; import type { BotProfile } from "./profiles.ts";
interface DefaultModels { const DEFAULT_MODELS: Record<BotProfile["type"], string> = {
claude: string;
codex: string;
opencode: string;
}
const DEFAULT_MODELS: DefaultModels = {
claude: "claude-sonnet-5", claude: "claude-sonnet-5",
codex: "gpt-5.6-terra", codex: "gpt-5.6-terra",
opencode: "openrouter/qwen/qwen3.8-27b", opencode: "openrouter/qwen/qwen3.8-27b",
@@ -1,13 +1,13 @@
import { spawnSync, type SpawnSyncReturns } from "node:child_process"; import { spawnSync } from "node:child_process";
import type { SpawnSyncReturns } from "node:child_process";
import { expect, test } from "vite-plus/test";
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 process from "node:process";
import { expect, test } from "vite-plus/test";
import { isNotFound } from "#common";
import type { RuntimeProfile } from "#runtime";
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "#testing";
import { isNotFound } from "./errors.ts";
import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from "./isolation.ts"; import { isRunnableProfileProcess, killProfileProcesses, readProfileFile } from "./isolation.ts";
import type { RuntimeProfile } from "./runtime.ts";
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "#testing";
const profile = (uid: number, home: string): RuntimeProfile => ({ const profile = (uid: number, home: string): RuntimeProfile => ({
index: uid - 20_000, index: uid - 20_000,
@@ -25,7 +25,7 @@ function runAs(
selected: RuntimeProfile, selected: RuntimeProfile,
command: string, command: string,
args: string[], args: string[],
env?: NodeJS.ProcessEnv, env?: Record<string, string>,
): SpawnSyncReturns<string> { ): SpawnSyncReturns<string> {
return spawnSync(command, args, { return spawnSync(command, args, {
cwd: "/", cwd: "/",
@@ -2,8 +2,8 @@ import { lstat, readdir, readFile } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises"; import { setTimeout as sleep } from "node:timers/promises";
import { isNotFound } from "#common"; import { isNotFound } from "./errors.ts";
import type { RuntimeProfile } from "#runtime"; import type { RuntimeProfile } from "./runtime.ts";
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+(\d+)/mu.exec(status)?.[1];
+1 -1
View File
@@ -1,5 +1,5 @@
// Type guards for values read from JSON, which arrive as `unknown`. // Type guards for values read from JSON, which arrive as `unknown`.
export type JsonObject = NodeJS.Dict<unknown>; export type JsonObject = Record<string, unknown>;
// Any non-null object, arrays included: reading a missing key yields undefined. // Any non-null object, arrays included: reading a missing key yields undefined.
export function isObject(value: unknown): value is JsonObject { export function isObject(value: unknown): value is JsonObject {
@@ -1,13 +1,11 @@
import { assert, expect, test } from "vite-plus/test"; import { assert, expect, test } from "vite-plus/test";
import { AgentAuthError } from "#profiles";
import { import {
collectOpenCodeOutput, collectOpenCodeOutput,
openCodeArgs, openCodeArgs,
openCodeError, openCodeError,
parseOpenCodeEvents, parseOpenCodeEvents,
} from "./opencode.ts"; } from "./opencode.ts";
import { AgentAuthError } from "./profiles.ts";
test("builds a non-interactive auto-approved OpenCode command", () => { 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([
@@ -101,5 +99,5 @@ test("surfaces an OpenCode error event instead of empty output", () => {
expect(() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded"))).toThrow( expect(() => parseOpenCodeEvents(openRouterError(429, "Rate limit exceeded"))).toThrow(
"API error 429", "API error 429",
); );
expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBeNull(); expect(openCodeError('{"type":"text","part":{"text":"ok"}}')).toBe(undefined);
}); });
+8 -16
View File
@@ -1,5 +1,5 @@
import { isObject } from "#common"; import { isObject } from "./json.ts";
import { AgentAuthError } from "#profiles"; import { AgentAuthError } from "./profiles.ts";
export function openCodeArgs(model: string, prompt: string): string[] { export function openCodeArgs(model: string, prompt: string): string[] {
return ["run", "--format", "json", "--auto", "--model", model, prompt]; return ["run", "--format", "json", "--auto", "--model", model, prompt];
@@ -7,21 +7,13 @@ export function openCodeArgs(model: string, prompt: string): string[] {
type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>; type ChunkWriter = (chunk: Uint8Array) => void | Promise<void>;
interface OpenCodeOutput {
stdout: string;
stderr: string;
}
export async function collectOpenCodeOutput( export async function collectOpenCodeOutput(
stdout: AsyncIterable<Uint8Array>, stdout: AsyncIterable<Uint8Array>,
stderr: AsyncIterable<Uint8Array>, stderr: AsyncIterable<Uint8Array>,
writeStdout: ChunkWriter, writeStdout: ChunkWriter,
writeStderr: ChunkWriter, writeStderr: ChunkWriter,
): Promise<OpenCodeOutput> { ): Promise<{ stdout: string; stderr: string }> {
const collect = async ( const collect = async (stream: AsyncIterable<Uint8Array>, write: ChunkWriter) => {
stream: AsyncIterable<Uint8Array>,
write: ChunkWriter,
): Promise<string> => {
const chunks: Uint8Array[] = []; const chunks: Uint8Array[] = [];
for await (const chunk of stream) { for await (const chunk of stream) {
chunks.push(chunk); chunks.push(chunk);
@@ -46,8 +38,8 @@ export async function collectOpenCodeOutput(
// OpenCode reports provider failures, including a rejected OpenRouter key // OpenCode reports provider failures, including a rejected OpenRouter key
// (401 "User not found."), as a JSON error event on stdout and exits 1 with an // (401 "User not found."), as a JSON error event on stdout and exits 1 with an
// empty stderr. // empty stderr.
export function openCodeError(output: string): Error | null { export function openCodeError(output: string): Error | undefined {
let failure: Error | null = null; let failure: Error | undefined;
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;
@@ -77,7 +69,7 @@ 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 | undefined;
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);
@@ -87,7 +79,7 @@ export function parseOpenCodeEvents(output: string): string {
const { text } = part; const { text } = part;
if (typeof text === "string" && text.trim() !== "") result = text; if (typeof text === "string" && text.trim() !== "") result = text;
} }
if (result === null) { if (result === undefined) {
throw openCodeError(output) ?? new Error("OpenCode produced no text output"); throw openCodeError(output) ?? new Error("OpenCode produced no text output");
} }
return result; return result;
+10 -20
View File
@@ -1,12 +1,13 @@
import { chmod, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises"; import { chmod, mkdir, rm, stat, symlink, writeFile } from "node:fs/promises";
import { assert, expect, test } from "vite-plus/test"; import { assert, expect, test } from "vite-plus/test";
import { isNotFound } from "#common"; import { isNotFound } from "./errors.ts";
import type { RuntimeFailure, RuntimeManifest, RuntimeOutcome } from "#runtime"; import { postRuntime } from "./post.ts";
import type { PostContext } from "./post.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing"; import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
import { type Fetcher, type PostContext, postRuntime } from "./post.ts";
const context: PostContext = { const context: PostContext = {
apiUrl: "https://gitea.example/api/v1", apiUrl: "https://gitea.example/api/v1",
repository: "owner/repo", repository: "owner/repo",
@@ -22,18 +23,7 @@ interface Fixture {
outcomeFile: string; outcomeFile: string;
} }
interface FixtureSuccess { type FixtureSource = Extract<RuntimeOutcome, { ok: false }> | { ok: true; body: string };
ok: true;
body: string;
}
type FixtureSource = RuntimeFailure | FixtureSuccess;
// One request the fake Gitea received.
interface RecordedRequest {
url: string;
init: RequestInit;
}
async function writePrivate(path: string, text: string): Promise<void> { async function writePrivate(path: string, text: string): Promise<void> {
await writeFile(path, text, { mode: 0o600 }); await writeFile(path, text, { mode: 0o600 });
@@ -42,8 +32,8 @@ async function writePrivate(path: string, text: string): Promise<void> {
async function writeArtifacts( async function writeArtifacts(
outcome: RuntimeOutcome, outcome: RuntimeOutcome,
review?: string, review: string | undefined,
anchors?: unknown, anchors: unknown,
): Promise<void> { ): Promise<void> {
if (!outcome.ok) return; if (!outcome.ok) return;
if (review !== undefined) await writePrivate(outcome.reviewPath, review); if (review !== undefined) await writePrivate(outcome.reviewPath, review);
@@ -117,7 +107,7 @@ function requestBody(init?: RequestInit): unknown {
} }
// A fake Gitea that records each request body and answers with `status`. // A fake Gitea that records each request body and answers with `status`.
function recordingFetch(bodies: unknown[], status = 201): Fetcher { function recordingFetch(bodies: unknown[], status = 201): typeof fetch {
return (_url, init) => { return (_url, init) => {
bodies.push(requestBody(init)); bodies.push(requestBody(init));
return Promise.resolve(new Response(null, { status })); return Promise.resolve(new Response(null, { status }));
@@ -126,7 +116,7 @@ function recordingFetch(bodies: unknown[], status = 201): Fetcher {
test("posts a successful issue result and removes runtime credentials", async () => { test("posts a successful issue result and removes runtime credentials", async () => {
const result = await fixture({ ok: true, body: "done" }); const result = await fixture({ ok: true, body: "done" });
const requests: RecordedRequest[] = []; const requests: { url: unknown; init: RequestInit | undefined }[] = [];
await postRuntime(result.manifestPath, context, (url, init) => { await postRuntime(result.manifestPath, context, (url, init) => {
requests.push({ url, init }); requests.push({ url, init });
+38 -22
View File
@@ -1,10 +1,14 @@
import type { Stats } from "node:fs"; import type { Stats } from "node:fs";
import { lstat, readFile, rm } from "node:fs/promises"; import { lstat, readFile, rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import process from "node:process";
import { isNotFound, isObject } from "#common"; import { isNotFound } from "./errors.ts";
import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "#profiles"; import { isObject } from "./json.ts";
import { readRuntimeManifest, type RuntimeManifest, type RuntimeOutcome } from "#runtime"; import { failureFooter, withProfile } from "./profiles.ts";
import type { FailureReason, ProfileFailure } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
export interface PostContext { export interface PostContext {
apiUrl: string; apiUrl: string;
@@ -22,7 +26,7 @@ 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: NodeJS.Dict<[event: string, mark: string]> = { const VERDICTS: Record<string, [event: string, mark: string]> = {
Approved: ["APPROVED", "✅"], Approved: ["APPROVED", "✅"],
"Changes requested": ["REQUEST_CHANGES", "🛑"], "Changes requested": ["REQUEST_CHANGES", "🛑"],
}; };
@@ -33,9 +37,6 @@ const isFailureReason = (value: unknown): value is FailureReason => FAILURE_REAS
const failureComment = (failures: ProfileFailure[]): string => const failureComment = (failures: ProfileFailure[]): string =>
`AI bot failed.\n\n${failureFooter(failures)}`; `AI bot failed.\n\n${failureFooter(failures)}`;
// The `fetch` subset the reviewer posts through, which tests replace.
export type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
class GiteaRequestError extends Error {} class GiteaRequestError extends Error {}
function parseAnchors(text: string, sourcePath: string): Anchor[] { function parseAnchors(text: string, sourcePath: string): Anchor[] {
@@ -69,9 +70,9 @@ async function readOutcome(path: string): Promise<RuntimeOutcome> {
throw new Error("invalid agent outcome"); throw new Error("invalid agent outcome");
} }
const outcome = value; const outcome = value;
if (outcome["ok"] === false) { if (outcome.ok === false) {
// Only known labels reach the comment, never raw agent output. // 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 { return {
ok: false, ok: false,
failures: failures.flatMap((entry: unknown) => { failures: failures.flatMap((entry: unknown) => {
@@ -83,26 +84,26 @@ async function readOutcome(path: string): Promise<RuntimeOutcome> {
}; };
} }
if ( if (
outcome["ok"] !== true || outcome.ok !== true ||
typeof outcome["body"] !== "string" || typeof outcome.body !== "string" ||
typeof outcome["reviewPath"] !== "string" || typeof outcome.reviewPath !== "string" ||
typeof outcome["anchorsPath"] !== "string" || typeof outcome.anchorsPath !== "string" ||
!isObject(outcome["profile"]) !isObject(outcome.profile)
) { ) {
throw new Error("invalid agent outcome"); throw new Error("invalid agent outcome");
} }
const profile = outcome["profile"]; const profile = outcome.profile;
if (!isProfileType(profile["type"]) || typeof profile["model"] !== "string") { if (!isProfileType(profile.type) || typeof profile.model !== "string") {
throw new Error("invalid agent outcome"); throw new Error("invalid agent outcome");
} }
return { return {
ok: true, ok: true,
body: outcome["body"], body: outcome.body,
reviewPath: outcome["reviewPath"], reviewPath: outcome.reviewPath,
anchorsPath: outcome["anchorsPath"], anchorsPath: outcome.anchorsPath,
profile: { profile: {
type: profile["type"], type: profile.type,
model: profile["model"], model: profile.model,
}, },
}; };
} }
@@ -136,7 +137,7 @@ async function readAgentArtifact(
export async function postRuntime( export async function postRuntime(
manifestPath: string, manifestPath: string,
context: PostContext, context: PostContext,
fetcher: Fetcher = fetch, fetcher: typeof fetch = fetch,
): Promise<void> { ): Promise<void> {
const manifest = await readRuntimeManifest(manifestPath); const manifest = await readRuntimeManifest(manifestPath);
try { try {
@@ -222,3 +223,18 @@ export async function postRuntime(
}); });
} }
} }
function required(name: string): string {
const value = process.env[name];
if (value === undefined || value === "") throw new Error(`${name} is not set`);
return value;
}
if (import.meta.main) {
await postRuntime(required("BOT_AGENTS_MANIFEST"), {
apiUrl: required("GITEA_API_URL"),
repository: required("GITEA_REPOSITORY"),
issueIndex: required("ISSUE_INDEX"),
eventName: required("EVENT_NAME"),
});
}
-22
View File
@@ -1,22 +0,0 @@
// The post phase: posts the agent's result as the reviewer and removes the
// runtime credentials.
import process from "node:process";
import { postRuntime } from "./post.ts";
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`);
return value;
}
if (import.meta.main) {
await postRuntime(required("BOT_AGENTS_MANIFEST"), {
apiUrl: required("GITEA_API_URL"),
repository: required("GITEA_REPOSITORY"),
issueIndex: required("ISSUE_INDEX"),
eventName: required("EVENT_NAME"),
});
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { appendFile, rm } from "node:fs/promises"; import { appendFile, rm } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { prepareRuntime } from "#runtime"; import { prepareRuntime } from "./runtime.ts";
const env = (name: string): string => { const env = (name: string): string => {
const value = process.env[name]; const value = process.env[name];
-4
View File
@@ -1,4 +0,0 @@
// Child processes: waiting on and echoing them, and the isolated profile
// user's processes and files.
export { echo, exitCode } from "./child.ts";
export { killProfileProcesses, readProfileFile } from "./isolation.ts";
@@ -1,5 +1,4 @@
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { import {
AgentAuthError, AgentAuthError,
agentEnvironment, agentEnvironment,
+14 -24
View File
@@ -1,32 +1,22 @@
// The agent and model a profile runs, which every report names. export interface BotProfile {
export interface ProfileIdentity {
type: "claude" | "codex" | "opencode"; type: "claude" | "codex" | "opencode";
model: string; model: string;
}
export interface BotProfile extends ProfileIdentity {
token?: string; token?: string;
} }
export function profileFooter(profile: ProfileIdentity): string { export function profileFooter(profile: Pick<BotProfile, "type" | "model">): string {
return `> Profile: \`${profile.type}\` · \`${profile.model}\``; return `> Profile: \`${profile.type}\` · \`${profile.model}\``;
} }
export function withProfile(body: string, profile: ProfileIdentity): string { export function withProfile(body: string, profile: Pick<BotProfile, "type" | "model">): string {
return `${body}\n\n${profileFooter(profile)}`; return `${body}\n\n${profileFooter(profile)}`;
} }
export type FailureReason = "sign-in failed" | "usage exhausted" | "failed"; export type FailureReason = "sign-in failed" | "usage exhausted" | "failed";
export interface ProfileFailure extends ProfileIdentity { export type ProfileFailure = Pick<BotProfile, "type" | "model"> & {
reason: FailureReason; reason: FailureReason;
} };
// Where a profile's isolated user keeps its home.
interface ProfileHome {
type: ProfileIdentity["type"];
home: string;
}
// Raised once an agent's credentials are rejected and any re-login is spent. // Raised once an agent's credentials are rejected and any re-login is spent.
export class AgentAuthError extends Error {} export class AgentAuthError extends Error {}
@@ -51,7 +41,7 @@ export function failureFooter(failures: ProfileFailure[]): string {
// Tries the profiles from `index` on, moving on only after a sign-in failure // Tries the profiles from `index` on, moving on only after a sign-in failure
// or exhausted usage, and records every profile it tried. // or exhausted usage, and records every profile it tried.
async function attemptFrom<T, P extends ProfileIdentity>( async function attemptFrom<T, P extends Pick<BotProfile, "type" | "model">>(
profiles: P[], profiles: P[],
attempt: (profile: P) => Promise<T>, attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[], failures: ProfileFailure[],
@@ -72,7 +62,7 @@ async function attemptFrom<T, P extends ProfileIdentity>(
} }
} }
export function runProfiles<T, P extends ProfileIdentity>( export function runProfiles<T, P extends Pick<BotProfile, "type" | "model">>(
profiles: P[], profiles: P[],
attempt: (profile: P) => Promise<T>, attempt: (profile: P) => Promise<T>,
failures: ProfileFailure[] = [], failures: ProfileFailure[] = [],
@@ -80,8 +70,8 @@ export function runProfiles<T, P extends ProfileIdentity>(
return attemptFrom(profiles, attempt, failures, 0); return attemptFrom(profiles, attempt, failures, 0);
} }
export function sanitizedEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv { export function sanitizedEnvironment(environment: Record<string, string>): Record<string, string> {
const result: NodeJS.ProcessEnv = {}; const result: Record<string, string> = {};
for (const name of [ for (const name of [
"PATH", "PATH",
"HOME", "HOME",
@@ -115,13 +105,13 @@ export function sanitizedEnvironment(environment: NodeJS.ProcessEnv): NodeJS.Pro
} }
export function agentEnvironment( export function agentEnvironment(
environment: NodeJS.ProcessEnv, environment: Record<string, string>,
profile: ProfileHome, profile: Pick<BotProfile, "type"> & { home: string },
token?: string, token?: string,
): NodeJS.ProcessEnv { ): Record<string, string> {
const result = sanitizedEnvironment(environment); const result = sanitizedEnvironment(environment);
result["HOME"] = profile.home; result.HOME = profile.home;
if (profile.type === "codex") result["CODEX_HOME"] = `${profile.home}/.codex`; if (profile.type === "codex") result.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",
-12
View File
@@ -1,12 +0,0 @@
// Bot profiles: their footers, failure reports, ordered runs, and the
// environment each agent gets.
export {
AgentAuthError,
agentEnvironment,
failureFooter,
profileFooter,
runProfiles,
sanitizedEnvironment,
withProfile,
} from "./profiles.ts";
export type { BotProfile, FailureReason, ProfileFailure, ProfileIdentity } from "./profiles.ts";
+53 -54
View File
@@ -10,51 +10,46 @@ import { finished } from "node:stream/promises";
import { setTimeout as sleep } from "node:timers/promises"; import { setTimeout as sleep } from "node:timers/promises";
import { import {
type ClaudeFailureFields,
type CodexAuthentication,
codexAuthentication, codexAuthentication,
collectOpenCodeOutput,
type CommandResult,
isClaudeAuthFailure, isClaudeAuthFailure,
isCodexAuthFailure, isCodexAuthFailure,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
persistCodexAuth, persistCodexAuth,
retryInvalidToken, retryInvalidToken,
stageCodexAuth, stageCodexAuth,
} from "#agents"; } from "./auth.ts";
import { isObject } from "#common"; import type { ClaudeFailureFields, CodexAuthentication } from "./auth.ts";
import { echo, exitCode, killProfileProcesses, readProfileFile } from "#processes"; import { echo, exitCode } from "./child.ts";
import { killProfileProcesses, readProfileFile } from "./isolation.ts";
import { isObject } from "./json.ts";
import {
collectOpenCodeOutput,
openCodeArgs,
openCodeError,
parseOpenCodeEvents,
} from "./opencode.ts";
import { import {
AgentAuthError, AgentAuthError,
agentEnvironment, agentEnvironment,
type ProfileFailure,
type ProfileIdentity,
runProfiles, runProfiles,
sanitizedEnvironment, sanitizedEnvironment,
withProfile, withProfile,
} from "#profiles"; } from "./profiles.ts";
import { readRuntimeManifest, type RuntimeOutcome, type RuntimeProfile } from "#runtime"; import type { ProfileFailure } from "./profiles.ts";
import { readRuntimeManifest } from "./runtime.ts";
import type { RuntimeOutcome, RuntimeProfile } from "./runtime.ts";
interface AttemptResult { interface AttemptResult {
body: string; body: string;
reviewPath: string; reviewPath: string;
anchorsPath: string; anchorsPath: string;
profile: ProfileIdentity; profile: Pick<RuntimeProfile, "type" | "model">;
} }
// Claude Code's stream-json `result` event, with camelCase field names. // Claude Code's stream-json `result` event, in its own field names.
interface ClaudeResult extends ClaudeFailureFields { type ClaudeResult = ClaudeFailureFields &
isError?: boolean; Partial<Record<"is_error", boolean>> & {
subtype: string; subtype: string;
} };
interface RenderedPrompt {
prompt: string;
reviewPath: string;
anchorsPath: string;
}
const env = (name: string): string => { const env = (name: string): string => {
const value = process.env[name]; const value = process.env[name];
@@ -62,8 +57,8 @@ const env = (name: string): string => {
return value; return value;
}; };
function definedEnvironment(): NodeJS.ProcessEnv { function definedEnvironment(): Record<string, string> {
const result: NodeJS.ProcessEnv = {}; const result: Record<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[name] = value; result[name] = value;
@@ -91,7 +86,7 @@ async function gitea(path: string, body?: unknown): Promise<Response> {
Authorization: `token ${AUTHOR_TOKEN}`, Authorization: `token ${AUTHOR_TOKEN}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: body === undefined ? undefined : JSON.stringify(body), body: body === undefined ? null : JSON.stringify(body),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`${path}: ${response.status} ${await response.text()}`); throw new Error(`${path}: ${response.status} ${await response.text()}`);
@@ -110,15 +105,15 @@ async function runRoot(command: string, args: string[]): Promise<void> {
async function configureGitAuthor(): Promise<void> { async function configureGitAuthor(): Promise<void> {
const user: unknown = await (await gitea("user")).json(); 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"); throw new Error("user: unexpected Gitea response");
} }
await runRoot("git", ["config", "user.name", user["login"]]); await runRoot("git", ["config", "user.name", user.login]);
await runRoot("git", ["config", "user.email", user["email"]]); await runRoot("git", ["config", "user.email", user.email]);
} }
async function readProfileToken(profile: RuntimeProfile): Promise<string | null> { async function readProfileToken(profile: RuntimeProfile): Promise<string | undefined> {
const token = profile.tokenFile === null ? null : await readFile(profile.tokenFile, "utf8"); const token = profile.tokenFile === null ? undefined : await readFile(profile.tokenFile, "utf8");
return token; return token;
} }
@@ -160,11 +155,13 @@ async function prepareProfile(profile: RuntimeProfile): Promise<void> {
// One issue comment as the prompt quotes it. // One issue comment as the prompt quotes it.
function formatComment(comment: unknown): string { function formatComment(comment: unknown): string {
const { user, created_at: createdAt, body } = isObject(comment) ? comment : {}; 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`; return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`;
} }
async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> { async function renderPrompt(
profile: RuntimeProfile,
): Promise<{ prompt: string; reviewPath: string; anchorsPath: string }> {
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 comments: unknown = await (
@@ -173,7 +170,7 @@ async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
if (!Array.isArray(comments)) { if (!Array.isArray(comments)) {
throw new TypeError("comments: unexpected Gitea response"); throw new TypeError("comments: unexpected Gitea response");
} }
const values: NodeJS.ProcessEnv = { const values: Record<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 (await gitea(RULES_PATH)).text(),
@@ -184,7 +181,7 @@ async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
REVIEW_PATH: reviewPath, REVIEW_PATH: reviewPath,
ANCHORS_PATH: anchorsPath, 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 { return {
prompt: template.replaceAll( prompt: template.replaceAll(
/\$\{(\w+)\}/gu, /\$\{(\w+)\}/gu,
@@ -220,7 +217,7 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
}, },
); );
const status = exitCode(claude); const status = exitCode(claude);
let result: ClaudeResult | null = null; let result: ClaudeResult | undefined;
let error = ""; let error = "";
const copyError = (async (): Promise<void> => { const copyError = (async (): Promise<void> => {
claude.stderr.setEncoding("utf8"); claude.stderr.setEncoding("utf8");
@@ -234,29 +231,31 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
if (line !== "") { if (line !== "") {
const event: unknown = JSON.parse(line); const event: unknown = JSON.parse(line);
if (isObject(event)) { 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 : []) { for (const part of Array.isArray(content) ? content : []) {
if (isObject(part)) { if (isObject(part)) {
const text = part["thinking"] ?? part["text"] ?? part["name"]; const text = part.thinking ?? part.text ?? part.name;
if (typeof text === "string" && text !== "") { if (typeof text === "string" && text !== "") {
console.log(text); console.log(text);
} }
} }
} }
if (event["type"] === "result") { if (event.type === "result") {
const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event; const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event;
result = { result = {
isError: typeof isError === "boolean" ? isError : undefined, ...(typeof isError === "boolean" && { is_error: isError }),
result: typeof answer === "string" ? answer : undefined, ...(typeof answer === "string" && { result: answer }),
subtype: String(subtype), subtype: String(subtype),
apiErrorStatus: typeof apiStatus === "number" ? apiStatus : undefined, ...((typeof apiStatus === "number" || apiStatus === null) && {
api_error_status: apiStatus,
}),
}; };
} }
} }
} }
} }
const [code] = await Promise.all([status, copyError]); const [code] = await Promise.all([status, copyError]);
if (result?.isError === true) { if (result?.is_error === 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)) { if (isClaudeAuthFailure(result)) {
throw new AgentAuthError("claude sign-in failed", { cause: failure }); throw new AgentAuthError("claude sign-in failed", { cause: failure });
@@ -289,7 +288,7 @@ async function pollForInstructions(
async function codexDeviceLogin( async function codexDeviceLogin(
profile: RuntimeProfile, profile: RuntimeProfile,
environment: NodeJS.ProcessEnv, environment: Record<string, string>,
): Promise<void> { ): Promise<void> {
const login = spawn("codex", ["login", "--device-auth"], { const login = spawn("codex", ["login", "--device-auth"], {
cwd: WORKSPACE, cwd: WORKSPACE,
@@ -318,7 +317,7 @@ async function codexDeviceLogin(
async function codexApiLogin( async function codexApiLogin(
profile: RuntimeProfile, profile: RuntimeProfile,
environment: NodeJS.ProcessEnv, environment: Record<string, string>,
token: string, token: string,
): Promise<void> { ): Promise<void> {
const login = spawn("codex", ["login", "--with-api-key"], { const login = spawn("codex", ["login", "--with-api-key"], {
@@ -365,7 +364,7 @@ async function runCodex(
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<{ code: number; error: string }> => {
const codex = spawn( const codex = spawn(
"codex", "codex",
[ [
@@ -442,12 +441,12 @@ async function runOpenCode(
async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> { async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
const token = await readProfileToken(profile); const token = await readProfileToken(profile);
const authentication = profile.type === "codex" ? codexAuthentication(token ?? undefined) : null; const authentication = profile.type === "codex" ? codexAuthentication(token) : null;
let stagedCodexAuth: string | null = null; let stagedCodexAuth: string | null = null;
let body: string | null = null; let body: string | undefined;
let resultPath: string | null = null; let resultPath: string | null = null;
let reviewPath: string | null = null; let reviewPath: string | undefined;
let anchorsPath: string | null = null; let anchorsPath: string | undefined;
try { try {
if (authentication?.kind === "persistent") { 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);
@@ -462,7 +461,7 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
resultPath = await runCodex( resultPath = await runCodex(
profile, profile,
rendered.prompt, rendered.prompt,
authentication ?? codexAuthentication(token ?? undefined), authentication ?? codexAuthentication(token),
); );
} else { } else {
body = await runOpenCode(profile, token ?? "", rendered.prompt); body = await runOpenCode(profile, token ?? "", rendered.prompt);
@@ -474,7 +473,7 @@ async function runProfile(profile: RuntimeProfile): Promise<AttemptResult> {
} }
} }
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 === undefined || reviewPath === undefined || anchorsPath === undefined) {
throw new Error("agent attempt produced no result"); throw new Error("agent attempt produced no result");
} }
return { return {
@@ -2,12 +2,13 @@ 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 process from "node:process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { assert, expect, test } from "vite-plus/test"; import { assert, expect, test } from "vite-plus/test";
import { prepareRuntime } from "./runtime.ts";
import type { RuntimeProfile } from "./runtime.ts";
import { IS_ROOT, makeTempDir } from "#testing"; import { IS_ROOT, makeTempDir } from "#testing";
import { prepareRuntime, type RuntimeProfile } from "./runtime.ts";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA"; const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
@@ -81,7 +82,7 @@ test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fail
); );
const result = spawnSync( const result = spawnSync(
process.execPath, process.execPath,
[fileURLToPath(new URL("../prepare/index.ts", import.meta.url))], [fileURLToPath(new URL("prepare.ts", import.meta.url))],
{ {
env: { env: {
ACTION_PATH: action, ACTION_PATH: action,
+14 -20
View File
@@ -1,20 +1,19 @@
import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { chmod, chown, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { isMapping } from "#common";
import type { ProfileFailure, ProfileIdentity } from "#profiles";
import { parseCredentials } from "./credentials.ts"; import { parseCredentials } from "./credentials.ts";
import { isMapping } from "./json.ts";
import type { BotProfile, ProfileFailure } from "./profiles.ts";
const FIRST_PROFILE_UID = 20_000; const FIRST_PROFILE_UID = 20_000;
export interface RuntimeProfile extends ProfileIdentity { export type RuntimeProfile = Pick<BotProfile, "type" | "model"> & {
index: number; index: number;
uid: number; uid: number;
gid: number; gid: number;
home: string; home: string;
tokenFile: string | null; tokenFile: string | null;
} };
export interface RuntimeManifest { export interface RuntimeManifest {
runtimeDir: string; runtimeDir: string;
@@ -24,20 +23,15 @@ export interface RuntimeManifest {
profiles: RuntimeProfile[]; profiles: RuntimeProfile[];
} }
export interface RuntimeSuccess { export type RuntimeOutcome =
| {
ok: true; ok: true;
body: string; body: string;
reviewPath: string; reviewPath: string;
anchorsPath: string; anchorsPath: string;
profile: ProfileIdentity; profile: Pick<RuntimeProfile, "type" | "model">;
} }
| { ok: false; failures: ProfileFailure[] };
export interface RuntimeFailure {
ok: false;
failures: ProfileFailure[];
}
export type RuntimeOutcome = RuntimeSuccess | RuntimeFailure;
async function writePrivate(path: string, value: string, uid: number, gid: number): Promise<void> { async function writePrivate(path: string, value: string, uid: number, gid: number): Promise<void> {
await writeFile(path, value, { mode: 0o600 }); await writeFile(path, value, { mode: 0o600 });
@@ -107,11 +101,11 @@ export async function prepareRuntime(
function isRuntimeManifest(value: unknown): value is RuntimeManifest { function isRuntimeManifest(value: unknown): value is RuntimeManifest {
return ( return (
isMapping(value) && isMapping(value) &&
typeof value["runtimeDir"] === "string" && typeof value.runtimeDir === "string" &&
typeof value["manifestPath"] === "string" && typeof value.manifestPath === "string" &&
typeof value["reviewerFile"] === "string" && typeof value.reviewerFile === "string" &&
typeof value["outcomeFile"] === "string" && typeof value.outcomeFile === "string" &&
Array.isArray(value["profiles"]) Array.isArray(value.profiles)
); );
} }
-10
View File
@@ -1,10 +0,0 @@
// The runtime the prepare phase writes for the later phases: decrypted
// credentials laid out per profile, described by a manifest.
export { prepareRuntime, readRuntimeManifest } from "./runtime.ts";
export type {
RuntimeFailure,
RuntimeManifest,
RuntimeOutcome,
RuntimeProfile,
RuntimeSuccess,
} from "./runtime.ts";
+1 -1
View File
@@ -38,7 +38,7 @@ export interface CommandResult {
export async function runCommand( export async function runCommand(
command: string, command: string,
args: string[], args: string[],
env: NodeJS.ProcessEnv, env: Record<string, string>,
): Promise<CommandResult> { ): Promise<CommandResult> {
const child = spawn(command, args, { env, stdio: ["ignore", "ignore", "pipe"] }); const child = spawn(command, args, { 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")]);
+4 -3
View File
@@ -1,10 +1,11 @@
import process from "node:process"; import process from "node:process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { runCommand } from "#testing"; import { runCommand } from "#testing";
// Each phase runs as `node src/<phase>/index.ts` with no build step. Node loads the // Each phase runs as `node src/<phase>.ts` with no build step. Node loads the
// whole import graph, stripping types, before the phase reads its first // whole import graph, stripping types, before the phase reads its first
// variable, so failing on that variable proves every module loads. // variable, so failing on that variable proves every module loads.
const PHASES: [phase: string, firstVariable: string][] = [ const PHASES: [phase: string, firstVariable: string][] = [
@@ -14,8 +15,8 @@ const PHASES: [phase: string, firstVariable: string][] = [
]; ];
test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => { 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}.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.success).toBe(false);
expect(result.stderr).toContain(`${firstVariable} is not set`); expect(result.stderr).toContain(`${firstVariable} is not set`);
}); });
+29 -30
View File
@@ -1,11 +1,12 @@
import { readdir, readFile, rm, stat } from "node:fs/promises"; import { readdir, readFile, rm, stat } from "node:fs/promises";
import process from "node:process"; import process from "node:process";
import { assert, expect, test } from "vite-plus/test"; import { assert, expect, test } from "vite-plus/test";
import { parse } from "yaml"; import { parse } from "yaml";
import { makeTempDir, runCommand } from "#testing"; import { makeTempDir, runCommand } from "#testing";
type JsonObject = NodeJS.Dict<unknown>; type JsonObject = Record<string, unknown>;
const isMapping = (value: unknown): value is JsonObject => const isMapping = (value: unknown): value is JsonObject =>
typeof value === "object" && value !== null && !Array.isArray(value); typeof value === "object" && value !== null && !Array.isArray(value);
@@ -47,11 +48,11 @@ test("all committed YAML parses", async () => {
test("the action exposes only the master key and scopes it to preparation", async () => { test("the action exposes only the master key and scopes it to preparation", async () => {
const action = await yaml("action.yml"); const action = await yaml("action.yml");
expect(Object.keys(mapping(action["inputs"], "inputs"))).toStrictEqual(["master-key"]); expect(Object.keys(mapping(action.inputs, "inputs"))).toStrictEqual(["master-key"]);
const steps = sequence(mapping(action["runs"], "runs")["steps"], "runs.steps"); const steps = sequence(mapping(action.runs, "runs").steps, "runs.steps");
for (const step of steps) { for (const step of steps) {
if (typeof step["run"] === "string") { if (typeof step.run === "string") {
expect(step["run"].includes("\n"), String(step["name"])).toBe(false); expect(step.run.includes("\n"), String(step.name)).toBe(false);
} }
} }
const checkout = findStep(steps, "uses", "actions/checkout@v4"); const checkout = findStep(steps, "uses", "actions/checkout@v4");
@@ -59,16 +60,16 @@ test("the action exposes only the master key and scopes it to preparation", asyn
const agent = findStep(steps, "id", "agent"); const agent = findStep(steps, "id", "agent");
const post = findStep(steps, "id", "post"); const post = findStep(steps, "id", "post");
findStep(steps, "id", "source"); findStep(steps, "id", "source");
expect(mapping(checkout["with"], "checkout.with")["token"]).toBe("${{ gitea.token }}"); expect(mapping(checkout.with, "checkout.with").token).toBe("${{ gitea.token }}");
expect(mapping(prepare["env"], "prepare.env")["BOT_AGENTS_MASTER_KEY"]).toBe( expect(mapping(prepare.env, "prepare.env").BOT_AGENTS_MASTER_KEY).toBe(
"${{ inputs.master-key }}", "${{ inputs.master-key }}",
); );
expect(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY")).toBe(false); expect(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(JSON.stringify(post).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 }}"); expect(mapping(agent.env, "agent.env").GITEA_TOKEN).toBe("${{ gitea.token }}");
assert(String(post["if"]).includes("always()")); assert(String(post.if).includes("always()"));
for (const step of [prepare, agent, post]) { 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"));
} }
}); });
@@ -86,11 +87,9 @@ test("the source protection script stages immutable action files", async () => {
const path = (await readFile(outputFile, "utf8")).trim().slice(5); const path = (await readFile(outputFile, "utf8")).trim().slice(5);
const files = [ const files = [
"src/prepare/index.ts", "src/prepare.ts",
"src/run/index.ts", "src/run.ts",
"src/post/index.ts", "src/post.ts",
"src/runtime/index.ts",
"src/agents/auth.ts",
"package.json", "package.json",
"profiles.json", "profiles.json",
"prompt.md", "prompt.md",
@@ -106,7 +105,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.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\("([^"]+)", import\.meta\.url\)/gu)].map(
([, asset = ""]) => new URL(asset, module), ([, asset = ""]) => new URL(asset, module),
@@ -119,17 +118,17 @@ test("module-relative runtime assets exist", async () => {
test("the repository uses its trusted main action", async () => { test("the repository uses its trusted main action", async () => {
const workflow = await yaml(".gitea/workflows/agents.yml"); const workflow = await yaml(".gitea/workflows/agents.yml");
const job = mapping(mapping(workflow["jobs"], "jobs")["agent"], "jobs.agent"); const job = mapping(mapping(workflow.jobs, "jobs").agent, "jobs.agent");
const permissions = mapping(job["permissions"], "jobs.agent.permissions"); const permissions = mapping(job.permissions, "jobs.agent.permissions");
expect(permissions).toStrictEqual({ expect(permissions).toStrictEqual({
code: "write", code: "write",
issues: "write", issues: "write",
"pull-requests": "write", "pull-requests": "write",
}); });
const steps = sequence(job["steps"], "jobs.agent.steps"); const steps = sequence(job.steps, "jobs.agent.steps");
expect(steps.findIndex((step) => step["uses"] === "./")).toStrictEqual(-1); expect(steps.findIndex((step) => step.uses === "./")).toStrictEqual(-1);
const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main"); 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 }}", "${{ secrets.BOT_AGENTS_MASTER_KEY }}",
); );
expect(JSON.stringify(workflow).includes("gitea.actor != 'bot'")).toBe(true); expect(JSON.stringify(workflow).includes("gitea.actor != 'bot'")).toBe(true);
@@ -137,9 +136,9 @@ test("the repository uses its trusted main action", async () => {
test("CI checks every phase and the root isolation suite", async () => { test("CI checks every phase and the root isolation suite", async () => {
const workflow = await yaml(".gitea/workflows/check.yml"); const workflow = await yaml(".gitea/workflows/check.yml");
const job = mapping(mapping(workflow["jobs"], "jobs")["check"], "jobs.check"); const job = mapping(mapping(workflow.jobs, "jobs").check, "jobs.check");
const commands = sequence(job["steps"], "jobs.check.steps") const commands = sequence(job.steps, "jobs.check.steps")
.map((step) => (typeof step["run"] === "string" ? step["run"] : "")) .map((step) => (typeof step.run === "string" ? step.run : ""))
.join("\n"); .join("\n");
// `vp check` type-checks every phase, and `vp test` runs each of them under // `vp check` type-checks every phase, and `vp test` runs each of them under
// Node as well as the root-only isolation tests. // Node as well as the root-only isolation tests.
@@ -149,7 +148,7 @@ test("CI checks every phase and the root isolation suite", async () => {
}); });
test("uses the standard Skills installer without custom discovery", async () => { test("uses the standard Skills installer without custom discovery", async () => {
const runner = await readFile("src/run/index.ts", "utf8"); const runner = await readFile("src/run.ts", "utf8");
assert(runner.includes("skills add obra/superpowers")); assert(runner.includes("skills add obra/superpowers"));
const removed = await stat("superpowers.ts").then( const removed = await stat("superpowers.ts").then(
() => false, () => false,
@@ -169,15 +168,15 @@ test("does not install Deno", async () => {
}); });
test("profile preparation does not receive provider credentials", async () => { test("profile preparation does not receive provider credentials", async () => {
const runner = await readFile("src/run/index.ts", "utf8"); const runner = await readFile("src/run.ts", "utf8");
assert(runner.includes("await prepareProfile(profile);")); assert(runner.includes("await prepareProfile(profile);"));
expect(runner.includes("prepareProfile(profile, token)")).toBe(false); expect(runner.includes("prepareProfile(profile, token)")).toBe(false);
}); });
test("central profiles fall back from Codex to Claude to OpenCode", async () => { test("central profiles fall back from Codex to Claude to OpenCode", async () => {
const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8")); const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8"));
const profiles = sequence(mapping(credentials, "profiles.json")["profiles"], "profiles"); const profiles = sequence(mapping(credentials, "profiles.json").profiles, "profiles");
expect(profiles.map((profile) => profile["type"])).toStrictEqual(["codex", "claude", "opencode"]); expect(profiles.map((profile) => profile.type)).toStrictEqual(["codex", "claude", "opencode"]);
expect(profiles[2]?.["model"]).toBe("openrouter/qwen/qwen3.8-27b"); expect(profiles[2]?.model).toBe("openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2]?.["token"]).startsWith("v1:")); assert(String(profiles[2]?.token).startsWith("v1:"));
}); });
+1 -1
View File
@@ -9,8 +9,8 @@
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"strict": true, "strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
+2 -13
View File
@@ -1,5 +1,3 @@
import explicitTypes from "eslint-plugin-explicit-types";
import importStructure from "eslint-plugin-import-structure";
import { defineConfig } from "vite-plus"; import { defineConfig } from "vite-plus";
import projectLint from "./scripts/project-lint.ts"; import projectLint from "./scripts/project-lint.ts";
@@ -7,18 +5,9 @@ import projectLint from "./scripts/project-lint.ts";
export default defineConfig({ export default defineConfig({
lint: { lint: {
plugins: ["typescript", "unicorn", "oxc", "import"], plugins: ["typescript", "unicorn", "oxc", "import"],
jsPlugins: [ jsPlugins: ["./scripts/project-lint.ts"],
"eslint-plugin-explicit-types",
"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" },
rules: { rules: projectLint.configs.project.rules,
...explicitTypes.configs.concrete.rules,
...explicitTypes.configs.absence.rules,
...importStructure.configs.modules.rules,
...projectLint.configs.project.rules,
},
options: { options: {
typeAware: true, typeAware: true,
typeCheck: true, typeCheck: true,