Route tests through a #testing alias, ban ../ imports, and document the alias ownership rule
Check / check (pull_request) Successful in 46s

This commit is contained in:
Danny Kim
2026-10-17 14:45:52 +09:00
parent 15fe481181
commit bfa8269801
15 changed files with 103 additions and 65 deletions
+4 -1
View File
@@ -17,6 +17,9 @@ as skipped instead of silently passing.
`codex`/`claude`/`npx` binaries and a fake Gitea through `codex`/`claude`/`npx` binaries and a fake Gitea through
`protect-source.sh` -> `prepare.ts` -> `run.ts` -> `post.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.ts` holds the shared test helpers; tests follow - `src/testing.ts` holds the shared test helpers and is the `#testing` file
alias. Every test imports it as `#testing`, never `./testing.ts`, and it
re-exports the source helpers `tests/` needs, since `tests/` may not use
`../`. Tests follow
[[tests-layout-vitest]] (unit tests in `src/*.test.ts`, repo-level ones in [[tests-layout-vitest]] (unit tests in `src/*.test.ts`, repo-level ones in
`tests/`). `tests/`).
+52 -42
View File
@@ -1,75 +1,85 @@
--- ---
description: Shared code comes through # aliases in package.json imports, each src/ subfolder's entry is index.ts, barrels keep imports few; never ../ out of a directory description: An alias owns its target (folder index.ts = folder and below, any other file = that file); no overlap; outside an area only the exact alias reaches it; ../ is banned in every file; no folder names in the rule
--- ---
## `#` aliases ## `#` aliases own their targets
Shared code is imported through Node subpath imports declared in Shared code is imported through Node subpath imports declared in
`package.json` `imports`, never by a relative path that climbs out of a `package.json` `imports`. Node, TypeScript, Vite, Vite+'s config bundler, and
directory. Oxlint's plugin loader all resolve them natively, so `.ts` source runs
unbuilt. `vp pack` inlines them, so they never reach package consumers.
**Targets (user rule, 2026-10-16):** an alias may point ONLY at a direct One rule for every file, never tied to folder or file names (user rule
subfolder of `src/`: its `index.ts` (`"#helpers": "./src/helpers/index.ts"`) 2026-10-17; it replaces the earlier "aliases only point at direct `src/`
or a `#x/*` pattern inside that one folder. Never alias anything outside subfolders" rule and the "import-check checks only aliased folders" scope):
`src/` (`scripts/`, root files), `src/` itself (no `#plugin` for
`./src/index.ts`), or a deeper folder (`./src/a/b/`). Code that would need - **An alias may point anywhere** and owns its target. An alias to a
such an alias uses a relative import instead; `scripts/` and folder's `index.ts` owns that folder and everything below it; an alias to
`vite.config.ts` import each other relatively. Layer rules any other file owns only that file.
(`enforce-layer-order`) must still pass, so reorder entries if needed. - **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. import-check'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`).
Anything above a file needs an alias.
- **Files no alias owns** need no `index.ts` and no alias, and import `./`
siblings and subfolders freely.
- Unchanged: no `/*` subpath patterns, the key order of `imports` is the
layer order `enforce-layer-order` checks (add a new alias at the right
layer), and entry files are `index.ts` only.
```jsonc ```jsonc
"imports": { "imports": {
"#helpers": "./src/helpers/index.ts", "#a": "./a/index.ts", // owns a/**
"#components/*": "./src/components/*" "#f": "./b/f.ts", // owns only b/f.ts
"#a-sub": "./a/sub/index.ts", // no: inside #a
"#a2": "./a/index.ts" // no: same target as #a
} }
``` ```
```ts ```ts
import { typeNameTail } from "#helpers"; // yes // in main.ts, which no alias owns
import { typeNameTail } from "../helpers/ast.ts"; // no: import/no-relative-parent-imports import { a } from "#a"; // yes
import { one } from "#a/one.ts"; // no: deeper alias specifier
import { one } from "./a/one.ts"; // no: relative path into #a
import { f } from "#f"; // yes
import { f } from "./b/f.ts"; // no: relative path to a file alias
import { g } from "./b/g.ts"; // yes: unowned
// in a/one.ts, owned by #a
import { two } from "./two.ts"; // yes: sibling
import { deep } from "./sub/deep.ts"; // yes: subfolder of the same area
import { x } from "../x.ts"; // no: no ../ anywhere
``` ```
Node, TypeScript, Vite, Vite+'s config bundler, and Oxlint's plugin loader
all resolve them natively, so `.ts` source runs unbuilt. `vp pack` inlines them,
so they never reach package consumers.
- Deno's trailing-slash mappings (`"#components/"`) don't exist in Node; they
become patterns, `"#components/*": "./src/components/*"`.
- Aliases point at `.ts` files only. There is no `#package` alias - Aliases point at `.ts` files only. There is no `#package` alias
([[ts-only-files-and-imports]]). ([[ts-only-files-and-imports]]).
- In repos linted by import-check, the key order of `imports` is the layer - Lint `jsPlugins` entries in `vite.config.ts` stay path strings, even when
order `enforce-layer-order` checks; add a new alias at the right layer. the file has an alias.
- Keep test-only helpers in their own alias (e.g. `#testing`) so they never - Keep test-only helpers in their own alias (e.g. `#testing`) so they never
land in a bundle. land in a bundle.
## `index.ts` entries and barrels ## Barrels
- Every `src/` subfolder's entry file is `index.ts`, never `mod.ts` (decided - An area used from outside exposes its API through its alias target, and
2026-10-12; import-check's `enforce-mod-file` requires it only in folders a callers import the alias (`#ui`), not files inside it.
`#` alias owns). Folders outside `src/`, such as `scripts/`, have none.
- A directory used from outside exposes its API through its `index.ts`
barrel, and callers import the barrel (`#ui`), not files inside it
(`no-barrel-bypass`).
- Barrels are also the standard fix for `import/max-dependencies`: one barrel - Barrels are also the standard fix for `import/max-dependencies`: one barrel
import replaces many direct ones ([[lint-overrides-policy]]). import replaces many direct ones ([[lint-overrides-policy]]).
- `import/no-cycle` is on; a barrel must not create a cycle. - `import/no-cycle` is on; a barrel must not create a cycle.
Relative imports inside one directory (`./sibling.ts`) are fine, with the Relative imports are written with the `.ts` extension.
`.ts` extension written out.
## `scripts/` is flat (user rule, 2026-10-16) ## `scripts/` is flat (user rule, 2026-10-16)
- `scripts/` has no subfolders and no `index.ts`. Barrels and `index.ts` - `scripts/` has no subfolders and no `index.ts`.
entries exist only inside `src/`; delete any `index.ts` outside it.
- Each tool is one file, `scripts/<name>.ts`, imported directly by file: - Each tool is one file, `scripts/<name>.ts`, imported directly by file:
`import { decorators } from "./scripts/decorators.ts";` in `import { decorators } from "./scripts/decorators.ts";` in
`vite.config.ts`. `vite.config.ts`. No alias owns `scripts/`, so these `./` imports are free.
- Code in a former `scripts/<tool>/` folder is merged or renamed into flat - Code in a former `scripts/<tool>/` folder is merged or renamed into flat
`scripts/<name>.ts` files. Non-TS assets (HTML, images, fixtures) move to `scripts/<name>.ts` files. Non-TS assets (HTML, images, fixtures) move to
where they belong (next to the tool's output, `.agents/`, or a gitignored where they belong (next to the tool's output, `.agents/`, or a gitignored
scratch dir). A subfolder that is really an app moves under `src/` as an scratch dir). A subfolder that is really an app moves under an alias.
aliased folder.
- import-check 0.10.0+ checks folder layout only where the importer or the
target sits in a `#`-aliased folder, so `vite.config.ts` →
`./scripts/x.ts` passes without any override. Remove overrides that only
dodged import-check.
+6 -3
View File
@@ -11,9 +11,7 @@ practice.
off for `src/rules/index.ts`, the rule registry, which must import every off for `src/rules/index.ts`, the rule registry, which must import every
rule. It is not a precedent for apps. rule. It is not a precedent for apps.
- **`scripts/**`:** a narrow override is allowed when a tooling script truly - **`scripts/**`:** a narrow override is allowed when a tooling script truly
needs it, e.g. `no-console` in a CLI script. Never one that only dodges needs it, e.g. `no-console` in a CLI script.
import-check (e.g. `no-relative-bypass` for `vite.config.ts`); import-check
ignores folders no `#` alias owns.
- **Generated files:** list them in `ignorePatterns`, never `overrides`. - **Generated files:** list them in `ignorePatterns`, never `overrides`.
- **Anything else needs the user's approval first.** When the user is away, - **Anything else needs the user's approval first.** When the user is away,
don't add it: refactor, or leave the violation failing and log the question. don't add it: refactor, or leave the violation failing and log the question.
@@ -45,3 +43,8 @@ pedantic hits in 274 files, of which `max-lines-per-function` 206,
`max-lines` 145, `max-classes-per-file` 59, `max-dependencies` 45 (median 14 `max-lines` 145, `max-classes-per-file` 59, `max-dependencies` 45 (median 14
imports, worst 29). The user chose refactoring over raising limits or imports, worst 29). The user chose refactoring over raising limits or
matching memona's old hugoalh limits (2048 lines per file, no function limit). matching memona's old hugoalh limits (2048 lines per file, no function limit).
Alias rule: an alias may point anywhere and owns its target (a folder's
`index.ts` owns the folder and below, any other file owns only itself); no
two aliases may own the same file, and `../` is banned everywhere
([[imports-aliases-barrels]]).
+6
View File
@@ -30,3 +30,9 @@ The `imports` key order can carry meaning: import-check's
Refresh the lockfile with `npm install` (or `npm install Refresh the lockfile with `npm install` (or `npm install
--package-lock-only` for a version bump) and commit it with the change. --package-lock-only` for a version bump) and commit it with the change.
Alias rule: an alias may point anywhere and owns its target (a folder's
`index.ts` owns the folder and everything below it; any other file owns only
itself). No two aliases may own the same file, outside an area only the exact
alias reaches it, and `../` is banned in every file
([[imports-aliases-barrels]]).
+7 -4
View File
@@ -1,5 +1,5 @@
--- ---
description: Vitest via vp test; unit tests sit next to code as *.test.ts, regression/integration tests in tests/ via the public entry; scripts/ has no tests description: Vitest via vp test; unit tests sit next to code as *.test.ts, regression/integration tests in tests/ via the public entry (through its # alias, never ../); scripts/ has no tests
--- ---
Decided 2026-10-14 for all repos. Decided 2026-10-14 for all repos.
@@ -11,9 +11,12 @@ Decided 2026-10-14 for all repos.
config needed. Packages publish only `dist/`, so colocated tests cost nothing config needed. Packages publish only `dist/`, so colocated tests cost nothing
in the tarball. in the tarball.
- **Regression and integration tests** live in the top-level `tests/` folder - **Regression and integration tests** live in the top-level `tests/` folder
and use only the public entry point through a relative import and use only the public entry point, imported through its `#` alias (e.g.
(`../src/index.ts`; no alias may target `src/` itself, see `#plugin` for `./src/plugin/index.ts`), never internals and never `../`,
[[imports-aliases-barrels]]), never internals. `vp test tests` runs just these. which is banned in every file. `vp test tests` runs just these
([[imports-aliases-barrels]]). In bot-agents, `tests/` imports only
`#testing` (`./src/testing.ts`), which re-exports the few source helpers
those tests need.
- **No in-source tests** (`import.meta.vitest`). - **No in-source tests** (`import.meta.vitest`).
- **`scripts/` gets no tests** (2026-10-15): it is repo tooling, not - **`scripts/` gets no tests** (2026-10-15): it is repo tooling, not
production code (`project-lint.ts`, `decorators.ts`, `lit-check.ts`, production code (`project-lint.ts`, `decorators.ts`, `lit-check.ts`,
+8 -4
View File
@@ -19,10 +19,14 @@ dependency. Keep the sources to erasable syntax and `.ts` imports, which
type-checks, and `vp test` runs Vitest. Unit tests sit next to the code as type-checks, and `vp test` runs Vitest. Unit tests sit next to the code as
`src/*.test.ts`; repository-level tests live in `tests/`. `src/*.test.ts`; repository-level tests live in `tests/`.
`src/` is flat, so `package.json` has no `imports` aliases and modules, tests, An alias in `package.json` `imports` owns its target: a folder's `index.ts`
and `scripts/` import each other relatively. If one is ever added, it may point owns that folder and everything below it, any other file owns only that file.
only at a direct subfolder of `src/` (its `index.ts`, or a `#x/*` pattern No file may be owned by 2 aliases. Outside an area, only the exact alias
inside it); never at `src/` itself, a deeper folder, or anything outside `src/`. reaches it (no deeper `#` specifier, no relative path); inside it, relative
imports of siblings and subfolders are free. `../` is banned in every file
(`import/no-relative-parent-imports`). The one alias is `#testing`
(`./src/testing.ts`): every test imports it by alias, and `tests/` reaches
`src/` only through it. Other `src/` files import `./` siblings freely.
`scripts/` is flat: one file per tool, no subfolders and no `index.ts` `scripts/` is flat: one file per tool, no subfolders and no `index.ts`
(barrels live only in `src/`), imported directly by file path. (barrels live only in `src/`), imported directly by file path.
+3
View File
@@ -3,6 +3,9 @@
"private": true, "private": true,
"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": {
"#testing": "./src/testing.ts"
},
"devDependencies": { "devDependencies": {
"@eslint/core": "^0.17.0", "@eslint/core": "^0.17.0",
"@types/node": "^24.19.2", "@types/node": "^24.19.2",
+2
View File
@@ -203,6 +203,8 @@ const rules: DummyRuleMap = {
], ],
// The explicit-types template's extras. // The explicit-types template's extras.
"import/no-cycle": "error", "import/no-cycle": "error",
// `../` is banned in every file; anything above a file needs a `#` alias.
"import/no-relative-parent-imports": "error",
"typescript/consistent-type-imports": "error", "typescript/consistent-type-imports": "error",
"typescript/no-import-type-side-effects": "error", "typescript/no-import-type-side-effects": "error",
"typescript/no-non-null-assertion": "error", "typescript/no-non-null-assertion": "error",
+1 -1
View File
@@ -9,7 +9,7 @@ import {
retryInvalidToken, retryInvalidToken,
stageCodexAuth, stageCodexAuth,
} from "./auth.ts"; } from "./auth.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.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;
+1 -1
View File
@@ -10,7 +10,7 @@ import {
readProfileFile, readProfileFile,
} from "./isolation.ts"; } from "./isolation.ts";
import type { RuntimeProfile } from "./runtime.ts"; import type { RuntimeProfile } from "./runtime.ts";
import { currentUid, IS_ROOT, IS_WINDOWS, makeTempDir } from "./testing.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,
+1 -1
View File
@@ -6,7 +6,7 @@ import { isNotFound } from "./errors.ts";
import { postRuntime } from "./post.ts"; import { postRuntime } from "./post.ts";
import type { PostContext } from "./post.ts"; import type { PostContext } from "./post.ts";
import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts"; import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts";
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts"; import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing";
const context: PostContext = { const context: PostContext = {
apiUrl: "https://gitea.example/api/v1", apiUrl: "https://gitea.example/api/v1",
+1 -1
View File
@@ -7,7 +7,7 @@ import { assert, expect, test } from "vite-plus/test";
import { output } from "./command.ts"; import { output } from "./command.ts";
import { prepareRuntime } from "./runtime.ts"; import { prepareRuntime } from "./runtime.ts";
import type { RuntimeProfile } from "./runtime.ts"; import type { RuntimeProfile } from "./runtime.ts";
import { IS_ROOT, makeTempDir } from "./testing.ts"; import { IS_ROOT, makeTempDir } from "#testing";
const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; const MASTER_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8";
const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA"; const ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA";
+8 -1
View File
@@ -1,4 +1,6 @@
// Test support: the current user's IDs and a fresh temporary directory. // Test support: the current user's IDs and a fresh temporary directory, plus
// the source helpers that the repository-level tests in tests/ need. Those
// tests sit outside src/ and reach it only through the `#testing` alias.
import { mkdtemp } from "node:fs/promises"; import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
@@ -21,3 +23,8 @@ export const IS_WINDOWS: boolean = process.platform === "win32";
export function makeTempDir(): Promise<string> { export function makeTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), "bot-agents-test-")); return mkdtemp(join(tmpdir(), "bot-agents-test-"));
} }
export { output } from "./command.ts";
export { isNotFound } from "./errors.ts";
export { isMapping } from "./json.ts";
export type { JsonObject } from "./json.ts";
+1 -1
View File
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
import { expect, test } from "vite-plus/test"; import { expect, test } from "vite-plus/test";
import { output } from "../src/command.ts"; import { output } from "#testing";
// Each phase runs as `node src/<phase>.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
+2 -5
View File
@@ -4,11 +4,8 @@ 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 { output } from "../src/command.ts"; import { isMapping, isNotFound, makeTempDir, output } from "#testing";
import { isNotFound } from "../src/errors.ts"; import type { JsonObject } from "#testing";
import { isMapping } from "../src/json.ts";
import type { JsonObject } from "../src/json.ts";
import { makeTempDir } from "../src/testing.ts";
const mapping = (value: unknown, at: string): JsonObject => { const mapping = (value: unknown, at: string): JsonObject => {
assert(isMapping(value), at); assert(isMapping(value), at);