From bfa82698015da9089a0f4af277b18ce047949e98 Mon Sep 17 00:00:00 2001 From: Danny Kim Date: Sat, 17 Oct 2026 14:45:52 +0900 Subject: [PATCH] Route tests through a #testing alias, ban ../ imports, and document the alias ownership rule --- .agents/memory/bot-agents-tests-root-only.md | 5 +- .agents/memory/imports-aliases-barrels.md | 94 +++++++++++--------- .agents/memory/lint-overrides-policy.md | 9 +- .agents/memory/package-json-shape.md | 6 ++ .agents/memory/tests-layout-vitest.md | 11 ++- AGENTS.md | 12 ++- package.json | 3 + scripts/project-lint.ts | 2 + src/auth.test.ts | 2 +- src/isolation.test.ts | 2 +- src/post.test.ts | 2 +- src/runtime.test.ts | 2 +- src/testing.ts | 9 +- tests/entrypoints.test.ts | 2 +- tests/workflow.test.ts | 7 +- 15 files changed, 103 insertions(+), 65 deletions(-) diff --git a/.agents/memory/bot-agents-tests-root-only.md b/.agents/memory/bot-agents-tests-root-only.md index e3cb44c..388a705 100644 --- a/.agents/memory/bot-agents-tests-root-only.md +++ b/.agents/memory/bot-agents-tests-root-only.md @@ -17,6 +17,9 @@ as skipped instead of silently passing. `codex`/`claude`/`npx` binaries and a fake Gitea through `protect-source.sh` -> `prepare.ts` -> `run.ts` -> `post.ts`; repeat that 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/`). diff --git a/.agents/memory/imports-aliases-barrels.md b/.agents/memory/imports-aliases-barrels.md index 787a253..db8b0b7 100644 --- a/.agents/memory/imports-aliases-barrels.md +++ b/.agents/memory/imports-aliases-barrels.md @@ -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 -`package.json` `imports`, never by a relative path that climbs out of a -directory. +`package.json` `imports`. 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. -**Targets (user rule, 2026-10-16):** an alias may point ONLY at a direct -subfolder of `src/`: its `index.ts` (`"#helpers": "./src/helpers/index.ts"`) -or a `#x/*` pattern inside that one folder. Never alias anything outside -`src/` (`scripts/`, root files), `src/` itself (no `#plugin` for -`./src/index.ts`), or a deeper folder (`./src/a/b/`). Code that would need -such an alias uses a relative import instead; `scripts/` and -`vite.config.ts` import each other relatively. Layer rules -(`enforce-layer-order`) must still pass, so reorder entries if needed. +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 and the "import-check checks only aliased folders" scope): + +- **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. 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 "imports": { - "#helpers": "./src/helpers/index.ts", - "#components/*": "./src/components/*" + "#a": "./a/index.ts", // owns a/** + "#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 -import { typeNameTail } from "#helpers"; // yes -import { typeNameTail } from "../helpers/ast.ts"; // no: import/no-relative-parent-imports +// in main.ts, which no alias owns +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 ([[ts-only-files-and-imports]]). -- In repos linted by import-check, the key order of `imports` is the layer - order `enforce-layer-order` checks; add a new alias at the right layer. +- Lint `jsPlugins` entries in `vite.config.ts` stay path strings, even when + the file has an alias. - Keep test-only helpers in their own alias (e.g. `#testing`) so they never land in a bundle. -## `index.ts` entries and barrels +## Barrels -- Every `src/` subfolder's entry file is `index.ts`, never `mod.ts` (decided - 2026-10-12; import-check's `enforce-mod-file` requires it only in folders a - `#` 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`). +- An area used from outside exposes its API through its alias target, and + callers import the alias (`#ui`), not files inside it. - Barrels are also the standard fix for `import/max-dependencies`: one barrel import replaces many direct ones ([[lint-overrides-policy]]). - `import/no-cycle` is on; a barrel must not create a cycle. -Relative imports inside one directory (`./sibling.ts`) are fine, with the -`.ts` extension written out. +Relative imports are written with the `.ts` extension. ## `scripts/` is flat (user rule, 2026-10-16) -- `scripts/` has no subfolders and no `index.ts`. Barrels and `index.ts` - entries exist only inside `src/`; delete any `index.ts` outside it. +- `scripts/` has no subfolders and no `index.ts`. - Each tool is one file, `scripts/.ts`, imported directly by file: `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//` folder is merged or renamed into flat `scripts/.ts` files. Non-TS assets (HTML, images, fixtures) move to 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 - 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. + scratch dir). A subfolder that is really an app moves under an alias. diff --git a/.agents/memory/lint-overrides-policy.md b/.agents/memory/lint-overrides-policy.md index 2b552c9..e35f788 100644 --- a/.agents/memory/lint-overrides-policy.md +++ b/.agents/memory/lint-overrides-policy.md @@ -11,9 +11,7 @@ practice. off for `src/rules/index.ts`, the rule registry, which must import every rule. It is not a precedent for apps. - **`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 - import-check (e.g. `no-relative-bypass` for `vite.config.ts`); import-check - ignores folders no `#` alias owns. + needs it, e.g. `no-console` in a CLI script. - **Generated files:** list them in `ignorePatterns`, never `overrides`. - **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. @@ -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 imports, worst 29). The user chose refactoring over raising limits or 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]]). diff --git a/.agents/memory/package-json-shape.md b/.agents/memory/package-json-shape.md index 714bbed..297bee3 100644 --- a/.agents/memory/package-json-shape.md +++ b/.agents/memory/package-json-shape.md @@ -30,3 +30,9 @@ The `imports` key order can carry meaning: import-check's Refresh the lockfile with `npm install` (or `npm install --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]]). diff --git a/.agents/memory/tests-layout-vitest.md b/.agents/memory/tests-layout-vitest.md index 1789756..739b5c6 100644 --- a/.agents/memory/tests-layout-vitest.md +++ b/.agents/memory/tests-layout-vitest.md @@ -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. @@ -11,9 +11,12 @@ Decided 2026-10-14 for all repos. config needed. Packages publish only `dist/`, so colocated tests cost nothing in the tarball. - **Regression and integration tests** live in the top-level `tests/` folder - and use only the public entry point through a relative import - (`../src/index.ts`; no alias may target `src/` itself, see - [[imports-aliases-barrels]]), never internals. `vp test tests` runs just these. + and use only the public entry point, imported through its `#` alias (e.g. + `#plugin` for `./src/plugin/index.ts`), never internals and never `../`, + 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`). - **`scripts/` gets no tests** (2026-10-15): it is repo tooling, not production code (`project-lint.ts`, `decorators.ts`, `lit-check.ts`, diff --git a/AGENTS.md b/AGENTS.md index 54775b3..357b130 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `src/*.test.ts`; repository-level tests live in `tests/`. -`src/` is flat, so `package.json` has no `imports` aliases and modules, tests, -and `scripts/` import each other relatively. If one is ever added, it may point -only at a direct subfolder of `src/` (its `index.ts`, or a `#x/*` pattern -inside it); never at `src/` itself, a deeper folder, or anything outside `src/`. +An alias in `package.json` `imports` owns its target: a folder's `index.ts` +owns that folder and everything below it, any other file owns only that file. +No file may be owned by 2 aliases. Outside an area, only the exact alias +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` (barrels live only in `src/`), imported directly by file path. diff --git a/package.json b/package.json index 2d3c442..b1e0a87 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "private": true, "description": "A Gitea composite action that runs a coding agent for approved issue and pull request automation.", "type": "module", + "imports": { + "#testing": "./src/testing.ts" + }, "devDependencies": { "@eslint/core": "^0.17.0", "@types/node": "^24.19.2", diff --git a/scripts/project-lint.ts b/scripts/project-lint.ts index 14d52bb..663997e 100644 --- a/scripts/project-lint.ts +++ b/scripts/project-lint.ts @@ -203,6 +203,8 @@ const rules: DummyRuleMap = { ], // The explicit-types template's extras. "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/no-import-type-side-effects": "error", "typescript/no-non-null-assertion": "error", diff --git a/src/auth.test.ts b/src/auth.test.ts index 67432ac..1a7f334 100644 --- a/src/auth.test.ts +++ b/src/auth.test.ts @@ -9,7 +9,7 @@ import { retryInvalidToken, stageCodexAuth, } 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 () => { let attempts = 0; diff --git a/src/isolation.test.ts b/src/isolation.test.ts index 3b8cd78..fbc6451 100644 --- a/src/isolation.test.ts +++ b/src/isolation.test.ts @@ -10,7 +10,7 @@ import { readProfileFile, } from "./isolation.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 => ({ index: uid - 20_000, diff --git a/src/post.test.ts b/src/post.test.ts index 737dcab..4a8c914 100644 --- a/src/post.test.ts +++ b/src/post.test.ts @@ -6,7 +6,7 @@ import { isNotFound } from "./errors.ts"; import { postRuntime } from "./post.ts"; import type { PostContext } from "./post.ts"; import type { RuntimeManifest, RuntimeOutcome } from "./runtime.ts"; -import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts"; +import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "#testing"; const context: PostContext = { apiUrl: "https://gitea.example/api/v1", diff --git a/src/runtime.test.ts b/src/runtime.test.ts index ff059fc..5f5cd9d 100644 --- a/src/runtime.test.ts +++ b/src/runtime.test.ts @@ -7,7 +7,7 @@ import { assert, expect, test } from "vite-plus/test"; import { output } from "./command.ts"; import { prepareRuntime } 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 ENCRYPTED = "v1:ICEiIyQlJicoKSor:oV_FAgnsN3p1FyegJxwGVdyjfyPio6qDeSUwbA"; diff --git a/src/testing.ts b/src/testing.ts index 0e96f38..24e185e 100644 --- a/src/testing.ts +++ b/src/testing.ts @@ -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 { tmpdir } from "node:os"; import { join } from "node:path"; @@ -21,3 +23,8 @@ export const IS_WINDOWS: boolean = process.platform === "win32"; export function makeTempDir(): Promise { 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"; diff --git a/tests/entrypoints.test.ts b/tests/entrypoints.test.ts index 68c10ae..587fe31 100644 --- a/tests/entrypoints.test.ts +++ b/tests/entrypoints.test.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url"; import { expect, test } from "vite-plus/test"; -import { output } from "../src/command.ts"; +import { output } from "#testing"; // Each phase runs as `node src/.ts` with no build step. Node loads the // whole import graph, stripping types, before the phase reads its first diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index 23ff4d0..5a868e7 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -4,11 +4,8 @@ import process from "node:process"; import { assert, expect, test } from "vite-plus/test"; import { parse } from "yaml"; -import { output } from "../src/command.ts"; -import { isNotFound } from "../src/errors.ts"; -import { isMapping } from "../src/json.ts"; -import type { JsonObject } from "../src/json.ts"; -import { makeTempDir } from "../src/testing.ts"; +import { isMapping, isNotFound, makeTempDir, output } from "#testing"; +import type { JsonObject } from "#testing"; const mapping = (value: unknown, at: string): JsonObject => { assert(isMapping(value), at);