Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2de4a5d6d | |||
| 0f4c2c274a | |||
| 8d654d69a4 | |||
| 8b5c249248 | |||
| 5af53b0882 | |||
| eba9b9c57c | |||
| 3cbe4171ea | |||
| cd1864e1a9 | |||
| 0c5059e3b4 | |||
| c6fc183ad6 | |||
| 1f54518320 | |||
| 999a089374 | |||
| 166476dee9 | |||
| c778c00068 | |||
| 6b87ff66b9 | |||
| d2d3cbffdb | |||
| bfa8269801 | |||
| 15fe481181 | |||
| 662e946fad | |||
| ffb7cb9a75 | |||
| 2e09945a6a | |||
| 934a3cfd03 | |||
| b170f25c7d |
@@ -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]]).
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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/`).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 }}
|
||||||
|
|||||||
Generated
+3
-28
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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";
|
|
||||||
@@ -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", () => {
|
||||||
@@ -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 () => {
|
||||||
@@ -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,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);
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
@@ -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 });
|
||||||
@@ -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"),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,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];
|
||||||
@@ -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,
|
||||||
@@ -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",
|
||||||
@@ -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";
|
|
||||||
@@ -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,
|
||||||
@@ -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;
|
| {
|
||||||
body: string;
|
ok: true;
|
||||||
reviewPath: string;
|
body: string;
|
||||||
anchorsPath: string;
|
reviewPath: string;
|
||||||
profile: ProfileIdentity;
|
anchorsPath: string;
|
||||||
}
|
profile: Pick<RuntimeProfile, "type" | "model">;
|
||||||
|
}
|
||||||
export interface RuntimeFailure {
|
| { ok: false; failures: ProfileFailure[] };
|
||||||
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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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";
|
|
||||||
@@ -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")]);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user