Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0920a4e91b | |||
| 627458fb5f | |||
| 50df193a8c | |||
| 5b568f9ea6 | |||
| 60c63dee98 | |||
| 0a31dbb820 | |||
| 6ad859be62 |
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
description: bot-agents migration decisions: live @main action for six repos, JSON profiles, Node 24.2+, Deno kept only for agents, no @cunarist plugins needed
|
description: bot-agents migration decisions: live @main action for six repos, JSON profiles, Node 24.2+, Deno kept only for agents, no eslint-plugin-* packages needed
|
||||||
---
|
---
|
||||||
|
|
||||||
Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to
|
Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to
|
||||||
@@ -15,17 +15,20 @@ 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.ts` uses `import.meta.main`. `engines.node`
|
- **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. There is no
|
||||||
is `>=24.2`, which the user accepted for this repo
|
`engines` (user decision 2026-10-18): the `node:24-bookworm` job image
|
||||||
([[bot-agents-runtime-no-build]]).
|
decides the version ([[bot-agents-runtime-no-build]]).
|
||||||
- **Deno stays in the action's global npm install** (`action.yml`), only
|
- **Deno stays in the action's global npm install** (`action.yml`), only
|
||||||
because the agents still check Deno-based consumer repos (memona,
|
because the agents still check Deno-based consumer repos (memona,
|
||||||
memona-home). Remove it once those repos have moved; it is not a
|
memona-home). Remove it once those repos have moved; it is not a
|
||||||
dependency of this repo's TypeScript.
|
dependency of this repo's TypeScript.
|
||||||
- **No `@cunarist/eslint-plugin-*`.** The Deno setup enabled no plugin, so
|
- **No `eslint-plugin-*` packages** (`eslint-plugin-explicit-types`,
|
||||||
this repo depends on none and CI passes without them being published.
|
`eslint-plugin-import-structure`, `eslint-plugin-lit-robust`; unscoped
|
||||||
Planned (2026-10-17): once the plugins are on npm, add
|
since 2026-10-18, formerly `@cunarist/...`). The Deno setup enabled no
|
||||||
`@cunarist/eslint-plugin-modules` here for its import checks.
|
plugin, so this repo depends on none and CI passes without them being
|
||||||
|
published. Planned (2026-10-17): once the plugins are on npm, add
|
||||||
|
`eslint-plugin-import-structure` (rule prefix `import-structure/`) here for
|
||||||
|
its import checks.
|
||||||
- **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,8 +10,9 @@ 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 `post.ts`; `check.yml` prints
|
be 24.2+ for `import.meta.main` in `post.ts`. That image is the only Node
|
||||||
`node --version` to confirm the cached image's minor.
|
pin (`package.json` has no `engines`); `check.yml` prints `node --version`
|
||||||
|
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"`
|
||||||
|
|||||||
@@ -11,23 +11,25 @@ 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 "modules plugin checks only
|
subfolders" rule as the general rule, and the "import-structure plugin
|
||||||
aliased folders" scope; this repo keeps the stricter rule as a local
|
checks only aliased folders" scope; this repo keeps the stricter rule as a
|
||||||
convention, see the last section):
|
local 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 modules plugin's
|
alias's area, and no 2 aliases with the same target. The import-structure
|
||||||
`enforce-layer-order` reports it once at the top of every file 2 aliases
|
plugin's `enforce-layer-order` reports it once at the top of every file 2
|
||||||
claim. Fix by merging the aliases or moving files apart.
|
aliases 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.**
|
||||||
- **`../` is banned in every file**, owned or not (`no-parent-import`).
|
- **A `../` 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.
|
||||||
@@ -89,8 +91,8 @@ 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 modules plugin checks and which allows more). It is not
|
(which the published import-structure plugin checks and which allows more).
|
||||||
a plugin rule.
|
It is not 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"`.
|
||||||
|
|||||||
@@ -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: ["@cunarist/eslint-plugin-explicit-types", "./scripts/project-lint.ts"],
|
jsPlugins: ["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 modules plugin's `enforce-import-order` is
|
- Don't add a second import sorter; the import-structure plugin's
|
||||||
the only one, and Oxfmt's `sortImports` stays off.
|
`enforce-import-order` is 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,19 +9,21 @@ 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 modules plugin's
|
The `imports` key order can carry meaning: the import-structure 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.** `engines` has only `node` (never `engines.bun`).
|
- **Standard fields only.** App repos have no `engines` field (user decision
|
||||||
|
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 plugin ships `parse5`, `oxc-parser`, `oxc-resolver`, and
|
(the lit-robust 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,12 +1,20 @@
|
|||||||
---
|
---
|
||||||
description: Node.js 22+ with Vite+ (vp) and npm; only package.json, tsconfig.json, vite.config.ts plus a committed package-lock.json
|
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
|
||||||
---
|
---
|
||||||
|
|
||||||
## The stack
|
## The stack
|
||||||
|
|
||||||
- **Runtime:** Node.js 22 or newer. `engines` is `{ "node": ">=22" }` and
|
- **Runtime:** Node.js. App repos have no `engines` field (user decision
|
||||||
nothing else. Containers and the publish job use Node 24 (LTS); a repo may
|
2026-10-18): the container or runner image decides the version, so the
|
||||||
need more where a feature demands it (bot-agents uses Node 24.2+).
|
Containerfile base image (`node:24-alpine`) and CI's `node-version: "24"`
|
||||||
|
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.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,11 +6,17 @@ 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 the template's strict flags
|
`tsconfig.json`: no `allowJs`, no `jsx`, and exactly this strictness set (user
|
||||||
(`verbatimModuleSyntax`, `erasableSyntaxOnly`, `allowImportingTsExtensions`,
|
decision, 2026-10-18): `strict` (written out), `noUncheckedIndexedAccess`,
|
||||||
`isolatedDeclarations` in packages, `exactOptionalPropertyTypes`,
|
`noPropertyAccessFromIndexSignature`, `noImplicitReturns`, `noUnusedLocals`,
|
||||||
`noUncheckedIndexedAccess`). If a tool or framework seems to need a JS or TSX
|
`noUnusedParameters`, `noImplicitOverride`, `noFallthroughCasesInSwitch`,
|
||||||
file, stop and flag it instead of adding one.
|
`erasableSyntaxOnly`, and `allowUnreachableCode: false` plus
|
||||||
|
`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)
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ 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), which strips the types itself: there is no build step and no runtime
|
newer; `package.json` has no `engines`, the `node:24-bookworm` image pins it),
|
||||||
|
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. Checks use Vite+: `vp check` formats, lints, and
|
`tsconfig.json` enforces. Its strictness set is fixed and leaves
|
||||||
type-checks, and `vp test` runs Vitest. Unit tests sit next to the code as
|
`exactOptionalPropertyTypes` off
|
||||||
`src/*.test.ts`; repository-level tests live in `tests/`.
|
([.agents/memory/ts-only-files-and-imports.md](.agents/memory/ts-only-files-and-imports.md)).
|
||||||
|
Checks use Vite+: `vp check` formats, lints, and type-checks, and `vp test`
|
||||||
|
runs Vitest. Unit tests sit next to the code as `src/*.test.ts`;
|
||||||
|
repository-level tests live in `tests/`.
|
||||||
|
|
||||||
A `#` alias in `package.json` `imports` may point anywhere and owns its
|
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
|
||||||
@@ -25,16 +29,17 @@ 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. `../`
|
Inside an area, relative imports of siblings and subfolders are free. A `../`
|
||||||
is banned in every file, owned or not
|
import is banned in every file, owned or not
|
||||||
(`import/no-relative-parent-imports`). Files no alias owns need no `index.ts`
|
(`import/no-relative-parent-imports`); runtime file URLs such as
|
||||||
and no alias, and import `./` siblings and subfolders freely. The one alias
|
`new URL("../prompt.md", import.meta.url)` are paths, not imports, and stay.
|
||||||
is `#testing` (`./src/testing/index.ts`): every test imports it by alias, and
|
Files no alias owns need no `index.ts` and no alias, and import `./` siblings
|
||||||
`tests/` reaches `src/` only through it. Local convention, stricter than the
|
and subfolders freely. The one alias is `#testing` (`./src/testing/index.ts`):
|
||||||
general rule above: in this repo, aliases point only at a direct subfolder of
|
tests that use its helpers import it by alias, and `tests/` imports `src/`
|
||||||
`src/` (`"#helpers": "./src/helpers/index.ts"`), and code outside `src/` uses
|
only through it. Local convention, stricter than the general rule above: in
|
||||||
`./` relative imports. `scripts/` is flat: one file per
|
this repo, aliases point only at a direct subfolder of `src/`
|
||||||
tool, no subfolders and no `index.ts`, imported directly by file path
|
(`"#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
|
||||||
|
|||||||
Generated
-3
@@ -11,9 +11,6 @@
|
|||||||
"@typescript-eslint/types": "^8.71.2",
|
"@typescript-eslint/types": "^8.71.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": {
|
||||||
|
|||||||
@@ -12,8 +12,5 @@
|
|||||||
"@typescript-eslint/types": "^8.71.2",
|
"@typescript-eslint/types": "^8.71.2",
|
||||||
"vite-plus": "^1.2.0",
|
"vite-plus": "^1.2.0",
|
||||||
"yaml": "^2.9.1"
|
"yaml": "^2.9.1"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=24.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -75,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] !== "",
|
||||||
|
|||||||
+14
-14
@@ -70,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) => {
|
||||||
@@ -84,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"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -110,8 +110,8 @@ export function agentEnvironment(
|
|||||||
token?: string,
|
token?: string,
|
||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
const result = sanitizedEnvironment(environment);
|
const result = sanitizedEnvironment(environment);
|
||||||
result.HOME = profile.home;
|
result["HOME"] = profile.home;
|
||||||
if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`;
|
if (profile.type === "codex") result["CODEX_HOME"] = `${profile.home}/.codex`;
|
||||||
if (token !== undefined && token !== "") {
|
if (token !== undefined && token !== "") {
|
||||||
const tokenName = {
|
const tokenName = {
|
||||||
claude: "CLAUDE_CODE_OAUTH_TOKEN",
|
claude: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||||
|
|||||||
+12
-13
@@ -85,7 +85,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 ? null : JSON.stringify(body),
|
body: body === undefined ? undefined : 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()}`);
|
||||||
@@ -104,11 +104,11 @@ 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 | undefined> {
|
async function readProfileToken(profile: RuntimeProfile): Promise<string | undefined> {
|
||||||
@@ -151,7 +151,7 @@ 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`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,24 +228,23 @@ 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 = {
|
||||||
...(typeof isError === "boolean" && { is_error: isError }),
|
is_error: typeof isError === "boolean" ? isError : undefined,
|
||||||
...(typeof answer === "string" && { result: answer }),
|
result: typeof answer === "string" ? answer : undefined,
|
||||||
subtype: String(subtype),
|
subtype: String(subtype),
|
||||||
...((typeof apiStatus === "number" || apiStatus === null) && {
|
api_error_status:
|
||||||
api_error_status: apiStatus,
|
typeof apiStatus === "number" || apiStatus === null ? apiStatus : undefined,
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -101,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"])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ 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}.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`);
|
||||||
});
|
});
|
||||||
|
|||||||
+21
-21
@@ -48,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");
|
||||||
@@ -60,16 +60,16 @@ test("the action exposes only the master key and scopes it to preparation", asyn
|
|||||||
const agent = findStep(steps, "id", "agent");
|
const 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"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,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);
|
||||||
@@ -136,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.
|
||||||
@@ -170,8 +170,8 @@ test("profile preparation does not receive provider credentials", async () => {
|
|||||||
|
|
||||||
test("central profiles fall back from Codex to Claude to OpenCode", async () => {
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user