7 Commits

Author SHA1 Message Date
Danny Kim 0920a4e91b Use the unscoped eslint-plugin names in the notes
Check / check (pull_request) Successful in 38s
2026-10-18 22:18:55 +09:00
Danny Kim 627458fb5f Limit the parent-path ban in the notes to imports
Check / check (pull_request) Successful in 1m24s
2026-10-18 03:50:14 +09:00
Danny Kim 50df193a8c Limit the #testing note to tests that use its helpers
Check / check (pull_request) Successful in 58s
2026-10-18 03:40:16 +09:00
Danny Kim 5b568f9ea6 Turn off unreachable code and unused labels in tsconfig
Check / check (pull_request) Successful in 39s
2026-10-18 03:18:25 +09:00
Danny Kim 60c63dee98 Leave the plugin engines floor to the plugins in the toolchain note
Check / check (pull_request) Successful in 3m12s
2026-10-18 03:11:31 +09:00
Danny Kim 0a31dbb820 Drop engines and let the job image pin Node
Check / check (pull_request) Successful in 3m6s
2026-10-18 03:09:55 +09:00
Danny Kim 6ad859be62 Set the agreed tsconfig strictness flags and drop exactOptionalPropertyTypes
Check / check (pull_request) Successful in 44s
2026-10-18 03:05:15 +09:00
18 changed files with 134 additions and 114 deletions
@@ -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
@@ -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
dependencies (`@eslint/core` and the other devDependencies serve only the
tooling).
- **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. `engines.node`
is `>=24.2`, which the user accepted for this repo
([[bot-agents-runtime-no-build]]).
- **Node 24.2+**, not 22: `src/post.ts` uses `import.meta.main`. There is no
`engines` (user decision 2026-10-18): the `node:24-bookworm` job image
decides the version ([[bot-agents-runtime-no-build]]).
- **Deno stays in the action's global npm install** (`action.yml`), only
because the agents still check Deno-based consumer repos (memona,
memona-home). Remove it once those repos have moved; it is not a
dependency of this repo's TypeScript.
- **No `@cunarist/eslint-plugin-*`.** The Deno setup enabled no 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
`@cunarist/eslint-plugin-modules` here for its import checks.
- **No `eslint-plugin-*` packages** (`eslint-plugin-explicit-types`,
`eslint-plugin-import-structure`, `eslint-plugin-lit-robust`; unscoped
since 2026-10-18, formerly `@cunarist/...`). The Deno setup enabled no
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
comment responses, the runtime manifest, and the outcome's profile type are
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.
- **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
`node --version` to confirm the cached image's minor.
be 24.2+ for `import.meta.main` in `post.ts`. That image is the only Node
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
into `$RUNNER_TEMP` so an agent editing the checkout can't swap a later
phase. It copies exactly `src/*.ts`, `package.json` (`"type": "module"`
+11 -9
View File
@@ -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
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
aliased folders" scope; this repo keeps the stricter rule as a local
convention, see the last section):
subfolders" rule as the general rule, and the "import-structure plugin
checks only aliased folders" scope; this repo keeps the stricter rule as a
local convention, see the last section):
- **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
any other file owns only that file.
- **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
`enforce-layer-order` reports it once at the top of every file 2 aliases
claim. Fix by merging the aliases or moving files apart.
alias's area, and no 2 aliases with the same target. The import-structure
plugin's `enforce-layer-order` reports it once at the top of every file 2
aliases claim. Fix by merging the aliases or moving files apart.
- **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
area (`./a/one.ts`, `no-relative-bypass`; `./b/f.ts` to a file alias,
`prefer-alias-import`, fixed to the alias) are bypasses.
- **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.
- **Files no alias owns** need no `index.ts` and no alias, and import `./`
siblings and subfolders freely.
@@ -89,8 +91,8 @@ Relative imports are written with the `.ts` extension.
## Local convention: aliases target direct `src/` subfolders
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
a plugin rule.
(which the published import-structure plugin checks and which allows more).
It is not a plugin rule.
- Every `package.json` `imports` alias points only at the `index.ts` of a
direct subfolder of `src/`, e.g. `"#helpers": "./src/helpers/index.ts"`.
+3 -3
View File
@@ -8,7 +8,7 @@ turn on whole categories, then list only what parity or a real need adds.
```ts
lint: {
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" },
rules: { ...explicitTypes.configs.concrete.rules, ...projectLint.configs.project.rules },
ignorePatterns: ["src/generated/**"], // generated files only, see below
@@ -40,8 +40,8 @@ lint: {
Hand-written code is never ignored.
- **No lint disable comments**: [[lint-no-disable-comments]].
- **Parity:** [[lint-parity-never-shrinks]]. **Naming:** [[naming-id-match]].
- Don't add a second import sorter; the modules plugin's `enforce-import-order` is
the only one, and Oxfmt's `sortImports` stays off.
- Don't add a second import sorter; the import-structure plugin's
`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
arrays, fixed as `ReadonlySet` + `.has()` rather than by turning a rule off.
+5 -3
View File
@@ -9,19 +9,21 @@ Oxfmt's standard `package.json` sorting is accepted: there is no
replaces the earlier hand-written order (metadata, `scripts`, `exports`,
`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.
## 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
reads nothing else and no other config file is allowed.
- **No `packageManager`.** npm is detected from `package-lock.json`.
- **No `peerDependencies`, ever** (user preference, 2026-10-14). If something
is needed at runtime, it goes in `dependencies`.
- **`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).
- **`devDependencies`** = everything else: `vite-plus`, `eslint`,
`@typescript-eslint/parser`, `eslint-doc-generator`, `esbuild`,
+12 -4
View File
@@ -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
- **Runtime:** Node.js 22 or newer. `engines` is `{ "node": ">=22" }` and
nothing else. Containers and the publish job use Node 24 (LTS); a repo may
need more where a feature demands it (bot-agents uses Node 24.2+).
- **Runtime:** Node.js. App repos have no `engines` field (user decision
2026-10-18): the container or runner image decides the version, so the
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
runtime branches. Oxlint's `RuleTester` refuses to run under Bun anyway, and
`vp` brings its own Node, the way uv brings its own Python.
+11 -5
View File
@@ -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
plain `.ts`. Build output in a gitignored folder is the only JS.
`tsconfig.json`: no `allowJs`, no `jsx`, and the template's strict flags
(`verbatimModuleSyntax`, `erasableSyntaxOnly`, `allowImportingTsExtensions`,
`isolatedDeclarations` in packages, `exactOptionalPropertyTypes`,
`noUncheckedIndexedAccess`). If a tool or framework seems to need a JS or TSX
file, stop and flag it instead of adding one.
`tsconfig.json`: no `allowJs`, no `jsx`, and exactly this strictness set (user
decision, 2026-10-18): `strict` (written out), `noUncheckedIndexedAccess`,
`noPropertyAccessFromIndexSignature`, `noImplicitReturns`, `noUnusedLocals`,
`noUnusedParameters`, `noImplicitOverride`, `noFallthroughCasesInSwitch`,
`erasableSyntaxOnly`, 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)
+19 -14
View File
@@ -13,11 +13,15 @@ explicit permission.
# Runtime and checks
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
`tsconfig.json` enforces. Checks use Vite+: `vp check` formats, lints, and
type-checks, and `vp test` runs Vitest. Unit tests sit next to the code as
`src/*.test.ts`; repository-level tests live in `tests/`.
`tsconfig.json` enforces. Its strictness set is fixed and leaves
`exactOptionalPropertyTypes` off
([.agents/memory/ts-only-files-and-imports.md](.agents/memory/ts-only-files-and-imports.md)).
Checks use Vite+: `vp check` formats, lints, and type-checks, and `vp test`
runs Vitest. Unit tests sit next to the code as `src/*.test.ts`;
repository-level tests live in `tests/`.
A `#` alias in `package.json` `imports` may point anywhere and owns its
target: an alias to a folder's `index.ts` owns that folder and everything
@@ -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
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.
Inside an area, relative imports of siblings and subfolders are free. `../`
is banned in every file, owned or not
(`import/no-relative-parent-imports`). Files no alias owns need no `index.ts`
and no alias, and import `./` siblings and subfolders freely. The one alias
is `#testing` (`./src/testing/index.ts`): every test imports it by alias, and
`tests/` reaches `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
Inside an area, relative imports of siblings and subfolders are free. A `../`
import is banned in every file, owned or not
(`import/no-relative-parent-imports`); runtime file URLs such as
`new URL("../prompt.md", import.meta.url)` are paths, not imports, and stay.
Files no alias owns need no `index.ts` and no alias, and import `./` siblings
and subfolders freely. The one alias is `#testing` (`./src/testing/index.ts`):
tests that use its helpers import it by alias, 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)).
The only config files are `package.json`, `tsconfig.json`, and
-3
View File
@@ -11,9 +11,6 @@
"@typescript-eslint/types": "^8.71.2",
"vite-plus": "^1.2.0",
"yaml": "^2.9.1"
},
"engines": {
"node": ">=24.2"
}
},
"node_modules/@babel/code-frame": {
-3
View File
@@ -12,8 +12,5 @@
"@typescript-eslint/types": "^8.71.2",
"vite-plus": "^1.2.0",
"yaml": "^2.9.1"
},
"engines": {
"node": ">=24.2"
}
}
+3 -3
View File
@@ -75,11 +75,11 @@ async function readCodexAuth(path: string): Promise<string> {
} catch {
throw new Error("invalid Codex login");
}
const tokens = isObject(value) ? value.tokens : undefined;
const tokens = isObject(value) ? value["tokens"] : undefined;
if (
!isObject(value) ||
typeof value.auth_mode !== "string" ||
value.auth_mode === "" ||
typeof value["auth_mode"] !== "string" ||
value["auth_mode"] === "" ||
!isObject(tokens) ||
!["access_token", "refresh_token", "id_token", "account_id"].every(
(name) => typeof tokens[name] === "string" && tokens[name] !== "",
+14 -14
View File
@@ -70,9 +70,9 @@ async function readOutcome(path: string): Promise<RuntimeOutcome> {
throw new Error("invalid agent outcome");
}
const outcome = value;
if (outcome.ok === false) {
if (outcome["ok"] === false) {
// Only known labels reach the comment, never raw agent output.
const failures: unknown[] = Array.isArray(outcome.failures) ? outcome.failures : [];
const failures: unknown[] = Array.isArray(outcome["failures"]) ? outcome["failures"] : [];
return {
ok: false,
failures: failures.flatMap((entry: unknown) => {
@@ -84,26 +84,26 @@ async function readOutcome(path: string): Promise<RuntimeOutcome> {
};
}
if (
outcome.ok !== true ||
typeof outcome.body !== "string" ||
typeof outcome.reviewPath !== "string" ||
typeof outcome.anchorsPath !== "string" ||
!isObject(outcome.profile)
outcome["ok"] !== true ||
typeof outcome["body"] !== "string" ||
typeof outcome["reviewPath"] !== "string" ||
typeof outcome["anchorsPath"] !== "string" ||
!isObject(outcome["profile"])
) {
throw new Error("invalid agent outcome");
}
const profile = outcome.profile;
if (!isProfileType(profile.type) || typeof profile.model !== "string") {
const profile = outcome["profile"];
if (!isProfileType(profile["type"]) || typeof profile["model"] !== "string") {
throw new Error("invalid agent outcome");
}
return {
ok: true,
body: outcome.body,
reviewPath: outcome.reviewPath,
anchorsPath: outcome.anchorsPath,
body: outcome["body"],
reviewPath: outcome["reviewPath"],
anchorsPath: outcome["anchorsPath"],
profile: {
type: profile.type,
model: profile.model,
type: profile["type"],
model: profile["model"],
},
};
}
+2 -2
View File
@@ -110,8 +110,8 @@ export function agentEnvironment(
token?: string,
): Record<string, string> {
const result = sanitizedEnvironment(environment);
result.HOME = profile.home;
if (profile.type === "codex") result.CODEX_HOME = `${profile.home}/.codex`;
result["HOME"] = profile.home;
if (profile.type === "codex") result["CODEX_HOME"] = `${profile.home}/.codex`;
if (token !== undefined && token !== "") {
const tokenName = {
claude: "CLAUDE_CODE_OAUTH_TOKEN",
+12 -13
View File
@@ -85,7 +85,7 @@ async function gitea(path: string, body?: unknown): Promise<Response> {
Authorization: `token ${AUTHOR_TOKEN}`,
"Content-Type": "application/json",
},
body: body === undefined ? null : JSON.stringify(body),
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`${path}: ${response.status} ${await response.text()}`);
@@ -104,11 +104,11 @@ async function runRoot(command: string, args: string[]): Promise<void> {
async function configureGitAuthor(): Promise<void> {
const user: unknown = await (await gitea("user")).json();
if (!isObject(user) || typeof user.login !== "string" || typeof user.email !== "string") {
if (!isObject(user) || typeof user["login"] !== "string" || typeof user["email"] !== "string") {
throw new Error("user: unexpected Gitea response");
}
await runRoot("git", ["config", "user.name", user.login]);
await runRoot("git", ["config", "user.email", user.email]);
await runRoot("git", ["config", "user.name", user["login"]]);
await runRoot("git", ["config", "user.email", user["email"]]);
}
async function readProfileToken(profile: RuntimeProfile): Promise<string | undefined> {
@@ -151,7 +151,7 @@ async function prepareProfile(profile: RuntimeProfile): Promise<void> {
// One issue comment as the prompt quotes it.
function formatComment(comment: unknown): string {
const { user, created_at: createdAt, body } = isObject(comment) ? comment : {};
const login = isObject(user) ? user.login : undefined;
const login = isObject(user) ? user["login"] : undefined;
return `## ${String(login)} at ${String(createdAt)}\n\n${String(body)}\n`;
}
@@ -228,24 +228,23 @@ async function runClaude(profile: RuntimeProfile, token: string, prompt: string)
if (line !== "") {
const event: unknown = JSON.parse(line);
if (isObject(event)) {
const content = isObject(event.message) ? event.message.content : undefined;
const content = isObject(event["message"]) ? event["message"]["content"] : undefined;
for (const part of Array.isArray(content) ? content : []) {
if (isObject(part)) {
const text = part.thinking ?? part.text ?? part.name;
const text = part["thinking"] ?? part["text"] ?? part["name"];
if (typeof text === "string" && text !== "") {
console.log(text);
}
}
}
if (event.type === "result") {
if (event["type"] === "result") {
const { is_error: isError, result: answer, subtype, api_error_status: apiStatus } = event;
result = {
...(typeof isError === "boolean" && { is_error: isError }),
...(typeof answer === "string" && { result: answer }),
is_error: typeof isError === "boolean" ? isError : undefined,
result: typeof answer === "string" ? answer : undefined,
subtype: String(subtype),
...((typeof apiStatus === "number" || apiStatus === null) && {
api_error_status: apiStatus,
}),
api_error_status:
typeof apiStatus === "number" || apiStatus === null ? apiStatus : undefined,
};
}
}
+5 -5
View File
@@ -101,11 +101,11 @@ export async function prepareRuntime(
function isRuntimeManifest(value: unknown): value is RuntimeManifest {
return (
isMapping(value) &&
typeof value.runtimeDir === "string" &&
typeof value.manifestPath === "string" &&
typeof value.reviewerFile === "string" &&
typeof value.outcomeFile === "string" &&
Array.isArray(value.profiles)
typeof value["runtimeDir"] === "string" &&
typeof value["manifestPath"] === "string" &&
typeof value["reviewerFile"] === "string" &&
typeof value["outcomeFile"] === "string" &&
Array.isArray(value["profiles"])
);
}
+1 -1
View File
@@ -16,7 +16,7 @@ const PHASES: [phase: string, firstVariable: string][] = [
test.each(PHASES)("Node loads the %s phase", async (phase, firstVariable) => {
const path = fileURLToPath(new URL(`../src/${phase}.ts`, import.meta.url));
const result = await runCommand(process.execPath, [path], { PATH: process.env.PATH ?? "" });
const result = await runCommand(process.execPath, [path], { PATH: process.env["PATH"] ?? "" });
expect(result.success).toBe(false);
expect(result.stderr).toContain(`${firstVariable} is not set`);
});
+21 -21
View File
@@ -48,11 +48,11 @@ test("all committed YAML parses", async () => {
test("the action exposes only the master key and scopes it to preparation", async () => {
const action = await yaml("action.yml");
expect(Object.keys(mapping(action.inputs, "inputs"))).toStrictEqual(["master-key"]);
const steps = sequence(mapping(action.runs, "runs").steps, "runs.steps");
expect(Object.keys(mapping(action["inputs"], "inputs"))).toStrictEqual(["master-key"]);
const steps = sequence(mapping(action["runs"], "runs")["steps"], "runs.steps");
for (const step of steps) {
if (typeof step.run === "string") {
expect(step.run.includes("\n"), String(step.name)).toBe(false);
if (typeof step["run"] === "string") {
expect(step["run"].includes("\n"), String(step["name"])).toBe(false);
}
}
const checkout = findStep(steps, "uses", "actions/checkout@v4");
@@ -60,16 +60,16 @@ test("the action exposes only the master key and scopes it to preparation", asyn
const agent = findStep(steps, "id", "agent");
const post = findStep(steps, "id", "post");
findStep(steps, "id", "source");
expect(mapping(checkout.with, "checkout.with").token).toBe("${{ gitea.token }}");
expect(mapping(prepare.env, "prepare.env").BOT_AGENTS_MASTER_KEY).toBe(
expect(mapping(checkout["with"], "checkout.with")["token"]).toBe("${{ gitea.token }}");
expect(mapping(prepare["env"], "prepare.env")["BOT_AGENTS_MASTER_KEY"]).toBe(
"${{ inputs.master-key }}",
);
expect(JSON.stringify(agent).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(JSON.stringify(post).includes("BOT_AGENTS_MASTER_KEY")).toBe(false);
expect(mapping(agent.env, "agent.env").GITEA_TOKEN).toBe("${{ gitea.token }}");
assert(String(post.if).includes("always()"));
expect(mapping(agent["env"], "agent.env")["GITEA_TOKEN"]).toBe("${{ gitea.token }}");
assert(String(post["if"]).includes("always()"));
for (const step of [prepare, agent, post]) {
assert(String(step.run).includes("steps.source.outputs.path"));
assert(String(step["run"]).includes("steps.source.outputs.path"));
}
});
@@ -118,17 +118,17 @@ test("module-relative runtime assets exist", async () => {
test("the repository uses its trusted main action", async () => {
const workflow = await yaml(".gitea/workflows/agents.yml");
const job = mapping(mapping(workflow.jobs, "jobs").agent, "jobs.agent");
const permissions = mapping(job.permissions, "jobs.agent.permissions");
const job = mapping(mapping(workflow["jobs"], "jobs")["agent"], "jobs.agent");
const permissions = mapping(job["permissions"], "jobs.agent.permissions");
expect(permissions).toStrictEqual({
code: "write",
issues: "write",
"pull-requests": "write",
});
const steps = sequence(job.steps, "jobs.agent.steps");
expect(steps.findIndex((step) => step.uses === "./")).toStrictEqual(-1);
const steps = sequence(job["steps"], "jobs.agent.steps");
expect(steps.findIndex((step) => step["uses"] === "./")).toStrictEqual(-1);
const trusted = findStep(steps, "uses", "https://git.capsulizers.com/commons/bot-agents@main");
expect(mapping(trusted.with, "trusted.with")["master-key"]).toBe(
expect(mapping(trusted["with"], "trusted.with")["master-key"]).toBe(
"${{ secrets.BOT_AGENTS_MASTER_KEY }}",
);
expect(JSON.stringify(workflow).includes("gitea.actor != 'bot'")).toBe(true);
@@ -136,9 +136,9 @@ test("the repository uses its trusted main action", async () => {
test("CI checks every phase and the root isolation suite", async () => {
const workflow = await yaml(".gitea/workflows/check.yml");
const job = mapping(mapping(workflow.jobs, "jobs").check, "jobs.check");
const commands = sequence(job.steps, "jobs.check.steps")
.map((step) => (typeof step.run === "string" ? step.run : ""))
const job = mapping(mapping(workflow["jobs"], "jobs")["check"], "jobs.check");
const commands = sequence(job["steps"], "jobs.check.steps")
.map((step) => (typeof step["run"] === "string" ? step["run"] : ""))
.join("\n");
// `vp check` type-checks every phase, and `vp test` runs each of them under
// Node as well as the root-only isolation tests.
@@ -170,8 +170,8 @@ test("profile preparation does not receive provider credentials", async () => {
test("central profiles fall back from Codex to Claude to OpenCode", async () => {
const credentials: unknown = JSON.parse(await readFile("profiles.json", "utf8"));
const profiles = sequence(mapping(credentials, "profiles.json").profiles, "profiles");
expect(profiles.map((profile) => profile.type)).toStrictEqual(["codex", "claude", "opencode"]);
expect(profiles[2]?.model).toBe("openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2]?.token).startsWith("v1:"));
const profiles = sequence(mapping(credentials, "profiles.json")["profiles"], "profiles");
expect(profiles.map((profile) => profile["type"])).toStrictEqual(["codex", "claude", "opencode"]);
expect(profiles[2]?.["model"]).toBe("openrouter/qwen/qwen3.8-27b");
assert(String(profiles[2]?.["token"]).startsWith("v1:"));
});
+1 -1
View File
@@ -9,8 +9,8 @@
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,