Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/astro-plugin-doctor-cwd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rrlab/astro-plugin": patch
---

`rr tsc doctor` looks for what `astro check` needs, `@astrojs/check` and `typescript`, in each package it checks instead of the directory `rr` was started from. Run at a monorepo root, it used to look for them there, and reported them missing when only the Astro package had them.
5 changes: 5 additions & 0 deletions .changeset/astro-plugin-non-astro-packages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rrlab/astro-plugin": patch
---

In a monorepo, `rr tsc` and `rr check` run `astro check` only in the Astro packages, the ones with an `astro.config.*` or with `astro` in `dependencies`, `devDependencies` or `peerDependencies`, and `tsc --noEmit` in the other packages with a `tsconfig.json`. `astro check` used to run in every one of them, so in a plain TypeScript package it booted Astro, printed Vite warnings and wrote `.astro/` into the package. Such a package needs only `typescript`, and `rr tsc doctor` there checks `tsc`.
5 changes: 5 additions & 0 deletions .changeset/cli-tsc-doctor-workspace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rrlab/cli": minor
---

`rr tsc doctor` checks the packages `rr tsc` checks. In a monorepo it runs the health check in each workspace package with a `tsconfig.json`, one row per package, instead of once at the workspace root, which `rr tsc` never checks. The other `doctor` commands and `rr doctor` run in the package they report on, the one with the nearest `package.json`, instead of the directory `rr` was started from. Plugins get that package as `doctor({ cwd })`, typed by the new `DoctorOptions`.
2 changes: 1 addition & 1 deletion run-run/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ If you find yourself tempted to add a `pnpm <script>` that exists only to call `
| Path | npm name | Role |
|---|---|---|
| `cli/` | `@rrlab/cli` | The kernel. Commander program, plugin registry, `rr` bin. Deep-dive in `cli/CLAUDE.md`. |
| `astro-plugin/` | `@rrlab/astro-plugin` | `tsc` via `astro check`, which covers `.astro` and `.ts` files alike. |
| `astro-plugin/` | `@rrlab/astro-plugin` | `tsc` via `astro check`, which covers `.astro` and `.ts` files alike. A monorepo's packages without Astro get `tsc --noEmit`. |
| `biome-plugin/` | `@rrlab/biome-plugin` | `lint` + `format` + `jsc` via Biome. |
| `oxc-plugin/` | `@rrlab/oxc-plugin` | `lint` (oxlint) + `format` (oxfmt) + `tsc` (oxlint type-aware). `jsc` is composed by the kernel. |
| `ts-plugin/` | `@rrlab/ts-plugin` | `tsc` (TypeScript). Has `install()` that scaffolds `tsconfig.json` from a preset. |
Expand Down
8 changes: 6 additions & 2 deletions run-run/astro-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@ It needs `@rrlab/cli`, Astro 5 or later, `@astrojs/check` 0.9 or later, and Type
| --- | --- |
| `typecheck` | `rr tsc` (workspace-aware in monorepos), `rr check`, `rr tsc doctor` |

`rr tsc` runs `astro check` in each package with a `tsconfig.json`. It reports the same diagnostics as `astro check`, for `.astro` files and plain `.ts` files alike, and fails when `astro check` exits non-zero, which it does on any error by default.
`rr tsc` runs `astro check` in an Astro package: one with an `astro.config.*`, or with `astro` in its `dependencies`, `devDependencies` or `peerDependencies`. It reports the same diagnostics as `astro check`, for `.astro` files and plain `.ts` files alike, and fails when `astro check` exits non-zero, which it does on any error by default.

In a monorepo, `rr tsc` checks each package with a `tsconfig.json`. It runs `astro check` in the Astro packages and `tsc --noEmit` in the others, so `astro` type-checks the whole workspace.

`astro check` also checks your `.ts` files, so in an Astro project it replaces `ts`. Configure one `typecheck` provider. If another plugin also provides one, such as `ts` or `oxc`, narrow it: `oxc({ only: ["lint", "format"] })`.

If `@astrojs/check` or `typescript` is missing, `rr tsc` fails and names it. Left alone, `astro check` would prompt to install them instead of checking anything.
If `@astrojs/check` or `typescript` is missing, `rr tsc` fails and names it. Left alone, `astro check` would prompt to install them instead of checking anything. A package that isn't an Astro package needs only `typescript`.

`rr tsc doctor` checks the packages `rr tsc` checks, each for the checker `rr tsc` runs there: `astro` and what `astro check` needs in an Astro package, `tsc` in the others.

## Removal

Expand Down
194 changes: 176 additions & 18 deletions run-run/astro-plugin/src/__tests__/check.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createShellService } from "@variablelab/clibuddy";
import { satisfies } from "semver";
import { afterEach, describe, expect, it } from "vitest";
import { AstroCheckService } from "../index.ts";
import { AstroCheckService, isAstroProject } from "../index.ts";

// These run the real `astro check` against a tmp Astro project, so they are slow.
const TIMEOUT = 60_000;
const PLUGIN_NODE_MODULES = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../node_modules");

// astro refuses to start on a Node older than its own `engines.node` (astro 7 needs a
// newer one than this repo's floor), so the tests that run it are skipped there. The
// missing-dependency tests never start astro, so they run everywhere.
// missing-dependency tests and those of a package without Astro never start astro, so
// they run everywhere.
const astroPkg = JSON.parse(readFileSync(path.join(PLUGIN_NODE_MODULES, "astro/package.json"), "utf8")) as {
version: string;
engines: { node: string };
};
const ASTRO_RUNS = satisfies(process.versions.node, astroPkg.engines.node);
Expand All @@ -24,28 +26,49 @@ const CLEAN_PAGE = `---\nconst answer: number = 42;\n---\n<p>{answer}</p>\n`;
const BROKEN_PAGE = `---\nconst answer: number = "forty-two";\n---\n<p>{answer}</p>\n`;
const CLEAN_TS = "export const ok: number = 1;\n";
const BROKEN_TS = 'export const bad: number = "not a number";\n';
const ALL_DEPS = ["astro", "@astrojs/check", "typescript"];

let tmpDir: string | undefined;
const manifest = (fields: Record<string, unknown> = {}) =>
`${JSON.stringify({ name: "astro-fixture", private: true, type: "module", ...fields })}\n`;
// A plain TS package's tsconfig: nothing from astro to extend.
const PLAIN_TSCONFIG = `${JSON.stringify({
compilerOptions: { target: "es2022", module: "esnext", moduleResolution: "bundler", strict: true, noEmit: true },
include: ["src"],
})}\n`;

const tmpDirs: string[] = [];

afterEach(async () => {
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
await Promise.all(tmpDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});

/**
* A minimal Astro project shaped like www (the `astro/tsconfigs/strict` preset,
* `.astro/types.d.ts` included). Its node_modules links the given packages from
* this plugin's own devDependencies, so `astro check` resolves them from the
* project root as it would in a host project.
* A minimal Astro project shaped like www (`astro` in its dependencies, the
* `astro/tsconfigs/strict` preset, `.astro/types.d.ts` included). Its
* node_modules links the given packages from this plugin's own devDependencies,
* so `astro check` resolves them from the project root as it would in a host
* project.
*/
async function makeProject(files: Record<string, string>, deps = ["astro", "@astrojs/check", "typescript"]) {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rr-astro-plugin-"));
const all = {
"package.json": `${JSON.stringify({ name: "astro-fixture", private: true, type: "module" })}\n`,
"tsconfig.json": `${JSON.stringify({ extends: "astro/tsconfigs/strict", include: [".astro/types.d.ts", "**/*"] })}\n`,
...files,
};
for (const [rel, content] of Object.entries(all)) {
function makeProject(files: Record<string, string>, deps = ALL_DEPS) {
return makeDir(
{
"package.json": manifest({ dependencies: { astro: astroPkg.version } }),
"tsconfig.json": `${JSON.stringify({ extends: "astro/tsconfigs/strict", include: [".astro/types.d.ts", "**/*"] })}\n`,
...files,
},
deps,
);
}

/** A plain TS package, as a monorepo has next to its Astro app: no `astro` dependency, no `astro.config.*`. */
function makePackage(files: Record<string, string>, deps = ["typescript"]) {
return makeDir({ "package.json": manifest(), "tsconfig.json": PLAIN_TSCONFIG, ...files }, deps);
}

async function makeDir(files: Record<string, string>, deps: string[] = []) {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rr-astro-plugin-"));
tmpDirs.push(tmpDir);
for (const [rel, content] of Object.entries(files)) {
const abs = path.join(tmpDir, rel);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, content);
Expand All @@ -62,6 +85,40 @@ function service(cwd: string) {
return new AstroCheckService(createShellService({ cwd }), cwd);
}

describe("isAstroProject()", () => {
it.each(["dependencies", "devDependencies", "peerDependencies"])("is true with astro in %s", async (field) => {
const dir = await makeDir({ "package.json": manifest({ [field]: { astro: "^7.0.0" } }) });
expect(await isAstroProject(dir)).toBe(true);
});

it.each(["mjs", "js", "ts", "mts", "cjs", "cts"])("is true with an astro.config.%s and no astro dependency", async (ext) => {
const dir = await makeDir({ "package.json": manifest(), [`astro.config.${ext}`]: "export default {};\n" });
expect(await isAstroProject(dir)).toBe(true);
});

it("is false with neither", async () => {
const dir = await makeDir({ "package.json": manifest({ dependencies: { react: "^19.0.0" } }) });
expect(await isAstroProject(dir)).toBe(false);
});

it("reads the package's own manifest, not its workspace root's", async () => {
const root = await makeDir({
"package.json": manifest({ devDependencies: { astro: "^7.0.0" } }),
"packages/lib/package.json": manifest(),
});
expect(await isAstroProject(path.join(root, "packages/lib"))).toBe(false);
});

it.each([
["missing", {}],
["malformed", { "package.json": "{ not json" }],
["not an object", { "package.json": "null\n" }],
])("is false when package.json is %s", async (_, files: Record<string, string>) => {
const dir = await makeDir(files);
expect(await isAstroProject(dir)).toBe(false);
});
});

describe("AstroCheckService.check()", () => {
it.runIf(ASTRO_RUNS)(
"passes on a project without type errors",
Expand Down Expand Up @@ -98,6 +155,75 @@ describe("AstroCheckService.check()", () => {
TIMEOUT,
);

it.runIf(ASTRO_RUNS)(
"runs astro check in a component library, with astro as a peer dependency and no config",
async () => {
// tsc can't check a .astro file (no inputs), so a library without a config still needs astro check.
const cwd = await makeProject({
"package.json": manifest({ peerDependencies: { astro: ">=5.0.0" } }),
"src/Button.astro": BROKEN_PAGE,
});
const report = await service(cwd).check({ cwd });
expect(report.ok).toBe(false);
expect(report.output).toContain("$ astro check");
expect(report.output).toContain("src/Button.astro");
},
TIMEOUT,
);

it.runIf(ASTRO_RUNS)(
"runs astro check in a project with an astro.config but no astro dependency of its own",
async () => {
// As in a monorepo app whose `astro` is hoisted to the workspace root.
const cwd = await makeProject({
"package.json": manifest(),
"astro.config.mjs": "export default {};\n",
"src/pages/index.astro": CLEAN_PAGE,
});
const report = await service(cwd).check({ cwd });
expect(report.ok, report.output).toBe(true);
expect(report.output).toContain("$ astro check");
},
TIMEOUT,
);

it(
"runs tsc, not astro check, in a package without Astro",
async () => {
// A monorepo's plain TS package, where astro's deps resolve as they do once hoisted. astro check
// ran there, booted Astro, warned about Vite and wrote `.astro/` into the package.
const cwd = await makePackage({ "src/bad.ts": BROKEN_TS }, ALL_DEPS);
const report = await service(cwd).check({ cwd });
expect(report.ok).toBe(false);
expect(report.output).toContain("$ tsc --noEmit --pretty");
expect(report.output).not.toContain("$ astro check");
expect(report.output).toContain("src/bad.ts");
expect(report.output).toMatch(/Type 'string' is not assignable to type 'number'/);
expect(existsSync(path.join(cwd, ".astro"))).toBe(false);
},
TIMEOUT,
);

it(
"passes a package without Astro and without type errors",
async () => {
const cwd = await makePackage({ "src/ok.ts": CLEAN_TS });
const report = await service(cwd).check({ cwd });
expect(report.ok, report.output).toBe(true);
expect(report.output).toContain("$ tsc --noEmit --pretty");
},
TIMEOUT,
);

it("fails naming only typescript when a package without Astro can't resolve it", async () => {
const cwd = await makePackage({ "src/ok.ts": CLEAN_TS }, []);
const report = await service(cwd).check({ cwd });
expect(report.ok).toBe(false);
expect(report.output).toMatchInlineSnapshot(
`"@rrlab/astro-plugin requires typescript to be installed in the host project. Run: rr plugins add astro (or: pnpm add -D typescript)"`,
);
});

it("fails without running astro when @astrojs/check is missing", async () => {
// Left to itself, astro would prompt to install it: a hang under capture, a false pass without stdin.
const cwd = await makeProject({ "src/pages/index.astro": BROKEN_PAGE }, ["astro", "typescript"]);
Expand All @@ -123,4 +249,36 @@ describe("AstroCheckService.doctor()", () => {
expect(report.ok).toBe(false);
expect(report.output).toContain("requires @astrojs/check, typescript to be installed");
});

it("checks tsc, the checker it runs there, in a package without Astro", async () => {
const cwd = await makePackage({});
const report = await service(cwd).doctor();
expect(report.ok, report.output).toBe(true);
expect(report.output).toContain("$ tsc --help");
});

it("asks only for typescript in a package without Astro", async () => {
const cwd = await makePackage({}, []);
const report = await service(cwd).doctor();
expect(report.ok).toBe(false);
expect(report.output).toContain("requires typescript to be installed");
expect(report.output).not.toContain("@astrojs/check");
});

it("checks the package it's given, not the one rr runs in", async () => {
// `rr tsc doctor` at a workspace root hands it each package `rr tsc` checks.
const root = await makeProject({}, ["astro"]);
const pkg = await makePackage({});
const report = await service(root).doctor({ cwd: pkg });
expect(report.ok, report.output).toBe(true);
expect(report.output).toContain("$ tsc --help");
});

it("fails for a package it's given that can't run its checker, wherever rr runs", async () => {
const root = await makeProject({});
const pkg = await makeProject({}, ["astro"]);
const report = await service(root).doctor({ cwd: pkg });
expect(report.ok).toBe(false);
expect(report.output).toContain("requires @astrojs/check, typescript to be installed");
});
});
Loading
Loading