Group the sources into aliased modules and lint with the published plugins #23

Merged
temeddix merged 2 commits from group-modules into main 2026-10-18 15:00:17 +00:00
18 changed files with 53 additions and 42 deletions
Showing only changes of commit 6c4e4bf67d - Show all commits
+1 -1
View File
@@ -15,7 +15,7 @@ when the migration PR was approved:
([[lint-overrides-policy]]).
Size and complexity limits match Deno, which had none here, so every Oxlint
limit rule is `"off"` ([[lint-config-shape]]). `run.ts` and `post.ts` keep
limit rule is `"off"` ([[lint-config-shape]]). `src/run/index.ts` and `src/post/index.ts` keep
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
stay sequential. Add no other opt-out without the user.
@@ -15,7 +15,7 @@ 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`. There is no
- **Node 24.2+**, not 22: `src/post/index.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]]).
- **Published lint plugins** (2026-10-18, after they reached npm):
@@ -10,7 +10,7 @@ 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`. That image is the only Node
be 24.2+ for `import.meta.main` in `src/post/index.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
+3 -3
View File
@@ -2,20 +2,20 @@
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 `prepare.ts`
Some tests exercise real UID isolation, process cleanup, and the prepare phase's
credential cleanup. They need root and are written as
`test.skipIf(!IS_ROOT)(...)` (`IS_ROOT` from `#testing`), so they show
as skipped instead of silently passing.
- As a normal user `vp test` passes 61 and skips 5. In CI (self-hosted
runner, root) all 66 run.
- When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare.ts`,
- When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare/index.ts`,
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
proves nothing about those paths.
- The migration also ran an end-to-end smoke as root with fake
`codex`/`claude`/`npx` binaries and a fake Gitea through
`protect-source.sh` -> `prepare.ts` -> `run.ts` -> `post.ts`; repeat that
`protect-source.sh` -> `prepare/index.ts` -> `run/index.ts` -> `post/index.ts`; repeat that
for changes to the phase flow ([[bot-agents-runtime-no-build]]).
- `src/testing/index.ts` holds the shared test helpers (user IDs, temp dirs,
`runCommand`) behind the `#testing` alias. Every test imports it as
+1 -1
View File
@@ -97,7 +97,7 @@ 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"`.
No alias to a single file, a deeper folder, or anything outside `src/`.
Here the aliases are `#agents`, `#processes`, `#runtime`, `#profiles`,
Here the aliases are `#prepare`, `#run`, `#post`, `#agents`, `#processes`, `#runtime`, `#profiles`,
`#common`, and `#testing`, in that layer order.
- Code outside `src/` (`scripts/`, `tests/`, `vite.config.ts`) gets no alias
of its own and imports its own files by `./` relative paths. It reaches
+1 -1
View File
@@ -14,7 +14,7 @@ description: Node.js (no engines in apps; images pin 24) with Vite+ (vp) and npm
`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`).
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
runtime branches. Oxlint's `RuleTester` refuses to run under Bun anyway, and
`vp` brings its own Node, the way uv brings its own Python.
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
steps:
- uses: actions/checkout@v4
# The action runs on this same image; src/post.ts needs Node 24.2+.
# The action runs on this same image; src/post/index.ts needs Node 24.2+.
- run: node --version
- run: npm ci
+6 -4
View File
@@ -34,13 +34,15 @@ 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 aliases, in layer order, are `#agents` (Codex,
and subfolders freely. Every top-level entry of `src/` is an aliased module
folder. The aliases, in layer order, are the three phases `#prepare`, `#run`,
and `#post`, then `#agents` (Codex,
Claude Code and OpenCode specifics), `#processes` (child processes and the
isolated profile users), `#runtime` (the prepared runtime and its
credentials), `#profiles`, `#common` (error and JSON checks), and `#testing`
(test helpers), each a `src/<name>/index.ts` barrel. The phase entry points
`src/prepare.ts`, `src/run.ts`, and `src/post.ts` stay at the top of `src/`,
unowned, because `action.yml` runs them by path. Tests that use the test
(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 `./`
+3 -3
View File
@@ -35,7 +35,7 @@ runs:
- name: Prepare credentials
id: prepare
shell: bash
run: node "${{ steps.source.outputs.path }}/src/prepare.ts"
run: node "${{ steps.source.outputs.path }}/src/prepare/index.ts"
env:
ACTION_PATH: ${{ steps.source.outputs.path }}
BOT_AGENTS_MASTER_KEY: ${{ inputs.master-key }}
@@ -43,7 +43,7 @@ runs:
- name: Run bot
id: agent
shell: bash
run: node "${{ steps.source.outputs.path }}/src/run.ts"
run: node "${{ steps.source.outputs.path }}/src/run/index.ts"
env:
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
GITEA_TOKEN: ${{ gitea.token }}
@@ -56,7 +56,7 @@ runs:
id: post
if: always() && steps.prepare.outputs.manifest != ''
shell: bash
run: node "${{ steps.source.outputs.path }}/src/post.ts"
run: node "${{ steps.source.outputs.path }}/src/post/index.ts"
env:
BOT_AGENTS_MANIFEST: ${{ steps.prepare.outputs.manifest }}
GITEA_API_URL: ${{ gitea.api_url }}
+3
View File
@@ -4,6 +4,9 @@
"description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.",
"type": "module",
"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",
+22
View File
@@ -0,0 +1,22 @@
// 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"),
});
}
-16
View File
@@ -1,7 +1,6 @@
import type { Stats } from "node:fs";
import { lstat, readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import { isNotFound, isObject } from "#common";
import { failureFooter, type FailureReason, type ProfileFailure, withProfile } from "#profiles";
@@ -223,18 +222,3 @@ 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 -1
View File
@@ -184,7 +184,7 @@ async function renderPrompt(profile: RuntimeProfile): Promise<RenderedPrompt> {
REVIEW_PATH: reviewPath,
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 {
prompt: template.replaceAll(
/\$\{(\w+)\}/gu,
+1 -1
View File
@@ -81,7 +81,7 @@ test.skipIf(!IS_ROOT)("removes prepared credentials when output publication fail
);
const result = spawnSync(
process.execPath,
[fileURLToPath(new URL("../prepare.ts", import.meta.url))],
[fileURLToPath(new URL("../prepare/index.ts", import.meta.url))],
{
env: {
ACTION_PATH: action,
+2 -2
View File
@@ -4,7 +4,7 @@ import { expect, test } from "vite-plus/test";
import { runCommand } from "#testing";
// Each phase runs as `node src/<phase>.ts` with no build step. Node loads the
// Each phase runs as `node src/<phase>/index.ts` with no build step. Node loads the
// whole import graph, stripping types, before the phase reads its first
// variable, so failing on that variable proves every module loads.
const PHASES: [phase: string, firstVariable: string][] = [
@@ -14,7 +14,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 path = fileURLToPath(new URL(`../src/${phase}/index.ts`, import.meta.url));
const result = await runCommand(process.execPath, [path], { PATH: process.env["PATH"] ?? "" });
expect(result.success).toBe(false);
expect(result.stderr).toContain(`${firstVariable} is not set`);
+6 -6
View File
@@ -86,9 +86,9 @@ test("the source protection script stages immutable action files", async () => {
const path = (await readFile(outputFile, "utf8")).trim().slice(5);
const files = [
"src/prepare.ts",
"src/run.ts",
"src/post.ts",
"src/prepare/index.ts",
"src/run/index.ts",
"src/post/index.ts",
"src/runtime/index.ts",
"src/agents/auth.ts",
"package.json",
@@ -106,7 +106,7 @@ test("the source protection script stages immutable action files", async () => {
});
test("module-relative runtime assets exist", async () => {
const module = new URL("../src/run.ts", import.meta.url);
const module = new URL("../src/run/index.ts", import.meta.url);
const source = await readFile(module, "utf8");
const assets = [...source.matchAll(/new URL\("([^"]+)", import\.meta\.url\)/gu)].map(
([, asset = ""]) => new URL(asset, module),
@@ -149,7 +149,7 @@ test("CI checks every phase and the root isolation suite", async () => {
});
test("uses the standard Skills installer without custom discovery", async () => {
const runner = await readFile("src/run.ts", "utf8");
const runner = await readFile("src/run/index.ts", "utf8");
assert(runner.includes("skills add obra/superpowers"));
const removed = await stat("superpowers.ts").then(
() => false,
@@ -169,7 +169,7 @@ test("does not install Deno", async () => {
});
test("profile preparation does not receive provider credentials", async () => {
const runner = await readFile("src/run.ts", "utf8");
const runner = await readFile("src/run/index.ts", "utf8");
assert(runner.includes("await prepareProfile(profile);"));
expect(runner.includes("prepareProfile(profile, token)")).toBe(false);
});