Move the action from Deno to Node.js and Vite+ (#20)
## Summary
Moves `commons/bot-agents` off Deno. The action's TypeScript now runs directly on the job image's Node.js (`node src/<phase>.ts`, using Node's built-in type stripping), with no build step and still no runtime dependency. Repository checks move to Vite+ (`vp check`, `vp test`).
## What changed
- **Runtime.** `action.yml` runs `prepare.ts`, `run.ts`, and `post.ts` with `node` instead of `deno run -A`. `package.json` (`"type": "module"`, `engines.node >=24.2` for `import.meta.main`) is now copied into the root-owned source copy by `scripts/protect-source.sh`.
- **Profiles.** `profiles.yml` → `profiles.json`, with the same content (encrypted values unchanged), parsed with `JSON.parse`. This drops the only runtime dependency (`@std/yaml`).
- **Deno APIs → Node.**
- `src/command.ts` wraps `child_process.spawn` with the old `Deno.Command` shapes: `output()`/`spawn()`, `clearEnv`, `uid`/`gid`, and Deno's stdio defaults (stdin inherited; stdout/stderr piped for `output()`, inherited for `spawn()`). A signal exit maps to `128 + signo`. Status settles on process exit.
- `src/errors.ts` maps `NotFound` to `ENOENT`/`ESRCH`.
- fs, path, stream, and readline calls are mapped one for one. `makeTempFile` becomes an exclusive `wx` 0600 create.
- **Size refactors.** These are needed for Oxlint's default limits. There are no behavior changes and no overrides for source files.
- `run.ts` is split into `run-context.ts`, `agents.ts`, and `agent-{claude,codex,opencode}.ts`.
- `post.ts` is split into `post.ts` and `post-artifacts.ts`.
- `parseCredentials`, `readOutcome`, and `postRuntime` are broken into helpers.
- Loops that awaited sequentially became recursion or a `reduce` chain, so they stay sequential. `/proc` status reads became `Promise.all`.
- `src/json.ts` adds type guards in place of `as` casts.
- **Tests.** The 58 `Deno.test` cases were converted to Vitest with the codemod, and its TODOs were finished by hand.
- Unit tests are `src/*.test.ts`; the repository-level workflow test is `tests/workflow.test.ts`.
- Root-only tests are now `test.skipIf(!IS_ROOT)`, so they show as skipped instead of silently passing.
- New `tests/entrypoints.test.ts` runs each phase under real Node to prove its whole import graph loads.
- **Config.** The only config files are `package.json`, `tsconfig.json`, and `vite.config.ts`, plus `package-lock.json`.
- Lint uses Oxlint correctness/suspicious/pedantic/perf at error, with default limits and type-aware checks.
- `scripts/project-lint.ts` holds the rule list. It maps Deno's `recommended` rules that fall outside those categories, adds the template's `id-match`, `no-disable-comments`, and `no-non-ts-imports`, and maps Deno's `no-window`/`no-process-global`/`no-node-globals` to `no-restricted-globals` (so `process`/`Buffer` come from `node:` imports).
- **CI.** `.gitea/workflows/check.yml` runs `node --version`, `npm ci`, `npx vp check`, and `npx vp test` in the default runner image.
- **Formatting.** Oxfmt formatting is a separate first commit, so the second commit shows only the semantic diff.
- **AGENTS.md** is updated for the new toolchain.
## Deviations / risks
- **Live `@main` switch.** Six consumer repos use `commons/bot-agents@main`, so merging switches all of them at once. Suggested before merging: tag the current `main` (e.g. `deno-final`) as a rollback target, and canary this branch from one consumer (`uses: …/commons/bot-agents@node-migration`) on a throwaway PR.
- **Node version.** `post.ts` relies on `import.meta.main`, which needs Node ≥ 24.2. DANNYEC's cached `node:24-bookworm` minor is unknown (`forcePull=false`), and the CI job now prints `node --version` to confirm it.
- **Deno stays in the global install.** `deno` remains in the action's global npm install because agents still verify Deno-based consumers (memona, memona-home). Remove it once those repos move.
- **`prefer-readonly-parameter-types` is off.** This pedantic rule is turned off, as in the explicit-types template and the app repos, because Node/web types (`Response`, `Stats`, `RequestInit`) can never satisfy it. `scripts/**` has one narrow override (`no-unsafe-enum-comparison`) for AST node `type` comparisons.
- **Stricter validation of malformed input.** For valid input, behavior is unchanged. A few validations are stricter where the old code would have used a malformed value:
- Gitea `/user` and comment responses are checked.
- The runtime manifest and the outcome's profile type are checked against known values.
- Some type-check errors are now `TypeError` (same messages).
- **@cunarist lint plugins.** "CI fails until @cunarist/eslint-plugin-* are published" does **not** apply here. This repo's Deno setup enabled no plugin, so it depends on none of `@cunarist/eslint-plugin-*`, and CI can pass as is.
## Verification
- `vp check`: formatting, lint (0 warnings), and type check all pass.
- `vp test` as a normal user: 56 passed, 5 root-only skipped.
- `vp test` as root in `node:24-bookworm` (Node v24.22.0, the CI image): all 61 passed, including the UID isolation, process cleanup, and `prepare.ts` cleanup tests.
- End-to-end smoke as root in `node:24-bookworm`, with fake `codex`/`claude`/`npx` binaries and a fake Gitea, through `protect-source.sh` → `prepare.ts` → `run.ts` → `post.ts`:
- Codex hit "usage limit" and fell through to Claude.
- Agents ran as UIDs 20000/20001 without the master key.
- The Codex login was persisted to `/root/.codex/auth.json` (0600).
- The PR review with an inline comment was posted with the reviewer token.
- Runtime credentials were removed and the git author was configured.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: #20
Reviewed-by: bot <noreply@capsulizers.com>
Co-authored-by: Danny Kim <temeddix@gmail.com>
Co-committed-by: Danny Kim <temeddix@gmail.com>
This commit was merged in pull request #20.
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
---
|
||||
description: Every repo has AGENTS.md starting with the damem recall header; durable context lives in .agents/memory (flat, one fact per file) and .agents/skills
|
||||
---
|
||||
|
||||
Standing convention (2026-10-12): every repo has an `AGENTS.md` whose first
|
||||
lines are exactly the damem header (cunarist/damem):
|
||||
|
||||
```markdown
|
||||
At the start of every new session, run `damem recall` in the terminal and treat
|
||||
its output as the context for this repository. If the `damem` command is not
|
||||
found, read [INSTALL.md](https://github.com/cunarist/damem/blob/main/INSTALL.md)
|
||||
and follow it before reasoning or responding.
|
||||
```
|
||||
|
||||
Below it: `# AGENTS.md`, then `## Project` (what the repo is, runtime,
|
||||
toolchain, where it came from) and `## Rules` (short bullets, each pointing at
|
||||
a memory where the reason lives). No `CLAUDE.md`, `GEMINI.md`, or other
|
||||
per-agent files; `AGENTS.md` is the single entry point.
|
||||
|
||||
## `.agents/` layout
|
||||
|
||||
- `memory/*.md`: one fact per file, flat (subdirectories are not listed),
|
||||
frontmatter with a one-line `description` that states the fact. Link
|
||||
related notes with double-bracket wiki links using the file name without
|
||||
`.md`; a link to a missing note fails `damem doctor`.
|
||||
- `skills/<name>/SKILL.md`: frontmatter `name` and `description` (when to use
|
||||
it), body says how. Helper scripts live next to it and are Python 3.12+.
|
||||
- `scratch/`: holds a `.gitignore` containing `*`.
|
||||
- Every memory and `SKILL.md` stays under 8 KB.
|
||||
|
||||
## Keeping it right
|
||||
|
||||
- Update an existing note instead of adding a near-duplicate; delete notes
|
||||
that turn out wrong.
|
||||
- Write a note when the user corrects something, states a preference, or
|
||||
when understanding something took reading many files.
|
||||
- Run `damem doctor` before finishing and fix what it reports.
|
||||
- Changing anything outside `.agents/` (source, config, `AGENTS.md`) follows
|
||||
the normal branch and PR flow ([[git-pr-workflow]]).
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
description: Containerfiles build the frontend in a node:24-alpine stage with npm ci and vp build; CI uses setup-vp and runs vp check, vp build, vp test (plus lit-check)
|
||||
---
|
||||
|
||||
## Containerfiles
|
||||
|
||||
- The frontend build stage is `docker.io/library/node:24-alpine`, replacing
|
||||
`denoland/deno:alpine`. Vite+ publishes `linux-x64-musl` binaries, so Alpine
|
||||
works. (`ghcr.io/voidzero-dev/vite-plus` is a glibc alternative that would
|
||||
need a pinned digest; not used.)
|
||||
- The stage copies `package.json` and `package-lock.json` first, runs
|
||||
`npm ci`, then copies the sources and configs and runs `npx vp build`.
|
||||
- The runtime stage copies the built `js/` and `css/` directories (not single
|
||||
files) from the node stage.
|
||||
- No Deno binary, `deno install`, or `deno task` remains in any stage.
|
||||
|
||||
```dockerfile
|
||||
FROM docker.io/library/node:24-alpine AS frontend
|
||||
WORKDIR /src/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npx vp build
|
||||
```
|
||||
|
||||
## CI
|
||||
|
||||
- GitHub repos (temeddix): `.github/workflows`. Capsulizers repos (Gitea):
|
||||
`.gitea/workflows`. Don't move a repo's CI between hosts.
|
||||
- Install with `voidzero-dev/setup-vp`, then run `vp check`, `vp build`, and
|
||||
`vp test`; Lit repos also run `npm run lit-check`
|
||||
([[lit-analyzer-ts6]]).
|
||||
- Repos that ran CI on a self-hosted runner keep it.
|
||||
- Until `@cunarist/eslint-plugin-*` are on npm, `npm ci` fails in CI. That is
|
||||
expected and stated in the PR; run `npm install` once they are published
|
||||
and commit the refreshed lockfile.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
description: Code that must ship as standalone JS (memona framed_find.js, brain stream-recovery.js) is written as .ts, compiled at build time, and its output gitignored
|
||||
---
|
||||
|
||||
Some apps ship plain JavaScript that runs outside the main bundle: memona's
|
||||
`framed_find.js` and brain's `stream-recovery.js`. Under the TS-only rule
|
||||
([[ts-only-files-and-imports]]) they are handled like this (2026-10-16):
|
||||
|
||||
- The source is a `.ts` file, linted and type-checked like everything else.
|
||||
- The `.js` is produced at build time by the existing build (a Vite input or
|
||||
a build step in `vite.config.ts` or `scripts/`), never committed.
|
||||
- The output path is gitignored and, if it lands in a linted folder, listed
|
||||
in `lint.ignorePatterns`; never in `overrides`, never with disable comments.
|
||||
- Whatever loads the file (Rust `include_str!`, a container `COPY`, an HTML
|
||||
`<script>`) keeps the same path and name as before, so behavior is
|
||||
unchanged. Builds that need the file must run the compile first; check
|
||||
that the Containerfile and CI order the steps that way.
|
||||
|
||||
Don't commit a hand-maintained `.js` copy "for convenience", and don't turn
|
||||
it into `.mjs` or `.cjs`.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
description: Apps use the npm lint plugins' configs, port their old deno-lint-plugin.ts rules into scripts/project-lint.ts, never override source files, and refactor instead of raising limits
|
||||
---
|
||||
|
||||
## Plugins
|
||||
|
||||
Apps consume the published plugins as npm devDependencies, through `jsPlugins`
|
||||
and the configs equivalent to what their Deno setup enabled:
|
||||
|
||||
- `@cunarist/eslint-plugin-explicit-types`: `concrete`, `naming`, `absence`
|
||||
- `@cunarist/eslint-plugin-import-check`: `import-check`
|
||||
- `@cunarist/eslint-plugin-lit`: `core`, `strict`, `dom-ref`,
|
||||
`reactive-controller`, `naming`
|
||||
|
||||
## Repo-local rules
|
||||
|
||||
The local `scripts/deno-lint-plugin.ts` that home, eximium, stockvibe, brain,
|
||||
memona, and memona-home carried is ported into `scripts/project-lint.ts`
|
||||
([[project-lint-plugin]]) under the `project` namespace, same rule names and
|
||||
behavior. Depending on the repo: `no-self-sync`, `no-sync-in-render`,
|
||||
`app-module-conventions`, `always-wildcard-export`, `no-class-extends`,
|
||||
`no-lit-nothing`, `no-title-attribute`, `prefer-wa-components`,
|
||||
`no-satisfies`, `no-unknown`, `restricted-types`, `no-line-lint-ignore`,
|
||||
`no-app-disposables-in-component`, `no-crepe-react-ui`. `@hugoalh` rules map to
|
||||
Oxlint built-ins in `configs.project`.
|
||||
|
||||
memona's `deno-lint-plugin.ts` and its `coding-standards` memory are the house
|
||||
style the explicit-types rules came from; they win any conflict.
|
||||
|
||||
## No overrides, refactor instead
|
||||
|
||||
Apps use **no `overrides` for source files at all** (the rule-registry
|
||||
exception exists only in plugin repos). Oxlint's default limits stay, and
|
||||
violations are refactored without behavior change in the same PR: barrels for
|
||||
`max-dependencies`, splits for `max-lines`/`max-lines-per-function`/
|
||||
`max-classes-per-file`, an options object for `max-params`. Long Lit
|
||||
`render()` templates are split into smaller template functions or
|
||||
components. See [[lint-overrides-policy]].
|
||||
|
||||
If a hand-written exception still looks truly necessary, ask the user first;
|
||||
when they're away, leave it failing or refactor, and log the question.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
Decisions for `commons/bot-agents` (Capsulizers Gitea), from the Deno to
|
||||
Node + Vite+ migration (PR #20, `node-migration`, 2026-10-15/16):
|
||||
|
||||
- **Live action.** Six consumer repos use `commons/bot-agents@main`, so a merge
|
||||
switches all of them at once. It changes only through a PR and a careful
|
||||
review. Suggested before merging a risky change: tag the current `main`
|
||||
(e.g. `deno-final`) as a rollback target and canary the branch from one
|
||||
consumer (`uses: .../commons/bot-agents@<branch>`) on a throwaway PR.
|
||||
- **Profiles are JSON.** `profiles.yml` became `profiles.json` with the same
|
||||
content (encrypted `v1:` values unchanged), read with `JSON.parse`. That
|
||||
removed the only runtime dependency (`@std/yaml`); keep it dependency-free.
|
||||
- **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]]).
|
||||
- **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.
|
||||
- **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.
|
||||
- Merging is the user's call; the `bot` account's approval is required first
|
||||
([[git-pr-workflow]]).
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
description: Frontends build with vp build in Vite app mode, keeping the exact output paths the Rust backend and HTML expect; emptyOutDir false when outDir holds committed files
|
||||
---
|
||||
|
||||
`deno bundle` was replaced by `vp build` in **Vite app mode** (not
|
||||
`build.lib`). Behavior must not change, so the output keeps the file names and
|
||||
locations the backend serves and the HTML references.
|
||||
|
||||
Typical shape for the Rust-backed frontends (home, eximium, stockvibe,
|
||||
memona-home), where `frontend/` builds into `backend/static`:
|
||||
|
||||
```ts
|
||||
build: {
|
||||
outDir: "../backend/static",
|
||||
emptyOutDir: false, // the folder holds committed files (index.html, ...)
|
||||
rollupOptions: {
|
||||
input: { bundle: "src/index.ts" },
|
||||
output: { entryFileNames: "js/[name].js" }, // -> js/bundle.js
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
- **Fixed names, no hashes:** set `entryFileNames` (and `assetFileNames`
|
||||
where assets are emitted) explicitly. Vite's hashed defaults would 404,
|
||||
since Rust and the templates request `js/bundle.js` and `css/theme.css`.
|
||||
- **`emptyOutDir: false` whenever `outDir` is outside the Vite root or holds
|
||||
committed files.** Never let it default to true there: it would wipe them.
|
||||
- Output stays gitignored, so `.gitignore`, `.containerignore`, and the Rust
|
||||
side need no edits; home's startup check for `js/bundle.js` and
|
||||
`css/theme.css` keeps passing.
|
||||
- **CSS is never imported from TS** (no `import "./theme.css"`); the HTML
|
||||
links the stylesheet. How the CSS file is produced is per repo; read the
|
||||
repo's `vite.config.ts` before changing it.
|
||||
- Copy whole `js/` and `css/` directories in Containerfiles, not single files,
|
||||
so code-split chunks ship too ([[app-containers-and-ci]]).
|
||||
- Where a repo serves an `index.html` entry (brain's dashboard), use plain app
|
||||
mode with `index.html` as input, `base` set to the mount path, and `outDir:
|
||||
"dist"` with the default `emptyOutDir`.
|
||||
- `vp dev` must work too, and the decorators plugin must cover build, dev, and
|
||||
test ([[decorators-standard-accessor]]).
|
||||
|
||||
Check the repo's own migration PR for its final values before editing; these
|
||||
are the conventions it followed, not a config to paste.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
description: bot-agents lint has prefer-readonly-parameter-types off (Node/web types can't satisfy it) and one scripts/** override for no-unsafe-enum-comparison; nothing else
|
||||
---
|
||||
|
||||
The repo follows [[lint-config-shape]] with two recorded exceptions, both
|
||||
present when the migration PR was approved:
|
||||
|
||||
- `typescript/prefer-readonly-parameter-types` is `"off"` in
|
||||
`scripts/project-lint.ts`, as in the explicit-types template, because
|
||||
Node/web types (`Response`, `Stats`, `RequestInit`) can never satisfy it.
|
||||
- `vite.config.ts` has one `overrides` entry, for `scripts/**` only:
|
||||
`typescript/no-unsafe-enum-comparison` off, because the lint plugin compares
|
||||
AST node `type`s, which the AST types declare as an enum. This is the
|
||||
tooling exception [[lint-overrides-policy]] allows.
|
||||
|
||||
Oxlint's default size limits apply to `src/`; the migration split `run.ts`
|
||||
into `run-context.ts`, `agents.ts`, and `agent-{claude,codex,opencode}.ts`,
|
||||
and `post.ts` into `post.ts` + `post-artifacts.ts`, rather than raising them.
|
||||
Sequential awaits in loops became recursion or a `reduce` chain to stay
|
||||
sequential. Add no other opt-out without the user.
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
description: src/command.ts reproduces Deno.Command on child_process.spawn (stdio defaults, clearEnv, uid/gid, 128+signo); src/errors.ts maps NotFound to ENOENT/ESRCH
|
||||
---
|
||||
|
||||
The Deno code was ported one for one; two small modules keep the old shapes
|
||||
so callers barely changed:
|
||||
|
||||
- **`src/command.ts`** wraps `child_process.spawn` with `Deno.Command`'s API:
|
||||
`output()` and `spawn()`, `clearEnv`, `uid`/`gid` (the agent isolation runs
|
||||
each profile as its own UID, 20000+), and Deno's stdio defaults: stdin
|
||||
inherited; stdout/stderr piped for `output()`, inherited for `spawn()`. A
|
||||
signal exit maps to status `128 + signo`. Status settles on process exit,
|
||||
not on stream close. Use it instead of calling `node:child_process`
|
||||
directly so isolation and status handling stay uniform.
|
||||
- **`src/errors.ts`** replaces `Deno.errors.NotFound` checks with an
|
||||
`ENOENT`/`ESRCH` code check.
|
||||
- `makeTempFile` became an exclusive `wx` create with mode `0600`.
|
||||
- `src/json.ts` holds type guards used instead of `as` casts on parsed JSON.
|
||||
- `process` and `Buffer` must come from `node:` imports:
|
||||
`no-restricted-globals` in `scripts/project-lint.ts` maps Deno's
|
||||
`no-window`/`no-process-global`/`no-node-globals` there
|
||||
([[lint-parity-never-shrinks]]).
|
||||
|
||||
Related: how each agent CLI reports bad credentials is in
|
||||
[[agent-cli-auth-failures]].
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
description: The action runs src/<phase>.ts directly with node on node:24-bookworm (no build); protect-source.sh copies only src/*.ts, package.json, profiles.json, prompt.md
|
||||
---
|
||||
|
||||
`action.yml` is a composite action running three phases as
|
||||
`node "<source>/src/{prepare,run,post}.ts"`. Node's built-in type stripping
|
||||
runs the TypeScript; there is **no build step, no bundle, and no runtime
|
||||
dependency**. Hence `tsconfig.json` has `erasableSyntaxOnly` and
|
||||
`allowImportingTsExtensions`: only erasable syntax (no `enum`, no parameter
|
||||
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.
|
||||
- **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"`
|
||||
makes Node load the sources as ES modules), `profiles.json`, and
|
||||
`prompt.md`. **A new runtime file or subdirectory must be added there**, or
|
||||
it is missing at run time. `src/` is flat; a subfolder would not be copied.
|
||||
- `tests/entrypoints.test.ts` runs each phase under real Node with a cleared
|
||||
environment and expects its first "X is not set" error, which proves the
|
||||
whole import graph loads without a build. Keep it in sync with the phases.
|
||||
- **None of the app build conventions apply here:** no `vp build`, no
|
||||
frontend or Rust/Tauri output paths, no Containerfile, no decorators
|
||||
plugin, no lit-check, no generated JS ([[app-vite-build-output]],
|
||||
[[app-containers-and-ci]], [[app-generated-js-as-ts]] describe the other
|
||||
apps). Don't add any of them.
|
||||
- **CI** (`.gitea/workflows/check.yml`) runs on the self-hosted runner's
|
||||
default image as root: `npm ci`, `npx vp check`, `npx vp test`. It does not
|
||||
use `setup-vp`. YAML stays declarative ([[yaml-stays-declarative]]).
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
description: Isolation and credential tests need root and use test.skipIf(!IS_ROOT); locally 5 skip, CI runs as root so all 61 run; verify isolation changes in node:24-bookworm as root
|
||||
---
|
||||
|
||||
Some tests exercise real UID isolation, process cleanup, and `prepare.ts`
|
||||
credential cleanup. They need root and are written as
|
||||
`test.skipIf(!IS_ROOT)(...)` (`IS_ROOT` from `src/testing.ts`), so they show
|
||||
as skipped instead of silently passing.
|
||||
|
||||
- As a normal user `vp test` passes 56 and skips 5. In CI (self-hosted
|
||||
runner, root) all 61 run.
|
||||
- When changing `isolation.ts`, `runtime.ts`, `credentials.ts`, `prepare.ts`,
|
||||
or `command.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
|
||||
for changes to the phase flow ([[bot-agents-runtime-no-build]]).
|
||||
- `src/testing.ts` holds the shared test helpers; tests follow
|
||||
[[tests-layout-vitest]] (unit tests in `src/*.test.ts`, repo-level ones in
|
||||
`tests/`).
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
description: Keep TC39 standard decorators with accessor; Oxc can't lower them, so scripts/decorators.ts (esbuild Vite plugin) does; never legacy decorators
|
||||
---
|
||||
|
||||
Applies to every repo that uses decorators (the Lit frontends).
|
||||
|
||||
**Decision (2026-10-16):** keep standard (TC39) decorators with `accessor`,
|
||||
exactly as the Deno code had them. No legacy decorators.
|
||||
|
||||
```ts
|
||||
@customElement("app-card")
|
||||
export class AppCard extends LitElement {
|
||||
@property() accessor name = ""; // keep this form
|
||||
}
|
||||
```
|
||||
|
||||
**Gotcha, verified 2026-10-16:** Vite 8's Oxc transformer parses standard
|
||||
decorators but can **not** lower them, so `vp build`, `vp dev`, and `vp test`
|
||||
emit decorator syntax browsers and Node don't run. Check again on each Vite+
|
||||
upgrade; when Oxc can lower them, delete the plugin and the `esbuild`
|
||||
devDependency.
|
||||
|
||||
**Fix in use:** `scripts/decorators.ts`, a small Vite plugin backed by
|
||||
`esbuild` (devDependency), registered in `vite.config.ts` `plugins` so it
|
||||
covers build, dev server, and Vitest alike:
|
||||
|
||||
- `enforce: "pre"`, so it runs before Oxc;
|
||||
- transforms only `.ts` modules whose source contains `@`;
|
||||
- `esbuild.transform(code, { loader: "ts", target: "es2022", sourcemap,
|
||||
sourcefile: id })`, returning `{ code, map }`.
|
||||
|
||||
It is tooling under `scripts/`: no tests, and it must pass every lint rule.
|
||||
|
||||
**The options as presented to the user:**
|
||||
|
||||
| Option | What | Why not |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| A | Legacy decorators: two tsconfig flags (`experimentalDecorators`, ...) and a codemod removing `accessor` | the user ruled out legacy decorators |
|
||||
| B | Wait for Oxc to lower `accessor` decorators | not available yet; blocks the migration |
|
||||
| C | No decorators: `static properties = {...}` + `declare` fields | rewrites every component |
|
||||
| D | A transform step in the Vite build | chosen, with esbuild as the transformer (above) |
|
||||
|
||||
Don't add `experimentalDecorators` to `tsconfig.json`, and don't strip
|
||||
`accessor` from fields.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
description: Every repo moved from Deno to Node + Vite+ in Oct 2026 because Cloudflare bought Deno; nothing goes back to Deno
|
||||
---
|
||||
|
||||
On 2026-10-12 the user moved every project from Deno to Node.js + Vite+
|
||||
(`vp`). Cloudflare bought Deno on 2026-10-09; the runtime gets roughly 12
|
||||
months of maintenance and Deno Deploy shuts down around April 2027. None of the
|
||||
repos used Deploy or KV, so the move is about the toolchain, not hosting.
|
||||
|
||||
Do not reintroduce `deno.json`, `deno.lock`, `Deno.*` APIs, `jsr:` or `npm:`
|
||||
specifiers, `mod.ts` entries, or `deno task` anywhere, docs and scripts
|
||||
included.
|
||||
|
||||
## Order the migration followed
|
||||
|
||||
1. The custom Deno lint plugins were ported to npm first, as
|
||||
`@cunarist/eslint-plugin-explicit-types`, `-import-check`, and `-lit`.
|
||||
`cunarist/eslint-plugin-explicit-types` is the template every other repo
|
||||
copies (config shape, `scripts/project-lint.ts`, AGENTS.md, notes).
|
||||
2. Then `commons/bot-agents` (six repos use it as an action), then the apps:
|
||||
home, eximium, stockvibe, brain, memona, memona-home.
|
||||
3. deno-practice, interview-practice, web-landing, and vemium were left out
|
||||
(archivable). Old `cunarist/deno-*` repos are archived one at a time, and
|
||||
only after their `eslint-plugin-*` replacement is published to npm.
|
||||
|
||||
## What "migrated" means
|
||||
|
||||
- Same behavior. A migration PR changes tooling, not features.
|
||||
- Lint coverage is at least what Deno enforced: [[lint-parity-never-shrinks]].
|
||||
- Only three config files and only `.ts`: [[toolchain-node-vite-plus]],
|
||||
[[ts-only-files-and-imports]].
|
||||
- Deno.test suites became Vitest: [[tests-layout-vitest]].
|
||||
|
||||
The custom plugins' Deno names map like this:
|
||||
|
||||
| Deno | npm |
|
||||
| ----------------------------------------------- | ---------------------------------------- |
|
||||
| `jsr:@cunarist/deno-lint-plugin-explicit-types` | `@cunarist/eslint-plugin-explicit-types` |
|
||||
| `jsr:@cunarist/deno-import-check` (`./lint`) | `@cunarist/eslint-plugin-import-check` |
|
||||
| `jsr:@cunarist/deno-lint-plugin-lit` | `@cunarist/eslint-plugin-lit` |
|
||||
| `scripts/deno-lint-plugin.ts` (per app) | `scripts/project-lint.ts` (per app) |
|
||||
|
||||
Until those packages are on npm, a repo that depends on them fails `npm ci` in
|
||||
CI; that is expected and stated in the PR, never worked around by committing
|
||||
tarballs or `file:` dependencies.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
description: Work on a branch, plain imperative commits without AI trailers, one PR with the Claude Code footer, review until approved, never merge yourself; squash only
|
||||
---
|
||||
|
||||
## Branches and commits
|
||||
|
||||
- Never push to `main`. Every change goes through a branch and a PR, even a
|
||||
one-line fix.
|
||||
- Commit messages are plain imperative sentences ("Port the import-check
|
||||
rules to an ESLint plugin"). No conventional-commit prefixes, no AI
|
||||
trailers (`Co-Authored-By: Claude`, "Generated with ..."), no emoji.
|
||||
- A version bump is its own commit titled `Version X.Y.Z` ([[publishing-ci-only]]).
|
||||
- When several agents share a branch, each works in its own `git worktree`,
|
||||
runs `git pull --rebase` before pushing, and retries a rejected push after
|
||||
rebasing. Remove the worktree and temp branch afterwards.
|
||||
- `vp check`, `vp test`, and the build pass locally before every push.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- One PR per repo for a migration, everything included (size-rule refactors
|
||||
too).
|
||||
- The body covers: summary, what changed, deviations and risks, verification
|
||||
done, and open questions. It ends with exactly this line:
|
||||
|
||||
```
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
```
|
||||
|
||||
- Then run the review cycle (skill `review-cycle`): on Capsulizers Gitea repos
|
||||
that have `.gitea/workflows/agents.yml`, comment `@bot` and address the `bot`
|
||||
account's review; elsewhere, a subagent reviews the diff and posts the
|
||||
review as a PR comment. Repeat until fully approved, then ask the user to
|
||||
review.
|
||||
|
||||
## Merging
|
||||
|
||||
- **Never merge on your own.** The user merges, unless they explicitly allow
|
||||
it for one specific PR.
|
||||
- GitHub repos are squash-merge only, so a PR lands as one commit titled after
|
||||
the PR.
|
||||
- Also never, without the user: publish, archive a repo, or delete a branch
|
||||
the user may still use.
|
||||
|
||||
## Hosts
|
||||
|
||||
- **cunarist** and **temeddix**: GitHub only, CI in `.github/workflows`, use
|
||||
the `gh` CLI.
|
||||
- **Capsulizers**: Gitea at git.capsulizers.com (Gitea 1.26), CI in
|
||||
`.gitea/workflows`, PRs through the Gitea API. Push and open PRs as the
|
||||
user's own account (credentials in the user's environment; source them in a
|
||||
subshell and never print them); `bot` is a separate review account. The
|
||||
org's codified rules live in the `commons/code-rules` README.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
description: Icons are .ts modules exporting a typed Lit svg template (memona's style), one per file behind an index.ts; never .svg imports as text
|
||||
---
|
||||
|
||||
memona's way of declaring icons is the standard (2026-10-14). An icon is a
|
||||
`.ts` module that exports a Lit `TemplateResult` holding the inline SVG:
|
||||
|
||||
```ts
|
||||
// src/icons/lock.ts
|
||||
import { html, type TemplateResult } from "lit";
|
||||
|
||||
/** Font Awesome Free's solid `lock`, as its SVG source. */
|
||||
export const lockIcon: TemplateResult = html`<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 384 512"
|
||||
>
|
||||
<path fill="currentColor" d="M128 96l0 64 ..." />
|
||||
</svg>`;
|
||||
```
|
||||
|
||||
```ts
|
||||
// usage
|
||||
import { lockIcon } from "#icons";
|
||||
render() { return html`<span class="icon">${lockIcon}</span>`; }
|
||||
```
|
||||
|
||||
- One icon per file, camelCase name with an `Icon` suffix, a doc comment
|
||||
saying what it depicts, exported through `src/icons/index.ts`.
|
||||
- Use `fill="currentColor"` so CSS controls the colour.
|
||||
- Keep a third-party licence comment that the SVG source carries (Font
|
||||
Awesome Free's `<!--! ... -->` notice) inside the template.
|
||||
|
||||
**Replaced pattern:** eximium, stockvibe, and memona-home imported Font
|
||||
Awesome `.svg` files as text in `icons.ts`:
|
||||
|
||||
```ts
|
||||
import lock from "@fortawesome/fontawesome-free/svgs/solid/lock.svg" with { type: "text" }; // no
|
||||
```
|
||||
|
||||
That breaks [[ts-only-files-and-imports]] (non-`.ts` import plus import
|
||||
attributes) and was converted to memona's style during the frontend
|
||||
migration. Before writing new icon code in any repo, read how that repo (or
|
||||
memona) already declares icons and match it.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
description: Shared code comes through # aliases in package.json imports, each directory's entry is index.ts, barrels keep imports few; never ../ out of a directory
|
||||
---
|
||||
|
||||
## `#` aliases
|
||||
|
||||
Shared code is imported through Node subpath imports declared in
|
||||
`package.json` `imports`, never by a relative path that climbs out of a
|
||||
directory:
|
||||
|
||||
```jsonc
|
||||
"imports": {
|
||||
"#helpers": "./src/helpers/index.ts",
|
||||
"#components/*": "./src/components/*"
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
import { typeNameTail } from "#helpers"; // yes
|
||||
import { typeNameTail } from "../helpers/ast.ts"; // no: import/no-relative-parent-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.
|
||||
|
||||
- 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.
|
||||
- Keep test-only helpers in their own alias (e.g. `#testing`) so they never
|
||||
land in a bundle.
|
||||
|
||||
## `index.ts` entries and barrels
|
||||
|
||||
- Every directory's entry file is `index.ts`, never `mod.ts` (decided
|
||||
2026-10-12; import-check's `enforce-mod-file` now requires `index.ts`).
|
||||
- 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
|
||||
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.
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
description: vite.config.ts lint uses Oxlint categories at error with default limits, names no rule itself, and spreads configs from the plugins and scripts/project-lint.ts
|
||||
---
|
||||
|
||||
The lint config is short and category-driven (user decisions 2026-10-14/15):
|
||||
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"],
|
||||
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
|
||||
options: { typeAware: true, typeCheck: true, maxWarnings: 0, reportUnusedDisableDirectives: "error" },
|
||||
},
|
||||
```
|
||||
|
||||
- **Categories:** `correctness`, `suspicious`, `pedantic`, `perf` at `error`.
|
||||
`style` and `restriction` are never turned on wholesale; pick single rules
|
||||
from them in `configs.project`. Extra rules that categories bring are fine.
|
||||
- **Default limits stay** (2026-10-16): pedantic's `max-lines` 300,
|
||||
`max-lines-per-function` 50, `max-classes-per-file` 1,
|
||||
`max-dependencies` 10, `max-params` 3, `complexity` 20. Don't raise them to
|
||||
make code pass; refactor (see [[lint-overrides-policy]]). Apps follow this
|
||||
strictly. The plugin template already turns off `max-lines`,
|
||||
`max-lines-per-function`, `typescript/prefer-readonly-parameter-types`, and
|
||||
`typescript/no-unsafe-enum-comparison` because AST visitors fight them; do
|
||||
not add new opt-outs anywhere without asking the user.
|
||||
- **`vite.config.ts` names no rule.** Every rule id, built-in ones included,
|
||||
lives in `configs.project` of `scripts/project-lint.ts`
|
||||
([[project-lint-plugin]]). `vite.config.ts` only spreads configs.
|
||||
- **Published plugins are consumed through their configs**: ESLint flat config
|
||||
uses `plugin.configs.<name>`; Oxlint lists the package in `jsPlugins` and
|
||||
spreads `plugin.configs.<name>.rules`, since Oxlint plugins carry no configs.
|
||||
Use the configs equivalent to what the repo enabled under Deno.
|
||||
- **`maxWarnings: 0`** keeps Deno's "every diagnostic fails" behaviour.
|
||||
- **`ignorePatterns`** is only for generated files (compiled output, codegen).
|
||||
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; import-check'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.
|
||||
That is the expected response to any new rule: change the code.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
description: Every eslint-/oxlint-disable or -enable comment is banned in every form; three checks enforce it together, keep all three
|
||||
---
|
||||
|
||||
Banned (2026-10-16): `eslint-disable`, `eslint-disable-line`,
|
||||
`eslint-disable-next-line`, `eslint-enable`, and the same with `oxlint-`, as
|
||||
line or block comments, anywhere in the repo. Fix the code; if the code truly
|
||||
can't be fixed, ask the user, don't silence the rule. Old
|
||||
`deno-lint-ignore` lines are removed during the migration and their cause
|
||||
fixed.
|
||||
|
||||
Oxlint (1.87) has no `noInlineConfig` and no `eslint-comments` rules, and
|
||||
`respectEslintDisableDirectives: false` only ignores `eslint-*` directives. So
|
||||
three checks enforce the ban together, and none is redundant:
|
||||
|
||||
1. `project/no-disable-comments` (custom, in `scripts/project-lint.ts`) fails
|
||||
any directive.
|
||||
2. `unicorn/no-abusive-eslint-disable` fails a directive that names no rule,
|
||||
which would otherwise silence the custom rule on its own line.
|
||||
3. `options.reportUnusedDisableDirectives: "error"` fails a directive naming
|
||||
`project/no-disable-comments` (the custom rule skips that one, so the
|
||||
directive silences nothing and is reported as unused).
|
||||
|
||||
Every form was probed and fails `vp check`.
|
||||
|
||||
A rule that fires on legitimate code is a rule problem: change the rule or its
|
||||
options in `configs.project` with the user's agreement, not the call site.
|
||||
Known example: rule records are `Map`s keyed by id rather than object
|
||||
literals with kebab-case keys, which keeps naming rules happy without any
|
||||
directive.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
description: No lint overrides for source files; narrow ones only for scripts/**; generated files go in ignorePatterns; refactor (barrels, splits) instead of raising limits
|
||||
---
|
||||
|
||||
Final policy (2026-10-16). The user considers per-file `overrides` bad
|
||||
practice.
|
||||
|
||||
- **Source files: never.** No `overrides` entry may target `src/**` or any
|
||||
other hand-written production or test code.
|
||||
- **The one exception** is in lint-plugin repos: `import/max-dependencies`
|
||||
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.
|
||||
- **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.
|
||||
|
||||
## Fix the code instead
|
||||
|
||||
Oxlint's defaults stay; violations are refactored without behavior change,
|
||||
covered by the tests, inside the same migration PR.
|
||||
|
||||
| Rule | Usual fix |
|
||||
| ------------------------- | ------------------------------------------------------- |
|
||||
| `import/max-dependencies` | import a barrel (`#ui`) instead of many direct modules |
|
||||
| `max-lines` | split the file by responsibility |
|
||||
| `max-lines-per-function` | extract helpers; split a long Lit template into parts |
|
||||
| `max-classes-per-file` | one class per file |
|
||||
| `max-params` | pass one options object |
|
||||
| `prefer-set-has` | `const K: ReadonlySet<string> = new Set([...])`, `.has` |
|
||||
|
||||
```ts
|
||||
// before: 14 imports, over the limit of 10
|
||||
import { a } from "#ui/button";
|
||||
import { b } from "#ui/dialog";
|
||||
// after: one barrel
|
||||
import { a, b } from "#ui";
|
||||
```
|
||||
|
||||
Measured on memona before the move (Oxlint 1.87, default limits): 921
|
||||
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).
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
description: Lint coverage never drops below what Deno enforced; every Deno rule maps to an Oxlint rule, a plugin rule, the compiler, or our own rule
|
||||
---
|
||||
|
||||
Hard rule (2026-10-12): after the move, a repo has at least the lint coverage
|
||||
it had under Deno: built-in rules (the `recommended` and `jsr` tags plus
|
||||
opt-ins like `camelcase`, `eqeqeq`, `guard-for-in`, `no-console`,
|
||||
`no-eval`, `no-throw-literal`, `no-non-null-assertion`,
|
||||
`verbatim-module-syntax`), `@hugoalh` rules, and the custom plugins. Rules may
|
||||
be added, never lost. Formatting may change. "Gap" is not an acceptable end
|
||||
state: a Deno rule Oxlint lacks is replaced by a plugin rule or our own rule in
|
||||
[[project-lint-plugin]].
|
||||
|
||||
Before removing any line from `configs.project`, find what else covers it.
|
||||
|
||||
## How the template mapped Deno's 97 rules
|
||||
|
||||
- **Same rule** in `configs.project` at `error`.
|
||||
- **Renamed equivalent:** `no-new-symbol` → `no-new-native-nonconstructor`;
|
||||
`no-window` → `no-restricted-globals: ["error", "window"]`;
|
||||
`ban-untagged-ignore` → `unicorn/no-abusive-eslint-disable`;
|
||||
`ban-unused-ignore`, `ban-unknown-rule-code` →
|
||||
`reportUnusedDisableDirectives`; `camelcase` → built-in `id-match`
|
||||
([[naming-id-match]]).
|
||||
- **Caught by the parser or `typeCheck`:** `no-dupe-args`, `no-octal`,
|
||||
`no-await-in-sync-fn`, `no-import-assertions` (TS2880), `no-sloppy-imports`
|
||||
(TS2835), `verbatim-module-syntax` (TS1484).
|
||||
- **`no-slow-types`:** `isolatedDeclarations`, enforced by `vp pack` (TS9010)
|
||||
in packages.
|
||||
- **Vacuous:** JSX rules (no `.tsx` exists) and Deno-only rules.
|
||||
- **`@hugoalh` rules** map to Oxlint built-ins in `configs.project`.
|
||||
- **import-check's CLI cycle check** → `import/no-cycle`.
|
||||
|
||||
The template's list in `configs.project` is the baseline to copy: `curly`,
|
||||
`guard-for-in`, `no-console`, `no-empty`, `no-regex-spaces`,
|
||||
`no-restricted-globals`, `no-var`, `prefer-const`, `import/no-cycle`,
|
||||
`import/no-relative-parent-imports`, `typescript/adjacent-overload-signatures`,
|
||||
`typescript/consistent-type-imports`, `typescript/no-empty-interface`,
|
||||
`typescript/no-explicit-any`, `typescript/no-namespace`,
|
||||
`typescript/no-non-null-assertion`, the two `project/*` rules,
|
||||
`unicorn/no-abusive-eslint-disable`, `id-match`, and
|
||||
`typescript/switch-exhaustiveness-check` with
|
||||
`considerDefaultExhaustiveForUnions`.
|
||||
|
||||
Each migration PR states its parity mapping (or links the template's table in
|
||||
cunarist/eslint-plugin-explicit-types#1) so the user can check it.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
description: lit-analyzer runs from scripts/lit-check.ts, which maps "typescript" to the aliased typescript-6 devDependency; npm run lit-check in CI
|
||||
---
|
||||
|
||||
Applies to Lit repos that ran `cunarist/deno-lit-analyzer` under Deno.
|
||||
|
||||
lit-analyzer is built on TypeScript's JavaScript compiler API and is run
|
||||
against TypeScript 6 (the open question was whether it works with TS 7 /
|
||||
tsgo). The repo installs TS 6 under another name and redirects lit-analyzer
|
||||
to it, so the rest of the toolchain is unaffected (decided 2026-10-16):
|
||||
|
||||
```jsonc
|
||||
// package.json
|
||||
"scripts": { "lit-check": "node scripts/lit-check.ts" },
|
||||
"devDependencies": { "typescript-6": "npm:typescript@^6", "lit-analyzer": "..." }
|
||||
```
|
||||
|
||||
```ts
|
||||
// scripts/lit-check.ts (shape)
|
||||
import { registerHooks } from "node:module";
|
||||
|
||||
registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
// when lit-analyzer asks for "typescript", hand it TS 6
|
||||
return next(specifier === "typescript" ? "typescript-6" : specifier, context);
|
||||
},
|
||||
});
|
||||
// then run lit-analyzer
|
||||
```
|
||||
|
||||
- Node runs the `.ts` script directly (type stripping), so no build step.
|
||||
- **Gotcha:** the obvious `await import("lit-analyzer/cli.js")` fails
|
||||
`project/no-non-ts-imports` (a `.js` subpath). Import the package entry, or
|
||||
spawn the package's bin with `node:child_process`, instead.
|
||||
- CI runs `npm run lit-check` as its own step.
|
||||
- It is tooling under `scripts/`: no tests; a narrow `overrides` entry for it
|
||||
(e.g. `no-console`) is allowed if truly needed.
|
||||
|
||||
Don't replace this with a global TypeScript downgrade, a second
|
||||
`tsconfig`, or a `.js` wrapper script.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
description: Deno's camelcase is Oxlint id-match ^_*[^_]*_*$|^[^a-z]*$; leading _ for unused vars; rename snake_case imports; PascalCase types; single-letter type params
|
||||
---
|
||||
|
||||
Oxlint has no `camelcase`. Its built-in `id-match` replaces it, set in
|
||||
`configs.project` (user decision 2026-10-16, replacing a ~400-line port):
|
||||
|
||||
```ts
|
||||
"id-match": ["error", "^_*[^_]*_*$|^[^a-z]*$", {
|
||||
properties: false, classFields: false, onlyDeclarations: false,
|
||||
ignoreDestructuring: true,
|
||||
}],
|
||||
```
|
||||
|
||||
**Meaning** (Deno's `is_underscored`): after trimming leading and trailing
|
||||
underscores, the name has no `_`, **or** the name has no lowercase ASCII
|
||||
letter. `id-match` can't tell what kind of name it sees, so one regex accepts
|
||||
every valid style; other rules decide which style goes where.
|
||||
|
||||
```ts
|
||||
const userName = 1; // ok
|
||||
class UserStore {} // ok here; pascal-case-types enforces PascalCase
|
||||
const MAX_SIZE = 10; // ok: no lowercase, so UPPER_SNAKE passes
|
||||
const _unused = 1; // ok: leading _ marks an unused binding
|
||||
const user_name = 1; // error
|
||||
```
|
||||
|
||||
- **Leading underscores stay allowed** for unused parameters and variables
|
||||
(`items.map((_item, index) => index)`). The user considered banning them and
|
||||
kept them for exactly this.
|
||||
- **Rename snake_case imports at the import** (user wants this flagged):
|
||||
`import { parse_args as parseArgs } from "some-lib";`
|
||||
- `declare` lines and snake_case globals are rare and accepted as they are.
|
||||
- **Types:** classes, interfaces, type aliases, enums are PascalCase
|
||||
(`pascal-case-types` from explicit-types' `naming` config); string-union
|
||||
members are UPPER_SNAKE_CASE; no `enum`.
|
||||
- **Type parameters:** the user wants single capital letters (`T`, `K`), not
|
||||
`Item`; explicit-types PR 2 adds `single-capital-type-params` to `naming`.
|
||||
Write new code that way already.
|
||||
- No autofix exists for `id-match`; rename by hand.
|
||||
|
||||
**Known differences from Deno's `camelcase`** (against its 130 test cases):
|
||||
it misses 9 (unquoted object keys like `{ bar_baz: 1 }` and `{ bar_baz }`,
|
||||
shorthand destructuring in parameters, `{ a_b: a_b }`,
|
||||
`{ foo: bar_baz = 1 }`) and over-reports 17 (non-renamed imports, assignments
|
||||
to unresolved globals, 11 ambient `declare` cases). Beyond Deno it also checks
|
||||
type parameters, interface and type-literal members, class methods, and
|
||||
labels. The user accepted these differences.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
description: package.json key order is metadata, scripts, exports, imports, devDependencies, dependencies; standard fields only; no peerDependencies or packageManager
|
||||
---
|
||||
|
||||
## Key order (2026-10-14)
|
||||
|
||||
Metadata first, then `scripts`, `exports`, `imports`, `devDependencies`,
|
||||
`dependencies`. Oxfmt would re-sort the file, so `vite.config.ts` sets
|
||||
`fmt: { sortPackageJson: false }`; keep it, and keep the order by hand.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "@cunarist/eslint-plugin-x",
|
||||
"version": "0.1.0",
|
||||
"description": "...",
|
||||
"keywords": ["eslint", "eslint-plugin", "eslintplugin", "oxlint"],
|
||||
"license": "MIT",
|
||||
"repository": { "type": "git", "url": "git+https://github.com/..." },
|
||||
"type": "module",
|
||||
"files": ["dist"],
|
||||
"engines": { "node": ">=22" },
|
||||
"eslint-doc-generator": {/* tool options that can only live here */},
|
||||
"scripts": { "docs": "eslint-doc-generator" },
|
||||
"exports": { ".": "./dist/index.js" },
|
||||
"imports": { "#helpers": "./src/helpers/index.ts" },
|
||||
"devDependencies": { "vite-plus": "^1.1.0" },
|
||||
"dependencies": {},
|
||||
}
|
||||
```
|
||||
|
||||
The `imports` key order can carry meaning: import-check'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`).
|
||||
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
|
||||
`@eslint/core` for its public types; they stay external, never bundled).
|
||||
- **`devDependencies`** = everything else: `vite-plus`, `eslint`,
|
||||
`@typescript-eslint/parser`, `eslint-doc-generator`, `esbuild`,
|
||||
`typescript-6`, `@types/node`. ESLint is a devDependency only; plugins need
|
||||
nothing from it at runtime.
|
||||
|
||||
Refresh the lockfile with `npm install` (or `npm install
|
||||
--package-lock-only` for a version bump) and commit it with the change.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
description: scripts/project-lint.ts is the repo-local plugin "project" holding the repo's rule list (typed DummyRuleMap) and repo-only rules; never published, no tests
|
||||
---
|
||||
|
||||
Every repo has `scripts/project-lint.ts`, copied from
|
||||
`cunarist/eslint-plugin-explicit-types` and extended. It is a repo-local
|
||||
Oxlint plugin with `meta.name` `"project"`, loaded through `jsPlugins` in
|
||||
`vite.config.ts`. It holds:
|
||||
|
||||
- **`configs.project.rules`**: the repo's whole rule list beyond the published
|
||||
plugin configs: Deno parity rules, extras, per-rule options, and opt-outs,
|
||||
built-in Oxlint ids included (`curly`, `id-match`, `import/no-cycle`, ...).
|
||||
- **Custom rules**, enabled as `project/<rule>`. Every repo has
|
||||
`no-disable-comments` and `no-non-ts-imports`. Apps also port the rules of
|
||||
their old `scripts/deno-lint-plugin.ts` here under the same names and
|
||||
behavior (`no-self-sync`, `app-module-conventions`, `restricted-types`, ...).
|
||||
|
||||
Rules and plugin stay in this one file; don't add `scripts/rules/`.
|
||||
|
||||
```ts
|
||||
import type { DummyRuleMap } from "vite-plus/lint";
|
||||
|
||||
const rules: DummyRuleMap = {
|
||||
"id-match": ["error", "^_*[^_]*_*$|^[^a-z]*$", {/* ... */}],
|
||||
curly: "error",
|
||||
"project/no-disable-comments": "error",
|
||||
};
|
||||
|
||||
const plugin = {
|
||||
meta: { name: "project" },
|
||||
rules: { "no-disable-comments": noDisableCommentsRule /* ... */ },
|
||||
configs: { project: { rules } },
|
||||
};
|
||||
export default plugin;
|
||||
```
|
||||
|
||||
## Why `DummyRuleMap` and an unannotated plugin (decided 2026-10-15)
|
||||
|
||||
- `DummyRuleMap` (re-exported by `vite-plus/lint`) is Oxlint's type for
|
||||
`OxlintConfig.rules`. Despite the name it lists every built-in rule with its
|
||||
options, so a typo in a rule id or option is a type error, not just a CI
|
||||
failure. Without it, TypeScript widens `"error"` to `string`; `as const`
|
||||
doesn't help because Oxlint wants mutable tuples.
|
||||
- `@eslint/core`'s `Plugin` is too loose to read back: `configs` is optional,
|
||||
any key may be missing, and a config may be an array, so
|
||||
`projectLint.configs.project.rules` fails in `vite.config.ts` (and `!` is
|
||||
banned). Optional chaining still leaves the array case.
|
||||
- Oxlint's own `Plugin` type (`vite-plus/lint/plugins`) has no `configs`.
|
||||
- Two hand-written interfaces worked but were dropped as noise; a plain
|
||||
config object was rejected because custom rules need the plugin form anyway
|
||||
and every repo should look the same.
|
||||
|
||||
`scripts/` is tooling, not production code: never packed or published, and
|
||||
no tests. Narrow `overrides` for `scripts/**` are allowed when a script truly
|
||||
needs one (e.g. `no-console`); see [[lint-overrides-policy]].
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
description: Packages publish only from CI when a GitHub release vX.Y.Z is published, via npm trusted publishing; never npm publish by hand; notes live only on the release page
|
||||
---
|
||||
|
||||
Standing convention (2026-10-12), applied to every package:
|
||||
|
||||
- **Only CI publishes.** Publishing a GitHub release `vX.Y.Z` triggers
|
||||
`.github/workflows/publish.yml`. Never run `npm publish` locally and never
|
||||
push a bare `v*` tag. Use the `release` skill, and only when the user asked
|
||||
for a release.
|
||||
- **The workflow** checks that the tag equals `v` + `package.json` `version`,
|
||||
runs the full CI as a reusable workflow (every Node version in the matrix),
|
||||
then `vp pack` and `npm publish --access public --provenance`. A release
|
||||
marked pre-release publishes under the `next` dist-tag, others `latest`.
|
||||
It runs on Node 24 (npm 11, which trusted publishing needs) with no cache.
|
||||
- **Auth is npm trusted publishing** (OIDC, `id-token: write`), which also
|
||||
attaches provenance. npm only allows a trusted publisher on a package that
|
||||
already exists, so the very first release bootstraps with a short-lived
|
||||
token in the `NPM_TOKEN` repo secret (the workflow uses it only when set).
|
||||
Afterwards the user configures the trusted publisher for `publish.yml`,
|
||||
deletes the secret, and sets the package to disallow tokens.
|
||||
- **Release notes live only on the GitHub release page**
|
||||
(`--generate-notes`, edited there). There is no `CHANGELOG.md`; never add
|
||||
one.
|
||||
- **The version lives in two places in plugin packages:** `package.json` and
|
||||
`meta.version` in `src/index.ts`; `tests/package-meta.test.ts` fails if they
|
||||
differ.
|
||||
- **Before 1.0**, a change that breaks users (renamed rule id, a rule that
|
||||
fires on more code, removed behavior) is a minor bump.
|
||||
- **Before a release**, pack the package and lint a known-bad and a known-good
|
||||
file from separate consumer projects, once through `vp lint` with
|
||||
`jsPlugins` and once through ESLint 9 with the configs. On the lit plugin
|
||||
this caught two bugs that 313 unit tests missed.
|
||||
|
||||
The cunarist lint plugins (explicit-types, import-check, lit) are published
|
||||
together, with a token the user provides; the old `deno-*` repos are archived
|
||||
only after that, one at a time.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
Decided 2026-10-14 for all repos.
|
||||
|
||||
- **Runner:** Vitest through `vp test`. Import test APIs from
|
||||
`vite-plus/test` (`describe`, `expect`, `it`).
|
||||
- **Unit tests** sit next to the code: `src/foo/bar.ts` is tested in
|
||||
`src/foo/bar.test.ts`. Vitest's default include finds them; no `test`
|
||||
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 (an alias like `#plugin`), never
|
||||
internals. `vp test tests` runs just these.
|
||||
- **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`,
|
||||
release helpers).
|
||||
- Test files follow every lint rule; no relaxed overrides for tests.
|
||||
|
||||
## Migrating Deno tests
|
||||
|
||||
`Deno.test` suites were converted by a codemod
|
||||
(`~/.local/share/deno-migration/src/convert.ts` on the user's machine), then
|
||||
its TODOs finished by hand. Watch for:
|
||||
|
||||
- `@std/assert` → `expect` (`assertEquals` → `toStrictEqual` or `toBe`,
|
||||
`assertThrows` → `toThrow`, `assertRejects` → `rejects.toThrow`);
|
||||
- `Deno.*` file, env, and process APIs → `node:fs`, `process.env`,
|
||||
`node:child_process`;
|
||||
- `t.step` → nested `describe`/`it`;
|
||||
- sanitizer options, which Vitest doesn't have.
|
||||
|
||||
A migration PR must keep every test case; a dropped case is lost coverage.
|
||||
Tests must pass locally before any push.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
description: Node.js 22+ 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+).
|
||||
- **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.
|
||||
- **Toolchain:** Vite+ (`vite-plus` devDependency, command `vp`): Vite,
|
||||
Vitest, Oxlint, Oxfmt, type checking, tsdown.
|
||||
- **Package manager:** npm everywhere. `package-lock.json` is committed in
|
||||
apps _and_ libraries (user agreed 2026-10-14: `npm ci` needs it). There is no
|
||||
`packageManager` field; Vite+ detects npm from the lockfile.
|
||||
|
||||
## Config files
|
||||
|
||||
Exactly three, plus the lockfile: `package.json`, `tsconfig.json`,
|
||||
`vite.config.ts`. No `.oxlintrc.json`, `.prettierrc`, `eslint.config.*`,
|
||||
`vitest.config.*`, `.npmrc`, or `tsdown.config.*`. Tool options go into
|
||||
`vite.config.ts` keys (`lint`, `fmt`, `test`, `pack`) or, when a tool only
|
||||
reads `package.json` (eslint-doc-generator), into a `package.json` key.
|
||||
The target is a short `vite.config.ts`, around 30 lines (not strict).
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Does |
|
||||
| ------------------------ | ---------------------------------------------------------- |
|
||||
| `vp check` | format check, Oxlint (type-aware), and type check together |
|
||||
| `vp check --fix` | the same, applying formatting and safe fixes |
|
||||
| `vp test` | Vitest; `vp test tests` runs only the top-level `tests/` |
|
||||
| `vp build` | Vite production build (apps) |
|
||||
| `vp dev` | Vite dev server (apps) |
|
||||
| `vp pack` | tsdown library build into `dist/` (packages) |
|
||||
| `npm ci` / `npm install` | install; never another package manager |
|
||||
|
||||
`vp check`, `vp test`, and the build (`vp build` or `vp pack`) must all pass
|
||||
before any push. CI installs the toolchain with `voidzero-dev/setup-vp`.
|
||||
|
||||
## Gotchas found during the migration
|
||||
|
||||
- `vp check` does **not** report `isolatedDeclarations` errors (TS9010); only
|
||||
`vp pack` does, so packages run `vp pack` in CI on every PR.
|
||||
- Oxfmt sorts `package.json` keys by default, which destroys the required key
|
||||
order. Set `fmt: { sortPackageJson: false }` ([[package-json-shape]]).
|
||||
- Oxlint and Oxfmt skip what `.gitignore` lists; that is the only reason
|
||||
`dist/` and `node_modules/` are not linted. Keep build output gitignored.
|
||||
- Vite+ bundles `vite.config.ts` itself and resolves `#` imports in it.
|
||||
- Oxlint loads a `.ts` plugin file directly on Node 24 (`jsPlugins`), so
|
||||
repo-local lint plugins need no build step.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
description: Every file is .ts (no js/mjs/cjs/jsx/tsx, configs included) and source imports only .ts; CSS comes from HTML, JSON/SVG become .ts modules
|
||||
---
|
||||
|
||||
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.
|
||||
|
||||
## Only `.ts` imports (2026-10-14)
|
||||
|
||||
`project/no-non-ts-imports` (in `scripts/project-lint.ts`) fails:
|
||||
|
||||
- a relative, absolute, `#`, or package-subpath specifier whose last segment
|
||||
has a non-`.ts` extension: `.css`, `.json`, `.svg`, `.js`, `.mjs`, `.tsx`;
|
||||
- any `?query` suffix (`?raw`, `?url`, `?inline`);
|
||||
- any import attributes (`with { type: "json" }`, `with { type: "text" }`).
|
||||
|
||||
Bare packages (`lit`, `@types/json-schema`, `lodash.debounce`), `#` aliases,
|
||||
and `node:` builtins pass. `@fortawesome/x/svgs/a.svg` fails.
|
||||
|
||||
```ts
|
||||
import "./theme.css"; // no
|
||||
import data from "./scenes.json" with { type: "json" }; // no
|
||||
import icon from "./lock.svg?raw"; // no
|
||||
import { scenes } from "#data/scenes"; // yes: scenes.ts
|
||||
```
|
||||
|
||||
## What to do instead
|
||||
|
||||
- **CSS:** load it from HTML with `<link rel="stylesheet" href="...">`. Never
|
||||
import CSS from TS, not even for Vite. (This also removes most of the
|
||||
`import/no-unassigned-import` hits the `suspicious` category reports.)
|
||||
- **JSON data:** turn it into a `.ts` module exporting a typed constant.
|
||||
- **SVG:** a `.ts` module exporting a Lit template; see [[icons-memona-style]].
|
||||
- **`package.json` fields at runtime:** never `import pkg from
|
||||
"./package.json"` and there is no `#package` alias. Write the value out in
|
||||
`.ts` and add a test in `tests/` that reads `package.json` with `node:fs`
|
||||
and fails on drift (the plugin repos do this for `meta.name`/`meta.version`).
|
||||
- **Reading a file is not importing it.** `readFileSync` + `JSON.parse` is
|
||||
fine in tooling and tests.
|
||||
- **A tool's `.js` entry:** don't `import("pkg/cli.js")`; use the package
|
||||
entry or spawn the package's bin ([[lit-analyzer-ts6]]).
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
description: Ask one decision at a time with a code example, follow ESLint/ecosystem conventions and ask before deviating, keep config minimal, and never block when the user is away
|
||||
---
|
||||
|
||||
## Asking
|
||||
|
||||
- **One question at a time.** Each decision question stands alone, with the
|
||||
options and a recommendation.
|
||||
- **Show code.** The user is new to the ESLint ecosystem and asked for
|
||||
example code blocks whenever possible (2026-10-14): before/after snippets
|
||||
or config examples, not prose alone.
|
||||
|
||||
```ts
|
||||
// A. per-file override // B. refactor with a barrel
|
||||
overrides: [{ files: [...] }] import { a, b } from "#ui";
|
||||
```
|
||||
|
||||
- **Keep answers short.** State the decision and what changed; detail belongs
|
||||
in `.agents/memory/`, not chat.
|
||||
- The user often probes with "why?" and "is this the simplest form?". Have
|
||||
the reason ready, and prefer the option with fewer lines, fewer files, and
|
||||
fewer custom types when the result is equivalent.
|
||||
|
||||
## Conventions over invention
|
||||
|
||||
- Follow ESLint's and the wider ecosystem's conventions (typescript-eslint,
|
||||
unicorn as references): `docs/rules/<rule>.md`, one plugin per package,
|
||||
flat configs at `plugin.configs`, `meta.docs.url`, `index.ts` entries.
|
||||
- **Any deviation is a question, not a default**, even one carried over from
|
||||
the Deno version. The user was unhappy when rule docs were quietly placed in
|
||||
`src/` ("You should have asked me about this though. We should respect
|
||||
ESLint's way").
|
||||
- Before finishing structural work, compare against well-known projects and
|
||||
list every difference for the user.
|
||||
|
||||
## When the user is away
|
||||
|
||||
- **Never block on the user.** No approval requests, no waiting. Pick the
|
||||
conservative option consistent with these notes, write the choice and its
|
||||
reason in the PR or report, and continue. (An idle approval request once
|
||||
cost hours; the user was emphatic.)
|
||||
- Log open questions where the user will look: the PR body, and the shared
|
||||
questions file the coordinator names (during the migration,
|
||||
`~/.local/share/deno-migration/morning-questions.md`).
|
||||
- Hard limits still hold without asking: no merging, no publishing, no
|
||||
archiving, no pushing to `main`, no external state changes.
|
||||
|
||||
## Working style the user expects from agents
|
||||
|
||||
- Keep the main conversation free: repo edits, commits, and long checks run
|
||||
in background subagents.
|
||||
- Fan independent work out to many parallel subagents (more than five when it
|
||||
splits), each in its own worktree when they share a branch.
|
||||
Reference in New Issue
Block a user