From f05b42f1eecfec47d7a46a8d2cd27338324c78dd Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 25 Sep 2026 00:50:03 +0700 Subject: [PATCH 1/2] refactor(core): let Node decide the config module format The loader asked Node for the format it chose and overrode only `commonjs-typescript`, instead of walking package.json files and checking the source text for ESM syntax. That removes a duplicated resolution rule, a cache, and a heuristic whose false positive silently switched a CommonJS-authored file to ESM. The rule is now one sentence: TypeScript in the config graph loads as ESM when Node would read it as CommonJS. The loader test covers the commonjs, module, and absent type fields. --- .changeset/loader-node-format.md | 10 +++ packages/cssforge/src/loader.ts | 67 +++++---------------- packages/cssforge/tests/load-config.test.ts | 46 ++++++++------ 3 files changed, 50 insertions(+), 73 deletions(-) create mode 100644 .changeset/loader-node-format.md diff --git a/.changeset/loader-node-format.md b/.changeset/loader-node-format.md new file mode 100644 index 0000000..622df2b --- /dev/null +++ b/.changeset/loader-node-format.md @@ -0,0 +1,10 @@ +--- +"@hebilicious/cssforge": patch +--- + +Read project TypeScript in the config graph as ESM whenever Node would read it as CommonJS. The loader +asks Node for the format it chose instead of repeating the package.json lookup and guessing from the +source text. + +A CommonJS-authored `.ts` module inside a CommonJS package now loads as ESM, which matches the documented +`export default` config shape. diff --git a/packages/cssforge/src/loader.ts b/packages/cssforge/src/loader.ts index ffd3b1b..7e91d79 100644 --- a/packages/cssforge/src/loader.ts +++ b/packages/cssforge/src/loader.ts @@ -8,9 +8,9 @@ * @module */ -import { existsSync, readFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { registerHooks } from "node:module"; -import { dirname, join, resolve } from "node:path"; +import { resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import type { CSSForgeConfig } from "./config.ts"; @@ -45,45 +45,6 @@ const isLocalFileUrl = (url: URL) => const isProjectTypeScriptUrl = (url: URL) => isLocalFileUrl(url) && url.pathname.endsWith(".ts"); -/** The `type` field of the nearest package.json, cached per directory. */ -const packageTypeCache = new Map(); - -const packageTypeOf = (path: string): string | undefined => { - const cached = packageTypeCache.get(path); - if (cached !== undefined) return cached; - - let directory = dirname(path); - - while (true) { - const manifestPath = join(directory, "package.json"); - if (existsSync(manifestPath)) { - try { - const parsed: unknown = JSON.parse(readFileSync(manifestPath, "utf8")); - const type = - typeof parsed === "object" && parsed !== null && "type" in parsed - ? (parsed as { type?: unknown }).type - : undefined; - const value = typeof type === "string" ? type : undefined; - packageTypeCache.set(path, value); - return value; - } catch { - packageTypeCache.set(path, undefined); - return undefined; - } - } - - const parent = dirname(directory); - if (parent === directory) { - packageTypeCache.set(path, undefined); - return undefined; - } - directory = parent; - } -}; - -/** Whether a module's source is written as ESM rather than CommonJS. */ -const usesEsmSyntax = (source: string) => /^\s*(?:export|import)\b/m.test(source); - /** Describes a rejected default export for the error message. */ const describeValue = (value: unknown): string => { if (value === null) return "null"; @@ -132,21 +93,21 @@ const loadConfigFile = async (absolutePath: string): Promise => { return nextLoad(url, context); } - // A package that declares CommonJS turns module syntax detection off, so - // a config or token module written as ESM fails to parse. Force the ESM - // TypeScript format for those files, and leave CommonJS-authored ones to - // Node so a working setup keeps working. - const path = fileURLToPath(parsed); - if (packageTypeOf(path) !== "commonjs") { - return nextLoad(url, context); - } + const loaded = nextLoad(url, context); - const source = readFileSync(path, "utf8"); - if (!usesEsmSyntax(source)) { - return nextLoad(url, context); + // Node answers the module-format question itself, so the loader does not + // repeat its package.json lookup. A package that declares CommonJS makes + // Node read the config as CommonJS, which rejects the documented + // `export default` shape; everything else already loads as ESM. + if (loaded.format !== "commonjs-typescript") { + return loaded; } - return { format: "module-typescript", source, shortCircuit: true }; + return { + format: "module-typescript", + source: loaded.source ?? readFileSync(fileURLToPath(parsed), "utf8"), + shortCircuit: true, + }; }, }); diff --git a/packages/cssforge/tests/load-config.test.ts b/packages/cssforge/tests/load-config.test.ts index 688d980..e289f68 100644 --- a/packages/cssforge/tests/load-config.test.ts +++ b/packages/cssforge/tests/load-config.test.ts @@ -111,28 +111,34 @@ Deno.test("loadConfig - returns the default export and every local file the conf }); }); -Deno.test("loadConfig - loads a TypeScript config in a CommonJS project", async () => { +Deno.test("loadConfig - loads a TypeScript config whatever the package type declares", async () => { // An explicit `"type": "commonjs"` makes Node read `.ts` as CommonJS and turns // off module syntax detection, so `export default` would be a syntax error. - await inConfigDir( - { - "package.json": `{ "name": "cjs-project", "version": "0.0.0", "type": "commonjs" }\n`, - ...configFiles, - }, - async (dir) => { - const { first } = runScenario(dir); - - assertEquals(first.config.spacing?.custom?.size?.value, { - 2: "0.5rem", - 4: "1rem", - }); - assertEquals(first.dependencies.map((path) => path.replace(dir, "")).sort(), [ - "/cssforge.config.ts", - "/nested.ts", - "/tokens.ts", - ]); - }, - ); + const manifests = [ + ["commonjs", `{ "name": "cjs", "version": "0.0.0", "type": "commonjs" }`], + ["module", `{ "name": "esm", "version": "0.0.0", "type": "module" }`], + ["absent", `{ "name": "typeless", "version": "0.0.0" }`], + ] as const; + + for (const [label, manifest] of manifests) { + await inConfigDir( + { "package.json": `${manifest}\n`, ...configFiles }, + async (dir) => { + const { first } = runScenario(dir); + + assertEquals( + first.config.spacing?.custom?.size?.value, + { 2: "0.5rem", 4: "1rem" }, + `type field: ${label}`, + ); + assertEquals( + first.dependencies.map((path) => path.replace(dir, "")).sort(), + ["/cssforge.config.ts", "/nested.ts", "/tokens.ts"], + `type field: ${label}`, + ); + }, + ); + } }); Deno.test("loadConfig - re-evaluates a token module the config imports on the next load", async () => { From 5082edada55fb090ef6d08727f55d6b512445864 Mon Sep 17 00:00:00 2001 From: Hebilicious Date: Fri, 25 Sep 2026 00:50:03 +0700 Subject: [PATCH 2/2] docs(agents): require review before merging a pull request Merging needs an explicit instruction from the user for that pull request. Opening one, green checks, and finished-looking work are not instructions. Release pull requests are included. --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 01f5c48..50596a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,10 @@ standard library or writing code from first principles. branch and worktree. Read-only inspection may remain in the current checkout. - Run `git status` before editing and review the complete diff afterward. Preserve unrelated changes and commit only when explicitly requested. +- Open a pull request and stop there. Never merge a pull request that a human has not reviewed, + including release pull requests, and never merge one because the work looks finished or the + checks are green. Merging needs an explicit instruction from the user for that specific pull + request, and opening a pull request is not that instruction. Report the pull request URL and wait. - Define repository operations in `moon.yml` or `.moon/tasks/**/*.yml`, not `package.json` scripts. Run existing operations through their Moon targets; use pnpm directly only for dependency management.