diff --git a/.claude/skills/writing-unit-tests/SKILL.md b/.claude/skills/writing-unit-tests/SKILL.md new file mode 100644 index 0000000..8dd98f0 --- /dev/null +++ b/.claude/skills/writing-unit-tests/SKILL.md @@ -0,0 +1,50 @@ +--- +name: writing-unit-tests +description: Use when writing or editing *.test.ts files in this repo (@gravity-ui/create). +--- + +# Writing Unit Tests (gravity-ui/create) + +## Overview + +Reference skill distilled from this repo's AGENTS.md files. Native `node:test`, colocated `*.test.ts`, never touch real disk. + +## Quick Reference + +| Rule | Why | +|---|---| +| Use `test.describe` + `test(name, (t: TestContext) => ...)` with `t.assert.*` | Not top-level `node:assert/strict` + nested `await t.test()` — lint/prettier normalizes to the former anyway | +| Colocate `foo.test.ts` next to `foo.ts` | Repo convention, no `__tests__` dirs | +| Generators: never call real `node:fs` | Always take a `FileSystem` param; tests pass `memfs.ts` | +| Generator tests: `runGenerators({dryRun: true})`, assert on captured files | Never write to real disk in a test | +| `nvm use && npm test` after any behavior change | `.nvmrc` pins Node; wrong Node breaks the TS loader | +| Chicago school: assert on state (captured files, return values), not call-args | London-style interaction mocks aren't used anywhere in this repo's tests | + +## Chicago school (state-based), not London (interaction-based) + +Verify state/output of the code under test, not "was function X called with args Y" on its collaborators. Concretely in this repo: + +- `mock.module('node:fs', ...)` in `destination.test.ts` mocks a builtin, but still fits Chicago school: it substitutes a working fake (backed by `memfs` from the `memfs` npm package) at the one true I/O boundary, and the tests assert on `validateDestination`'s return value (state) — never on whether `existsSync` was called. The distinction is state-vs-interaction verification, not "mocking is banned." +- Don't add call-count/call-args assertions (`t.mock.fn`/`t.mock.method` + `assert(called with...)`) for code that takes a `FileSystem` param — pass a real `memfs` instance and assert on what it captured instead. + +## Mocking `node:fs` directly + +`eslint.config.js`'s `no-restricted-imports` scopes which file(s) may import `node:fs`/`node:fs/promises` directly — check that rule to find the current file(s). When testing one of those: + +`mock.module('node:fs', ...)` only rebinds modules that import the mocked specifier *directly*. Once such a module has been evaluated and its top-level `import {existsSync} from 'node:fs'` linked, `mock.restore()` + a fresh `mock.module()` does **not** rebind it — Node's ESM cache never re-evaluates the module for the same specifier. Remocking per test silently reuses the *first* test's mock in every later test, with no error. + +**Working pattern**: +- Mock `node:fs` **once** at module top-level, backed by a single `memfs()` volume. +- Import the module under test once. +- Give each test its own path namespace (`/case-/cwd/...`) inside that shared volume instead of remocking per test. + +## Testing pure logic split out of I/O + +When a module mixes pure decision logic with unavoidable I/O (terminal prompts, `p.note` formatting), pull the decision logic into a small pure function over plain data and unit-test that directly — e.g. `buildNextSteps(model, cwd)` extracted from `main()` in `src/index.ts`. Don't try to test the I/O wrapper. + +## Common Mistakes + +- Writing to real disk in a generator test instead of `{dryRun: true}` + memfs. +- Remocking `node:fs` per test instead of once at module top-level. +- Nested `await t.test()` instead of flat `test.describe`/`test`. +- Mocking a direct import of the module under test (e.g. `mock.module`-ing `utils/isModulePackage.js` while testing `generators/base.ts`) instead of driving it with real input data. These are plain functions over the `model`/args already in scope — construct a `model` that produces the branch you want, don't intercept the import. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4981a65 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +README.md linguist-generated=true diff --git a/.gitignore b/.gitignore index c09ef2f..e2ab9bd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ /test-results /playwright-report /blob-report +/docs/superpowers/ +/lcov.info diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9dc76c0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# AGENTS.md + +## What this is + +`@gravity-ui/create` — CLI that scaffolds a new project on the gravity-ui stack. Asks a few questions (or reads CLI flags), builds a `ProjectModel`, then runs generators that write files into the chosen destination folder. + +## Source layout + +- `src/cli` — arg parsing, help/version/dry-run rendering. Flag behavior itself lives in `CliSchema` (see Conventions). +- `src/prompts` — interactive questionnaire (`@clack/prompts`). +- `src/model` — `ProjectModel`: mutable accumulator, built up by prompts, consumed and further mutated by generators. +- `src/generators` — one file per feature. Each generator registers deps/scripts on the model (via `utils/pm.ts`) and writes files through the `FileSystem` abstraction. + - `templates/` — `.hbs` sources; see `templates/AGENTS.md` for the precompile hook and an eslint-config-specific gotcha. +- `src/utils` — `fs.ts` is the only file eslint allows to import `node:fs`/`node:fs/promises`/`fs` (`no-restricted-imports`, scoped to this one file, covers sync too) — any new fs-touching helper must live here. +- `e2e` — Playwright specs driving CLI-scaffolded output, run via `.github/workflows/e2e.yml`. See `e2e/AGENTS.md`. +- Any `__fixtures__` dir anywhere under `src/` is test-only, excluded from the publish build (`tsconfig.publish.json`'s glob exclude) — never put anything meant to ship in `lib` there. + +## Checks + +All `npm`/`node`/`npx` commands below MUST be prefixed with `nvm use &&` — wrong Node version breaks the TS loader. + +- To run a one-off `.ts` file directly, plain `node file.ts` fails on module resolution — source imports use `.js` extensions (NodeNext) that don't exist on disk. Use: `node --import ./scripts/test-runner-register.js file.ts` (same loader hook `npm test` registers). +- `npm test` — run after any behavior change, always. +- `npm test -- --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info --test-coverage-exclude='**/*.test.ts' --test-coverage-exclude='**/__fixtures__/**' --test-coverage-exclude='**/*.hbs.ts' --test-coverage-exclude='**/.*.hbs.ts' --test-coverage-include='src/**'` — coverage report, on demand. + - **Gap**: node's coverage only instruments files actually `import`ed during the run — an untouched file is silently absent from `lcov.info` (not listed at 0%), inflating the reported %. Find genuinely untested files: diff `find src -name '*.ts' ! -name '*.test.ts' ! -path '*/__fixtures__/*' ! -name '*.hbs.ts'` against `grep '^SF:' lcov.info | sed 's/^SF://'` — anything only on the left was never touched by a test. +- `npx tsc --noEmit` — run after any change, always. Running it directly (vs `npm run typecheck`) skips the `pretypecheck` hook that regenerates `.hbs.ts` — see the `.hbs` note below if templates changed. +- `npm run lint -- --fix` and `npm run lint:prettier` — run once a file is believed done (style pass, not per-edit). +- `npm run knip` — run before declaring work finished, to catch unused exports left behind by a refactor. The dummy `E2E_APP_DIR=.` in its script exists only so knip's Playwright plugin can import `playwright.config.ts` (which throws if unset) — it's never used to actually launch anything. +- `.hbs` templates compile to gitignored `.hbs.ts` (never hand-edit). Regen is hooked into `pretest`/`pretypecheck`/`prebuild`/`preknip`, so `npm test`/`npm run knip` always see fresh output — `npx tsc --noEmit` doesn't, run `npm run precompile-templates` manually first if `.hbs` files changed. See `templates/AGENTS.md`. +- `README.md` is generated (see header comment) — `scripts/generate-readme.test.ts` catches drift. + +## Conventions + +- **Types**: put `types.ts` next to the code that owns it. Only hoist to a shared parent (`generators/types.ts`, `utils/types.ts`) when colocating would cause a circular import. +- **Filesystem**: generators never touch `node:fs` directly — always take a `FileSystem` param, so `--dry-run` and tests can swap in `memfs` instead of writing to disk. +- **CLI flags**: add fields to `CliSchema` only — parsing, per-flag help text (description/placeholder), and validation all derive from it automatically. Don't hand-wire a new flag into `parseArgs` separately. Known gap: `help.ts`'s `Examples` section is hand-written prose (command strings with literal flag names), not derived from the schema — renaming a flag silently leaves it stale, so grep the old name there too. +- **Tests**: native `node:test` (`npm test`), colocated as `*.test.ts` next to the source under test. See `writing-unit-tests` skill for conventions/gotchas. +- **Style**: never hand-fix styling/formatting (import order, quotes, indentation) — run the lint/prettier commands from `## Checks` instead, even for a single misplaced import. +- **View/logic split for testability**: when a module mixes pure data-building with rendering (e.g. terminal `p.note` formatting), separate the two — e.g. `src/cli/dryRun.ts`'s `buildDryRunSummary` (pure, unit-tested) vs `renderDryRunSummary`/`formatDryRunSummary` (terminal output). +- **Derived flags**: a simple single-field check (e.g. `model.hasBackend`) stays inline. Once a decision needs more than one `ProjectModel` field (e.g. `hasAppBuilder = hasBackend || hasFrontend`), add it to `calculateFlags(model)` (`src/utils/calculateFlags.ts`) instead of a one-off predicate. +- **Generated files**: every JS/TS output file goes through a `.hbs` template — no generator writes JS/TS content directly. JSON files are the exception, built via `writeJson`/`JSON.stringify` instead. + +## Generated project structure + +See [README.md](./README.md#what-gets-generated) for the three shapes and the app-builder directory layout. + +Dev/build scripts pick a side via `--target ` when only one of ui/server exists. + +Plain-JS shape's entry stays at project root, not under `src/`: with no bundler and no frontend/backend, there's no convention to commit to, so it stays at the root instead of guessing a layout. + +## Valid parameter combinations + +`styles`/`react` are only settable when frontend is enabled — enforced twice: `CliSchema`'s `.refine()` checks (`schema.ts`) and structurally by `runPromptFlow.ts` (styles/react prompts only run `if (model.frontend)`). Keep both in sync if this changes. So the frontend axis collapses to 5 states — `false`, or enabled × `{none, styles-only, react-only, styles+react}` — not a raw 2×2 sub-cross. + +Combined with `language` (2) and `hasBackend` (2): **20** distinct configs. `registry` (default vs. custom URL) is an independent axis, only toggling whether `generateBase` writes `.npmrc`. No package-manager (npm/yarn/pnpm) axis exists. + +What's actually observable at runtime per combo (browser vs stdout vs nothing), and current e2e coverage gaps: see `e2e/AGENTS.md`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index fdc7723..98574ba 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ + + # @gravity-ui/create > Scaffold a Gravity UI stack project in seconds. @@ -7,3 +12,104 @@ ```bash npm create @gravity-ui ``` + +## Flags + +**Project options** + +| Flag | Description | +| --- | --- | +| `--out ` | Destination folder for the new project | +| `--language ` | Project language (ts\|js) | +| `--frontend, --no-frontend` | Include frontend setup | +| `--styles, --no-styles` | Include stylelint (requires --frontend) | +| `--react, --no-react` | Include React + JSX transform (requires --frontend) | +| `--backend, --no-backend` | Include nodekit backend | +| `--registry ` | Custom npm registry | + +**Mode options** + +| Flag | Description | +| --- | --- | +| `-y, --yes` | Accept defaults, skip prompts | +| `--dry-run` | Show what would be generated without writing files | + +**Other** + +| Flag | Description | +| --- | --- | +| `-h, --help` | Show this help and exit | +| `-v, --version` | Print version and exit | + + +## Examples + +```bash +npm create @gravity-ui +``` + +fully interactive + +```bash +npm create @gravity-ui -- --out my-app +``` + +specify path + +```bash +npm create @gravity-ui -- --out my-package --language ts --no-frontend --no-backend -y +``` + +basic TypeScript package + +```bash +npm create @gravity-ui -- --out my-api --language ts --no-frontend --backend -y +``` + +TypeScript backend service + +```bash +npm create @gravity-ui -- --out my-app --dry-run +``` + +preview without writing + +## What gets generated + +Three shapes: app-builder layout (frontend and/or backend selected), plain TS entry (neither, `ts`), plain JS entry (neither, `js`). + +**App-builder layout.** Worked example below is `ts` / backend / frontend + styles + react (all features on); annotations mark what's conditional: + +``` +. +|-- .gitignore +|-- .prettierrc.js +|-- .stylelintrc.json # only with styles +|-- README.md +|-- app-builder.config.ts +|-- eslint.config.mjs +|-- package.json +|-- tsconfig.json # references ui/ and/or server/; both languages +`-- src/ + |-- server/ # only if hasBackend + | |-- index.ts # expresskit+nodekit+app-layout init; ext by language + | `-- tsconfig.json + `-- ui/ # only if frontend enabled + |-- tsconfig.json + |-- entries/ + | `-- my-app-app.tsx # entry (>=1 allowed); name from project; ext/case by react+language + |-- components/ # only if react enabled + | |-- index.ts # re-exports App; ext by language + | `-- App/ + | `-- App.tsx # demo component; ext by language + `-- types/ + `-- assets.d.ts # only ts+react +``` + +**Neither frontend nor backend, TypeScript** — plain `src/index.ts`, no app-builder, straight `tsc` compile. + +**Neither frontend nor backend, JavaScript** — plain `index.js` at project root. + +## Requirements + +Node.js `^22.13.0 || >=23.5.0`. diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md new file mode 100644 index 0000000..36c6917 --- /dev/null +++ b/e2e/AGENTS.md @@ -0,0 +1,30 @@ +# e2e/AGENTS.md + +Playwright specs that assert on CLI-scaffolded output rendered in a real browser. Not run by `npm test` — driven by `.github/workflows/e2e.yml`, which builds the CLI, scaffolds one or more throwaway projects into `/tmp`, and points Playwright at whichever one is under test. + +## Targeting + +- All specs flat in one `testDir` (config) — no per-shape split. +- CI job picks target app by setting `E2E_APP_DIR`/`APP_PORT` before `npx playwright test`, not by pointing at a different testDir. +- Since one unfiltered run would hit every spec against whatever's scaffolded, each spec is tagged `{tag: '@name'}` (e.g. `@frontend-react`/`@frontend-no-react`) and each CI job filters `--grep @tag`. New spec file → add its tag to the right job's grep, else other jobs pick it up and fail. + +## What's actually observable per combo (verified by running each, not just reading templates) + +`language` and `styles` never affect runtime output — only `hasFrontend`/`hasBackend`/`hasReact` do. That collapses the 20 configs in the root AGENTS.md's parameter matrix to 4 buckets: + +| hasFrontend | hasBackend | what you can assert | where | +|---|---|---|---| +| false | false | stdout `Hello, world!` after `npm run build && npm start` | plain entry (`src/index.ts` or root `index.js`), CI `test-cli` job — **ts only, no js coverage** | +| false | true | HTTP body is the literal string `Hello, world!`, **no HTML markup at all** despite `Content-Type: text/html` — verified via curl. A Playwright `h1` locator finds nothing; must assert raw body text | not covered by any CI job | +| true | false | **nothing app-related to assert.** `npm run dev` (`app-builder dev --target client`) only builds the JS bundle to `dist/public/build/`; hitting `/` in a browser gets webpack-dev-server's raw directory listing, not the app — there's no HTML shell without a backend to render `@gravity-ui/app-layout` | not covered, not coverable as scaffolded | +| true | true | `GET /` renders the `@gravity-ui/app-layout` shell with a real `h1` — assert via `assertHeading` fixture (below). Text is `Hello, Gravity UI!` for `--react`, `Hello, world!` for `--no-react`; `--styles` never changes it | CI: `test-e2e-browser` (react, ts only) + `test-e2e-no-react` (no-react, ts **and** js) | + +Gaps if adding coverage: no js-language run of the CLI-stdout or react-frontend jobs, and the backend-only / frontend-only buckets have no test at all. + +## `--out` must resolve inside the invoking process's cwd + +`validateDestination` (`src/utils/destination.ts`) rejects `--out` resolving outside cwd. So `.github/workflows/e2e.yml` scaffolds via `working-directory: /tmp` + relative `--out`, calling CLI by absolute `$GITHUB_WORKSPACE/lib/index.js`. Changing either side: check the other didn't break. + +## Fixtures + +`fixtures.ts` extends Playwright's base `test` with an `assertHeading(expected)` fixture (goto `/` + assert `h1` text). New spec asserting `h1` after `goto('/')`: use `assertHeading`, don't inline goto+expect. Different assertion shape (different element, multiple assertions, no goto): add a new fixture to `fixtures.ts`, not inline boilerplate — specs stay tag + expected value only. diff --git a/package.json b/package.json index 9b012d7..04e1b32 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "lint": "eslint --quiet", "lint:prettier": "prettier --list-different '**/*.ts'", "precompile-templates": "node scripts/precompile-templates.ts", + "generate-readme": "node --import ./scripts/test-runner-register.js scripts/generate-readme.ts > README.md", "pretest": "npm run precompile-templates", "test": "node --experimental-test-module-mocks --import ./scripts/test-runner-register.js --test", "test:e2e": "playwright test", diff --git a/scripts/generate-readme.test.ts b/scripts/generate-readme.test.ts new file mode 100644 index 0000000..045a031 --- /dev/null +++ b/scripts/generate-readme.test.ts @@ -0,0 +1,21 @@ +import {execFile as execFileCb} from 'node:child_process'; +import path from 'node:path'; +import {type TestContext, test} from 'node:test'; +import {promisify} from 'node:util'; + +import readme from '../README.md' with {type: 'text'}; + +const execFile = promisify(execFileCb); +const rootDir = path.join(import.meta.dirname, '..'); + +test.describe('generate-readme', () => { + test('README.md matches generated output', async (t: TestContext) => { + const {stdout} = await execFile( + process.execPath, + ['--import', './scripts/test-runner-register.js', 'scripts/generate-readme.ts'], + {cwd: rootDir}, + ); + + t.assert.strictEqual(stdout, readme); + }); +}); diff --git a/scripts/generate-readme.ts b/scripts/generate-readme.ts new file mode 100644 index 0000000..74e5e00 --- /dev/null +++ b/scripts/generate-readme.ts @@ -0,0 +1,46 @@ +import {readFile} from 'node:fs/promises'; +import path from 'node:path'; + +import Handlebars from 'handlebars'; + +import {buildHelpData} from '../src/cli/help.js'; +import type {FlagSignature} from '../src/cli/types.js'; + +const rootDir = path.join(import.meta.dirname, '..'); +const templatesDir = path.join(import.meta.dirname, 'readme'); + +function formatSignature({parts, placeholder}: FlagSignature): string { + return placeholder ? `${parts.join(', ')} ${placeholder}` : parts.join(', '); +} + +function buildContext(pkg: {engines: {node: string}}) { + const {groups, examples} = buildHelpData(); + + return { + groups: groups.map(({label, flags}) => ({ + label, + flags: flags.map((flag) => ({ + signature: formatSignature(flag.signature), + description: flag.choices + ? `${flag.description} (${flag.choices.join('|')})` + : flag.description, + })), + })), + examples: examples.map(([cmd, comment]) => ({cmd, comment})), + engines: pkg.engines.node, + }; +} + +const [readmeSource, flagsTableSource, pkgSource] = await Promise.all([ + readFile(path.join(templatesDir, 'readme.hbs'), 'utf8'), + readFile(path.join(templatesDir, 'flagsTable.hbs'), 'utf8'), + readFile(path.join(rootDir, 'package.json'), 'utf8'), +]); + +Handlebars.registerHelper( + 'tableCell', + (value: string) => new Handlebars.SafeString(value.replaceAll('|', '\\|')), +); +Handlebars.registerPartial('flagsTable', flagsTableSource); +const template = Handlebars.compile(readmeSource); +process.stdout.write(template(buildContext(JSON.parse(pkgSource)))); diff --git a/scripts/md.d.ts b/scripts/md.d.ts new file mode 100644 index 0000000..827adb7 --- /dev/null +++ b/scripts/md.d.ts @@ -0,0 +1,4 @@ +declare module '*.md' { + const content: string; + export default content; +} diff --git a/scripts/readme/flagsTable.hbs b/scripts/readme/flagsTable.hbs new file mode 100644 index 0000000..179e65b --- /dev/null +++ b/scripts/readme/flagsTable.hbs @@ -0,0 +1,10 @@ +{{#each groups}} +**{{this.label}}** + +| Flag | Description | +| --- | --- | +{{#each this.flags}} +| `{{tableCell this.signature}}` | {{tableCell this.description}} | +{{/each}} + +{{/each}} diff --git a/scripts/readme/readme.hbs b/scripts/readme/readme.hbs new file mode 100644 index 0000000..3225381 --- /dev/null +++ b/scripts/readme/readme.hbs @@ -0,0 +1,68 @@ + + +# @gravity-ui/create + +> Scaffold a Gravity UI stack project in seconds. + +## Quick start + +```bash +npm create @gravity-ui +``` + +## Flags + +{{> flagsTable}} + +## Examples + +{{#each examples}} +```bash +{{this.cmd}} +``` + +{{this.comment}} + +{{/each}} +## What gets generated + +Three shapes: app-builder layout (frontend and/or backend selected), plain TS entry (neither, `ts`), plain JS entry (neither, `js`). + +**App-builder layout.** Worked example below is `ts` / backend / frontend + styles + react (all features on); annotations mark what's conditional: + +``` +. +|-- .gitignore +|-- .prettierrc.js +|-- .stylelintrc.json # only with styles +|-- README.md +|-- app-builder.config.ts +|-- eslint.config.mjs +|-- package.json +|-- tsconfig.json # references ui/ and/or server/; both languages +`-- src/ + |-- server/ # only if hasBackend + | |-- index.ts # expresskit+nodekit+app-layout init; ext by language + | `-- tsconfig.json + `-- ui/ # only if frontend enabled + |-- tsconfig.json + |-- entries/ + | `-- my-app-app.tsx # entry (>=1 allowed); name from project; ext/case by react+language + |-- components/ # only if react enabled + | |-- index.ts # re-exports App; ext by language + | `-- App/ + | `-- App.tsx # demo component; ext by language + `-- types/ + `-- assets.d.ts # only ts+react +``` + +**Neither frontend nor backend, TypeScript** — plain `src/index.ts`, no app-builder, straight `tsc` compile. + +**Neither frontend nor backend, JavaScript** — plain `index.js` at project root. + +## Requirements + +Node.js `{{{engines}}}`. diff --git a/scripts/ts-extension-override.js b/scripts/ts-extension-override.js index 750ca26..05620c4 100644 --- a/scripts/ts-extension-override.js +++ b/scripts/ts-extension-override.js @@ -1,4 +1,5 @@ -export async function resolve(specifier, context, nextResolve) { +/** @type {import('node:module').ResolveHook} */ +export const resolve = async (specifier, context, nextResolve) => { if (specifier.endsWith('.js')) { const tsSpecifier = specifier.replace(/\.js$/, '.ts'); try { @@ -9,4 +10,19 @@ export async function resolve(specifier, context, nextResolve) { } } return nextResolve(specifier, context); -} +}; + +/** @type {import('node:module').LoadHook} */ +export const load = async (url, context, nextLoad) => { + if (url.endsWith('.md')) { + const {readFile} = await import('node:fs/promises'); + const {fileURLToPath} = await import('node:url'); + const source = await readFile(fileURLToPath(url), 'utf8'); + return { + format: 'module', + source: `export default ${JSON.stringify(source)};`, + shortCircuit: true, + }; + } + return nextLoad(url, context); +}; diff --git a/src/generators/templates/AGENTS.md b/src/generators/templates/AGENTS.md new file mode 100644 index 0000000..d75d67e --- /dev/null +++ b/src/generators/templates/AGENTS.md @@ -0,0 +1,5 @@ +# templates/ + +- New generated file uses Node APIs but isn't under `src/server` (which gets node globals via `serverConfig`)? Add it to `eslint.config.js.hbs`'s `files: [...]` block (gate by right `{{#if}}`) — else `no-undef`/`process is not defined`. +- Check blank-line/conditional rendering across flag combos: `npm run precompile-templates`, then use root AGENTS.md's one-off `.ts` runner to import the compiled `.hbs.ts` default with different context objects. Faster than eyeballing `.hbs` source. +- Never name a `.hbs` file so precompiled output contains `.d.ts` mid-filename (e.g. `assets.d.ts.hbs` → `assets.d.ts.hbs.ts`) — tsc treats it as ambient, silently skips emit. Fix: avoid substring in source name (e.g. `assets.d-ts.hbs`). Detect: diff `find src/generators/templates -name '*.hbs'` vs `find lib/generators/templates -name '*.js'` after build. diff --git a/tsconfig.json b/tsconfig.json index b8ca721..0107c78 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "moduleResolution": "NodeNext", "outDir": "./lib", "resolveJsonModule": true, + "checkJs": true, "types": ["node"] }, "include": ["src/**/*", "scripts/**/*", "e2e/**/*", "playwright.config.ts"]