@agjs/tsforge 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agjs/tsforge",
3
3
  "type": "module",
4
- "version": "0.2.9",
4
+ "version": "0.3.1",
5
5
  "license": "MIT",
6
6
  "description": "TypeScript coding harness with a deterministic gate, stack-aware guardrails, and stream-level correction.",
7
7
  "repository": {
@@ -313,7 +313,7 @@ Co-locate by domain under \`src/features/<domain>/\`:
313
313
  <domain>.types.ts — entity types, discriminated unions, branded IDs
314
314
  <domain>.constants.ts — \`as const\` registries / label maps (typed Record<Union, V>)
315
315
  <domain>.service.ts — async data access (seeded/mock async with latency + failure paths)
316
- <domain>.hooks.ts — ONLY genuine derived/computed state (the data hook is the SDK's useCollection; do NOT write a fetch/query wrapper)
316
+ <domain>.hooks.ts — ONLY genuine derived/computed state (the data hook is the SDK's useResource; do NOT write a fetch/query wrapper)
317
317
  <PascalCase>.tsx — ONE component per file
318
318
  index.ts — barrel re-exporting the public surface
319
319
  Shared shadcn primitives live in \`src/components/ui/\` (already scaffolded). Routes/pages are TanStack files under \`src/routes/\`.
@@ -10,6 +10,7 @@ import { readFiles, type IFileView } from "../lib/fs";
10
10
  import { EDIT_TOOL, CREATE_TOOL, TOOL_NAME } from "./agent.constants";
11
11
  import { toEdits, toCreate } from "./tools";
12
12
  import { ruleHelp } from "../loop/feedback";
13
+ import { DEFAULT_TEMPERATURE } from "../loop/loop.constants";
13
14
 
14
15
  /**
15
16
  * The errors the agent can actually act on: those in its editable files (plus
@@ -54,7 +55,7 @@ export function modelAgent(
54
55
  toolChoice: "auto",
55
56
  // Temp 0 by default — the eval sweep showed it's decisively better for
56
57
  // convergence on coding tasks (temp 0: 100% pass vs temp 0.5: 0%).
57
- temperature: options.temperature ?? 0,
58
+ temperature: options.temperature ?? DEFAULT_TEMPERATURE,
58
59
  // Cap reasoning so the quality-repair pass can't ramble unbounded
59
60
  // ("writing novels"): the budget that bounds the implement loop must
60
61
  // bound this agent too, or the cap leaks.
@@ -389,20 +389,11 @@ async function crawlRoutes(
389
389
  for (const route of routes) {
390
390
  try {
391
391
  await page.goto(`${base}${route}`, { waitUntil: "load", timeout });
392
- // Let the client router + first paint settle before the blank check (the
393
- // shell/nav renders immediately, so this only flags genuinely dead routes).
394
- await page.waitForTimeout(150);
395
392
 
396
- const blank = await page.evaluate(() => {
397
- const root =
398
- document.querySelector("#root") ??
399
- document.querySelector("#app") ??
400
- document.body;
401
-
402
- return root.children.length === 0 || root.textContent.trim() === "";
403
- });
404
-
405
- if (blank) {
393
+ // Poll for first paint before the blank check, so a route that mounts
394
+ // ASYNCHRONOUSLY (after MSW's worker.start() resolves, or any await-before-
395
+ // render bootstrap) isn't misjudged dead. Only a genuinely empty root fails.
396
+ if (!(await waitForMount(page))) {
406
397
  errors.push(`route ${route} rendered blank`);
407
398
  continue;
408
399
  }
@@ -433,15 +424,30 @@ const SMOKE_CLICK_LIMIT = 5;
433
424
  * or console error surfaces via the page handlers wired in renderCheck. No per-app
434
425
  * knowledge, so it never needs the model to author (flaky) checks.
435
426
  */
436
- async function runSmoke(page: Page, errors: string[]): Promise<void> {
437
- const mounted = await page.evaluate(() => {
438
- const root =
439
- document.querySelector("#root") ??
440
- document.querySelector("#app") ??
441
- document.body;
427
+ /** Wait for the app to paint content into its root — polls up to `timeoutMs`, so an
428
+ * app that renders ASYNCHRONOUSLY (after MSW's worker.start() resolves, or any
429
+ * await-before-mount bootstrap) is not misjudged "blank" by a check that fires the
430
+ * instant `load` does. Returns true once mounted, false on timeout. */
431
+ async function waitForMount(page: Page, timeoutMs = 5000): Promise<boolean> {
432
+ return page
433
+ .waitForFunction(
434
+ () => {
435
+ const root =
436
+ document.querySelector("#root") ??
437
+ document.querySelector("#app") ??
438
+ document.body;
442
439
 
443
- return root.children.length > 0 && root.textContent.trim() !== "";
444
- });
440
+ return root.children.length > 0 && root.textContent.trim() !== "";
441
+ },
442
+ undefined,
443
+ { timeout: timeoutMs, polling: 100 }
444
+ )
445
+ .then(() => true)
446
+ .catch(() => false);
447
+ }
448
+
449
+ async function runSmoke(page: Page, errors: string[]): Promise<void> {
450
+ const mounted = await waitForMount(page);
445
451
 
446
452
  if (!mounted) {
447
453
  errors.push("app did not mount: root is blank after load");
@@ -349,17 +349,57 @@ export function webGuidance(framework: WebFramework): string {
349
349
  * progress to the terminal. Required before the gate's tsc + vite build can run.
350
350
  * Skipped when deps are already present. Returns false on a failed install. */
351
351
  export async function installWebDeps(cwd: string): Promise<boolean> {
352
- if (await Bun.file(join(cwd, "node_modules", ".bin", "vite")).exists()) {
353
- return true;
352
+ if (!(await Bun.file(join(cwd, "node_modules", ".bin", "vite")).exists())) {
353
+ const proc = Bun.spawn(["bun", "install"], {
354
+ cwd,
355
+ stdout: "inherit",
356
+ stderr: "inherit",
357
+ });
358
+
359
+ if ((await proc.exited) !== 0) {
360
+ return false;
361
+ }
354
362
  }
355
363
 
356
- const proc = Bun.spawn(["bun", "install"], {
357
- cwd,
358
- stdout: "inherit",
359
- stderr: "inherit",
360
- });
364
+ await initMswWorker(cwd);
361
365
 
362
- return (await proc.exited) === 0;
366
+ return true;
367
+ }
368
+
369
+ /** Lay down `public/mockServiceWorker.js` via the MSW CLI — the in-browser mock
370
+ * API the React scaffold relies on. The worker script is version-matched to the
371
+ * installed msw (so we never ship a stale, version-pinned blob) and Vite copies
372
+ * `public/` into `dist/`, so the gate's built-app smoke serves it at
373
+ * `/mockServiceWorker.js`. No-op when msw isn't installed (e.g. the vanilla
374
+ * scaffold) or the worker already exists; best-effort so it never breaks install. */
375
+ async function initMswWorker(cwd: string): Promise<void> {
376
+ const hasMsw = await Bun.file(
377
+ join(cwd, "node_modules", "msw", "package.json")
378
+ ).exists();
379
+ const alreadyInit = await Bun.file(
380
+ join(cwd, "public", "mockServiceWorker.js")
381
+ ).exists();
382
+
383
+ if (!hasMsw || alreadyInit) {
384
+ return;
385
+ }
386
+
387
+ try {
388
+ // `--no-save`, NOT a bare `init`: bare `init` (save flag absent) drops into an
389
+ // interactive @inquirer "save the worker dir to package.json?" prompt, which has
390
+ // no TTY in this headless pipeline and crashes the msw child with ExitPromptError.
391
+ // `--save` would answer it but rewrites package.json un-prettified (fails the
392
+ // gate's format check). `--no-save` copies the worker, skips package.json, and
393
+ // never prompts. The worker lands at the default `/mockServiceWorker.js`, which
394
+ // `worker.start()` finds without any config.
395
+ await Bun.spawn(["bunx", "msw", "init", "public", "--no-save"], {
396
+ cwd,
397
+ stdout: "inherit",
398
+ stderr: "inherit",
399
+ }).exited;
400
+ } catch {
401
+ // best-effort — a missing worker surfaces as a clear runtime error in the gate
402
+ }
363
403
  }
364
404
 
365
405
  /** The full web ladder: `vite build` + tsc strict + web eslint (vendored-exempt) +
@@ -1,3 +1,23 @@
1
1
  /** A throwaway prefix the model may always write to — `scratch/` experiments are
2
2
  * ignored by the gate, so it can test hypotheses by running code. */
3
3
  export const SCRATCH_PREFIX = "scratch/";
4
+
5
+ /**
6
+ * VENDORED, harness-authored files the model must NEVER edit or create. These are
7
+ * tested, already-type-correct SDK/primitive/generated files: the web scaffold's
8
+ * `src/lib/**` toolkit, the `src/components/ui/**` primitives, the MSW mock
9
+ * machinery (`src/mocks/db.ts` + `src/mocks/browser.ts`), and any `*.gen.ts`
10
+ * codegen output (TanStack's route tree). They are eslint- and prettier-ignored,
11
+ * so a model that touches them sees tsc errors it cannot fix and — with
12
+ * eslint-disable + `@ts-*` suppressions banned — has no escape, looping to the
13
+ * turn cap. A write to any of these is rejected: a type error involving them is
14
+ * always a wrong CALL SITE, never the library. (`src/mocks/handlers.ts` is NOT
15
+ * vendored — the model registers its mock resources there.)
16
+ */
17
+ export const VENDORED_PATTERNS = [
18
+ "src/lib/**",
19
+ "src/components/ui/**",
20
+ "src/mocks/db.ts",
21
+ "src/mocks/browser.ts",
22
+ "**/*.gen.ts",
23
+ ] as const;
@@ -1,5 +1,5 @@
1
1
  import { resolve, relative } from "node:path";
2
- import { SCRATCH_PREFIX } from "./scope.constants";
2
+ import { SCRATCH_PREFIX, VENDORED_PATTERNS } from "./scope.constants";
3
3
 
4
4
  /**
5
5
  * Normalize a model-supplied path against the workspace root, fixing the common
@@ -27,6 +27,13 @@ export function isInScope(file: string, patterns: string[]): boolean {
27
27
  return patterns.some((pattern) => new Bun.Glob(pattern).match(file));
28
28
  }
29
29
 
30
+ /** True when `file` is a VENDORED, harness-authored file the model must not
31
+ * touch (`src/lib/**`, `src/components/ui/**`, the MSW machinery, `*.gen.ts`).
32
+ * Expects the workspace-relative form (`normalizeWorkspacePath` first). */
33
+ export function isVendored(file: string): boolean {
34
+ return VENDORED_PATTERNS.some((pattern) => new Bun.Glob(pattern).match(file));
35
+ }
36
+
30
37
  /** A file the model may write: its editable scope, OR a throwaway scratch file.
31
38
  * A path that escapes the workspace (`../…`) or is absolute is NEVER writable —
32
39
  * a recursive glob would otherwise match a traversal path. Normalize with
@@ -144,6 +144,11 @@ const RULE_DOCS: Record<string, IRuleDoc> = {
144
144
  bad: "<ul>{items.filter((i) => i.visible).map((i) => <li key={i.id}>{i.label}</li>)}</ul>",
145
145
  good: "const rows = useMemo(() => items.filter(...).map(...), [items]); return <ul>{rows}</ul>;",
146
146
  },
147
+ "tsforge/no-loading-text-use-skeleton": {
148
+ what: "Loading states render a <Skeleton/> shaped like the content — never 'Loading…' text or a spinner. Add `skeleton` via scaffold_ui.",
149
+ bad: "if (isLoading) { return <p>Loading…</p>; }",
150
+ good: 'if (isLoading) { return <Skeleton className="h-8 w-full" />; }',
151
+ },
147
152
  "tsforge/no-state-in-component-body": {
148
153
  what: "State hooks (`useState`, `useEffect`, `useMemo`, …) belong in `Component.hooks.ts`, not in the `.tsx` component body.",
149
154
  bad: "export function Button() { const [open, setOpen] = useState(false); return <button />; }",
@@ -17,6 +17,10 @@ export const SPEC_STATUS = {
17
17
  blocked: "blocked",
18
18
  } as const;
19
19
 
20
+ /** Default sampling temperature for main agent turns (Session, runTask, modelAgent).
21
+ * Auxiliary one-shot calls (compaction, plan summary, judge) stay at 0. */
22
+ export const DEFAULT_TEMPERATURE = 0.2;
23
+
20
24
  /**
21
25
  * Loop tuning — kept with the loop domain (not a global bucket). Each value's
22
26
  * rationale lives here so a tuning pass sees the whole budget at a glance.
package/src/loop/run.ts CHANGED
@@ -3,7 +3,12 @@ import type { IChatMessage, IModelResponse, IProvider } from "../inference";
3
3
  import { validate, type ErrorParser } from "../validate";
4
4
  import { parseEslintJson } from "../validate";
5
5
  import { readFiles } from "../lib/fs";
6
- import { RUN_STATUS, STUCK_REASON, LOOP_LIMITS } from "./loop.constants";
6
+ import {
7
+ DEFAULT_TEMPERATURE,
8
+ RUN_STATUS,
9
+ STUCK_REASON,
10
+ LOOP_LIMITS,
11
+ } from "./loop.constants";
7
12
  import type {
8
13
  IRunResult,
9
14
  IRunOptions,
@@ -215,7 +220,7 @@ export async function runTask(
215
220
  ): Promise<IRunResult> {
216
221
  const { parse, enableThinking, thinkingTokenBudget } = opts;
217
222
  const effectiveParse = effectiveParserFor(parse);
218
- const temperature = opts.temperature ?? 0;
223
+ const temperature = opts.temperature ?? DEFAULT_TEMPERATURE;
219
224
  const maxTurns = opts.maxTurns ?? LOOP_LIMITS.maxTurns;
220
225
  // Buffer every event so the post-run memory hook can mine the run for
221
226
  // failure→fix lessons, while still forwarding live to the real reporter.
@@ -27,7 +27,7 @@ import {
27
27
  } from "../config/tsforge-config";
28
28
  import { connectMcpServers } from "../mcp";
29
29
  import { loadAndRegisterPlugins } from "../config/external-plugins";
30
- import { LOOP_LIMITS, RUN_STATUS } from "./loop.constants";
30
+ import { DEFAULT_TEMPERATURE, LOOP_LIMITS, RUN_STATUS } from "./loop.constants";
31
31
  import type { Reporter, ILoopEvent } from "./loop.types";
32
32
  import type { TtsrManager } from "./ttsr";
33
33
  import { initTtsrManager, applyTtsrInterrupt } from "./ttsr-init";
@@ -263,15 +263,16 @@ const INTERIM_CHECK_NOTE =
263
263
  const IMPLEMENT_STEP =
264
264
  "STEP 2 of 2 — build the app in THIS ORDER, so every file compiles the moment " +
265
265
  "you write it (each step depends only on earlier ones — no forward references):\n" +
266
- "1) DATA LAYER — each domain's seed + service (`createCollection`). Small files; " +
267
- "emit them together.\n" +
266
+ "1) DATA LAYER — each domain's faker seed (in <feature>.constants.ts) + ONE " +
267
+ "`mockResource('/api/x', SEED)` line registered in src/mocks/handlers.ts. Small " +
268
+ "files; emit them together.\n" +
268
269
  "2) ROUTES — call `scaffold_routes` ONCE with EVERY page the app needs (list, " +
269
270
  "detail with $param like /accounts/$accountId, and create/edit like " +
270
271
  "/deals/create). This writes all route files at once, so from here every " +
271
272
  "<Link to>/navigate target type-checks — NEVER hand-write a route file.\n" +
272
273
  "3) SHELL — the app-shell layout + nav linking those routes.\n" +
273
274
  "4) FILL, FEATURE BY FEATURE — replace each route's placeholder with its real " +
274
- "component (import your types + `useCollection(service)` + @/components/ui + " +
275
+ "component (import your types + `useResource('/api/x')` + @/components/ui + " +
275
276
  "<Link> to any route). FINISH one feature before starting the next.\n" +
276
277
  "PACE: write ONE coherent slice per turn — a single feature's few files together " +
277
278
  "(or one file if it's large) — then let the gate check it. Do NOT dump the whole " +
@@ -1054,7 +1055,7 @@ export class Session {
1054
1055
 
1055
1056
  const res = await this.provider.complete(ctx.messages, {
1056
1057
  tools: offeredTools,
1057
- temperature: this.cfg.temperature ?? 0,
1058
+ temperature: this.cfg.temperature ?? DEFAULT_TEMPERATURE,
1058
1059
  toolChoice,
1059
1060
  ...(enableThinking === undefined ? {} : { enableThinking }),
1060
1061
  ...(this.cfg.thinkingTokenBudget === undefined
@@ -2,7 +2,7 @@ import { join } from "node:path";
2
2
  import { applyEdits } from "../../files/edit";
3
3
  import { applyCreate } from "../../files/create";
4
4
  import { EDIT_FAIL_REASON } from "../../files";
5
- import { writable, normalizeWorkspacePath } from "../../lib/scope";
5
+ import { writable, normalizeWorkspacePath, isVendored } from "../../lib/scope";
6
6
  import { LOOP_LIMITS } from "../loop.constants";
7
7
  import { toEdits, toCreate, toRun, toRead, runCommand } from "../../agent";
8
8
  import { ruleHelpFromOutput } from "../feedback/rule-docs";
@@ -179,6 +179,17 @@ export async function runShell(
179
179
  return `exit ${res.exitCode}\n${output}${guidance}`;
180
180
  }
181
181
 
182
+ /**
183
+ * The rejection shown when the model tries to write a VENDORED file. These are
184
+ * tested, already-type-correct harness files (the SDK in `src/lib/`, the UI
185
+ * primitives in `src/components/ui/`, the MSW machinery, `*.gen.ts`). The whole
186
+ * point is to break the loop where a model "fixes" generic SDK types it can never
187
+ * satisfy: the message redirects it to its own call site.
188
+ */
189
+ function vendoredRejectMessage(op: "edit" | "create", file: string): string {
190
+ return `${op} ${file} REJECTED: this is a VENDORED, already-type-correct harness file you must NOT modify. A type error involving it means the bug is at YOUR CALL SITE — fix how you USE it (the types/args you pass), not the file itself.`;
191
+ }
192
+
182
193
  export async function doEdit(
183
194
  args: Record<string, unknown>,
184
195
  ctx: IToolContext
@@ -195,6 +206,14 @@ export async function doEdit(
195
206
 
196
207
  edit.file = normalizeWorkspacePath(ctx.cwd, edit.file);
197
208
 
209
+ if (isVendored(edit.file)) {
210
+ return reject(
211
+ ctx,
212
+ "edit:vendored",
213
+ vendoredRejectMessage("edit", edit.file)
214
+ );
215
+ }
216
+
198
217
  if (!writable(edit.file, ctx.files)) {
199
218
  return reject(
200
219
  ctx,
@@ -293,6 +312,14 @@ export async function doCreate(
293
312
 
294
313
  create.file = normalizeWorkspacePath(ctx.cwd, create.file);
295
314
 
315
+ if (isVendored(create.file)) {
316
+ return reject(
317
+ ctx,
318
+ "create:vendored",
319
+ vendoredRejectMessage("create", create.file)
320
+ );
321
+ }
322
+
296
323
  if (!writable(create.file, ctx.files)) {
297
324
  return reject(
298
325
  ctx,
@@ -61,7 +61,7 @@ export async function doScaffoldRoutes(
61
61
  `scaffold_routes: created ${String(written.length)} NEW route stub(s)${keptNote}. ` +
62
62
  `It is ADDITIVE and safe to call again — it NEVER overwrites a route you've already ` +
63
63
  `built, only adds missing ones. New stubs are PLACEHOLDERS (data-tsforge-stub): replace ` +
64
- `EACH with the real page (its list/detail/form using your types + useCollection(service) ` +
64
+ `EACH with the real page (its list/detail/form using your types + useResource('/api/x') ` +
65
65
  `+ @/components/ui). The gate FAILS while any stub remains. To FILL a route, EDIT its file ` +
66
66
  `directly — do NOT call scaffold_routes again to "reset" it.`
67
67
  );
@@ -50,7 +50,14 @@ function collectSourceFiles(root: string): string[] {
50
50
  } else if (stat.isFile()) {
51
51
  const ext = extname(entry);
52
52
 
53
- if (ext === ".ts" || ext === ".tsx") {
53
+ // Skip generated output (*.gen.ts e.g. TanStack's route tree). It
54
+ // ships with `/* eslint-disable */` + `@ts-nocheck`, which the
55
+ // source-text meta-rules (no-eslint-disable-comments,
56
+ // no-ts-suppressions) would otherwise flag. The model cannot write
57
+ // *.gen.ts (vendored scope), so the only such file is harness/codegen
58
+ // output — hand-written files stay fully covered, keeping the bypass
59
+ // ban airtight where it matters.
60
+ if ((ext === ".ts" || ext === ".tsx") && !entry.endsWith(".gen.ts")) {
54
61
  out.push(rel);
55
62
  }
56
63
  }
@@ -12,6 +12,7 @@ import { noCrossFeatureImportsRule } from "./rules/no-cross-feature-imports";
12
12
  import { noDerivedStateInEffectRule } from "./rules/no-derived-state-in-effect";
13
13
  import { noInlineJsxFunctionsRule } from "./rules/no-inline-jsx-functions";
14
14
  import { noJsxComputationRule } from "./rules/no-jsx-computation";
15
+ import { noLoadingTextUseSkeletonRule } from "./rules/no-loading-text-use-skeleton";
15
16
  import { noNestedComponentRule } from "./rules/no-nested-component";
16
17
  import { noReactFcRule } from "./rules/no-react-fc";
17
18
  import { noStateInComponentBodyRule } from "./rules/no-state-in-component-body";
@@ -30,6 +31,7 @@ const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
30
31
  "no-derived-state-in-effect": noDerivedStateInEffectRule,
31
32
  "no-inline-jsx-functions": noInlineJsxFunctionsRule,
32
33
  "no-jsx-computation": noJsxComputationRule,
34
+ "no-loading-text-use-skeleton": noLoadingTextUseSkeletonRule,
33
35
  "no-nested-component": noNestedComponentRule,
34
36
  "no-react-fc": noReactFcRule,
35
37
  "no-state-in-component-body": noStateInComponentBodyRule,
@@ -53,6 +55,7 @@ export const reactComponentArchitecturePack: IRulePack = {
53
55
  "no-derived-state-in-effect": "warn",
54
56
  "no-inline-jsx-functions": "warn",
55
57
  "no-jsx-computation": "error",
58
+ "no-loading-text-use-skeleton": "error",
56
59
  "no-nested-component": "error",
57
60
  "no-react-fc": "error",
58
61
  "no-state-in-component-body": "error",
@@ -0,0 +1,63 @@
1
+ import { type TSESTree } from "@typescript-eslint/utils";
2
+
3
+ import { createRule } from "../../create-rule";
4
+ import { isStoryFile, isTestFile } from "../utils";
5
+
6
+ export const RULE_NAME = "no-loading-text-use-skeleton";
7
+
8
+ type RuleOptions = [];
9
+ type MessageIds = "useSkeleton";
10
+
11
+ /** A loading PLACEHOLDER text node: "Loading", "Loading…", "Loading...",
12
+ * "loading data" — the spinner-era text models reach for. Matched
13
+ * case-insensitively against the trimmed text; an ordinary sentence that merely
14
+ * CONTAINS the word "loading" mid-string is NOT flagged (anchored at start). */
15
+ const LOADING_TEXT = /^loading\b[\s.…!]*$/iu;
16
+
17
+ function isLoadingText(raw: string): boolean {
18
+ return LOADING_TEXT.test(raw.trim());
19
+ }
20
+
21
+ export const noLoadingTextUseSkeletonRule = createRule<RuleOptions, MessageIds>(
22
+ {
23
+ name: RULE_NAME,
24
+ meta: {
25
+ type: "problem",
26
+ docs: {
27
+ description:
28
+ "Loading states must render a <Skeleton/>, not loading text or a spinner",
29
+ },
30
+ schema: [],
31
+ messages: {
32
+ useSkeleton:
33
+ "Render a <Skeleton/> shaped like the content, not loading text. Every isLoading/isPending branch shows skeletons — never a spinner or 'Loading…' text.",
34
+ },
35
+ },
36
+ defaultOptions: [],
37
+ create(context) {
38
+ const filename = context.filename;
39
+
40
+ if (isStoryFile(filename) || isTestFile(filename)) {
41
+ return {};
42
+ }
43
+
44
+ return {
45
+ JSXText(node: TSESTree.JSXText) {
46
+ if (isLoadingText(node.value)) {
47
+ context.report({ node, messageId: "useSkeleton" });
48
+ }
49
+ },
50
+ "JSXElement > Literal"(node: TSESTree.Literal) {
51
+ if (typeof node.value === "string" && isLoadingText(node.value)) {
52
+ context.report({ node, messageId: "useSkeleton" });
53
+ }
54
+ },
55
+ "JSXExpressionContainer > Literal"(node: TSESTree.Literal) {
56
+ if (typeof node.value === "string" && isLoadingText(node.value)) {
57
+ context.report({ node, messageId: "useSkeleton" });
58
+ }
59
+ },
60
+ };
61
+ },
62
+ }
63
+ );
@@ -34,6 +34,7 @@ export type ComponentName =
34
34
  | "select"
35
35
  | "badge"
36
36
  | "separator"
37
+ | "skeleton"
37
38
  | "table"
38
39
  // composition blocks (molecules) — the view chrome the model otherwise re-rolls
39
40
  | "app-shell"
@@ -52,6 +53,7 @@ export const COMPONENT_NAMES: readonly ComponentName[] = [
52
53
  "select",
53
54
  "badge",
54
55
  "separator",
56
+ "skeleton",
55
57
  "table",
56
58
  "app-shell",
57
59
  "page-header",
@@ -362,6 +364,23 @@ export function Separator({
362
364
  />
363
365
  );
364
366
  }
367
+ `,
368
+ skeleton: `import { cn } from "@/lib/utils";
369
+
370
+ // THE loading-state primitive. Render a Skeleton SHAPED like the content it
371
+ // stands in for (a row, a card, a line of text) for every isLoading/isPending
372
+ // branch — never a spinner, never "Loading…" text. Size it with className.
373
+ export function Skeleton({
374
+ className,
375
+ ...props
376
+ }: React.HTMLAttributes<HTMLDivElement>): React.JSX.Element {
377
+ return (
378
+ <div
379
+ className={cn("animate-pulse rounded-md bg-muted $DELTA", className)}
380
+ {...props}
381
+ />
382
+ );
383
+ }
365
384
  `,
366
385
  table: `import { cn } from "@/lib/utils";
367
386
 
@@ -35,6 +35,9 @@ const PRETTIER_IGNORE = `node_modules
35
35
  dist
36
36
  src/components/ui
37
37
  src/lib
38
+ src/mocks/db.ts
39
+ src/mocks/browser.ts
40
+ public/mockServiceWorker.js
38
41
  *.gen.ts
39
42
  `;
40
43
 
@@ -71,6 +74,7 @@ const REACT_PACKAGE_JSON = `{
71
74
  "@types/react": "^19.0.0",
72
75
  "@types/react-dom": "^19.0.0",
73
76
  "@vitejs/plugin-react": "^4.3.4",
77
+ "msw": "^2.7.0",
74
78
  "tailwindcss": "^4.0.0",
75
79
  "tw-animate-css": "^1.0.0",
76
80
  "typescript": "^5.7.0",
@@ -336,6 +340,7 @@ import { createRoot } from "react-dom/client";
336
340
  import { RouterProvider, createRouter } from "@tanstack/react-router";
337
341
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
338
342
 
343
+ import { worker } from "./mocks/browser";
339
344
  import { routeTree } from "./routeTree.gen";
340
345
  import "./index.css";
341
346
 
@@ -355,13 +360,23 @@ if (rootElement === null) {
355
360
  throw new Error("missing #root element");
356
361
  }
357
362
 
358
- createRoot(rootElement).render(
359
- <StrictMode>
360
- <QueryClientProvider client={queryClient}>
361
- <RouterProvider router={router} />
362
- </QueryClientProvider>
363
- </StrictMode>
364
- );
363
+ const root = createRoot(rootElement);
364
+
365
+ // Start the MSW mock API BEFORE mounting — there is no real backend, so the app's
366
+ // fetches must be intercepted from the very first render (in dev AND in the build).
367
+ async function start(): Promise<void> {
368
+ await worker.start({ onUnhandledRequest: "bypass", quiet: true });
369
+
370
+ root.render(
371
+ <StrictMode>
372
+ <QueryClientProvider client={queryClient}>
373
+ <RouterProvider router={router} />
374
+ </QueryClientProvider>
375
+ </StrictMode>,
376
+ );
377
+ }
378
+
379
+ void start();
365
380
  `;
366
381
 
367
382
  // A STUB of TanStack Router's generated route tree, registering only the stock "/"
@@ -451,7 +466,7 @@ const REACT_GUIDANCE = [
451
466
  " (NO `DealsTable` around <Table> — render <Table> with deal columns instead).",
452
467
  " – <feature>.types.ts — the feature's interfaces/types (I-prefixed).",
453
468
  " – <feature>.constants.ts — its `as const` registries/label maps/column specs.",
454
- " – (NO <feature>.hooks.ts query wrapper — the SDK's useCollection IS the data",
469
+ " – (NO <feature>.hooks.ts query wrapper — the SDK's useResource IS the data",
455
470
  " hook; add a hook file ONLY for genuine derived/computed state, never to fetch.)",
456
471
  " • A component .tsx (index.tsx or components/<X>.tsx) = imports + the component,",
457
472
  " nothing else. A constant (label map, column spec) → <feature>.constants.ts. A type",
@@ -515,9 +530,9 @@ const REACT_GUIDANCE = [
515
530
  " • UI BUILDING BLOCKS — call `scaffold_ui` ONCE near the start to generate what",
516
531
  " the app needs, themed to its vibe (minimal | warm | futuristic, from the",
517
532
  " user's request). Two tiers, BOTH from @/components/ui: PRIMITIVES (button,",
518
- " card, input, label, textarea, select, badge, separator, table) AND COMPOSITION",
519
- " BLOCKS — app-shell (sidebar+nav layout, renders <Outlet/>), page-header, field",
520
- " (label+control+error), form-actions, toolbar, empty-state. COMPOSE these:",
533
+ " card, input, label, textarea, select, badge, separator, skeleton, table) AND",
534
+ " COMPOSITION BLOCKS — app-shell (sidebar+nav layout, renders <Outlet/>), page-",
535
+ " header, field (label+control+error), form-actions, toolbar, empty-state. COMPOSE:",
521
536
  " layout = app-shell; a list view = page-header + toolbar + table + empty-state;",
522
537
  " a form = field × N + form-actions. NEVER hand-roll a component OR this view",
523
538
  " chrome — it wastes time and breaks theme coherence. Write only feature wiring.",
@@ -525,14 +540,28 @@ const REACT_GUIDANCE = [
525
540
  " => d.id} />`, where `dealColumns: readonly IColumn<IDeal>[]` is a feature CONSTANT",
526
541
  " (in <feature>.constants.ts). Each column is `{ header, cell: (row) => …, className? }`.",
527
542
  " Do NOT build a per-feature table component — pass columns to the one <Table>.",
528
- " • HARNESS SDK USE IT, do NOT hand-roll the data layer (this is the biggest",
529
- " speed+quality lever). A tested generic toolkit is already in src/lib/:",
530
- " createCollection(key, SEED) [from @/lib/collection] IS a feature's whole",
531
- " service: typed async CRUD + Result + latency. <feature>.service.ts (in the",
532
- " view folder) is ONE line: `export const items = createCollection('items', SEED_ITEMS)`.",
533
- " useCollection(collection) [from @/lib/use-collection] IS the data hook:",
534
- " cached list, isLoading/error, and create/update/remove mutations WITH",
535
- " optimistic updates + rollback. Do NOT write a <feature>.hooks.ts query wrapper.",
543
+ " • LOADING STATES ARE SKELETONS every `isLoading`/`isPending` branch renders",
544
+ " `<Skeleton/>` (from @/components/ui/skeleton) SHAPED like the content it stands",
545
+ " in for: skeleton rows for a table, skeleton cards for a grid, a short skeleton",
546
+ ' line for a heading. NEVER render `"Loading…"`/`"Loading"` text and NEVER a',
547
+ " spinner the gate REJECTS loading text (no-loading-text-use-skeleton). Request",
548
+ " `skeleton` from scaffold_ui. Pattern: `if (isLoading) return <Skeleton className=",
549
+ ' "h-9 w-full" />;` (size with Tailwind h-/w- classes; render several for a list).',
550
+ " DATA LAYER a REAL mock API (MSW), do NOT hand-roll it (biggest speed+quality",
551
+ " lever). The app does REAL `fetch()` to REST endpoints intercepted in-browser by",
552
+ " Mock Service Worker — already wired and started in src/main.tsx. Two vendored",
553
+ " generics (in src/lib + src/mocks; NEVER edit them — a type error involving them",
554
+ " is a wrong CALL SITE) give you the whole loop in two lines per feature:",
555
+ " – REGISTER the endpoint: in src/mocks/handlers.ts add ONE line —",
556
+ " `...mockResource('/api/deals', SEED_DEALS)` [mockResource from @/mocks/db].",
557
+ " It serves GET (list), GET /:id, POST, PATCH /:id, DELETE /:id over an in-",
558
+ " memory faker-seeded store. handlers.ts is the ONLY mock file you edit.",
559
+ " – CONSUME it: `const { items, isLoading, error, mutations } =",
560
+ " useResource<IDeal>('/api/deals')` [useResource from @/lib/use-resource] IS",
561
+ " the data hook — cached list, isLoading/error, and create/update/remove",
562
+ " mutations WITH optimistic updates + rollback. Pass the SAME path string you",
563
+ " registered. Do NOT write a <feature>.hooks.ts query wrapper or call fetch",
564
+ " yourself — useResource is the only data access.",
536
565
  " – useForm({ initial, validate, submit }) [from @/lib/use-form] IS form state:",
537
566
  " values, per-field errors, async submit status. Do NOT hand-roll form state.",
538
567
  " – SEED DATA — GENERATE with faker. NEVER hand-write literal arrays, and NEVER",
@@ -551,9 +580,11 @@ const REACT_GUIDANCE = [
551
580
  " ` ['like','reply','follow']), from: faker.helpers.arrayElement(SEED_USERS),`",
552
581
  " ` text: faker.lorem.sentence() }), { count: 15 });`",
553
582
  " No `i`, no `arr[i % len]`, no undefined-guards, no parser — the type system +",
554
- " the factory return type ARE the validation. There is NO backend/localStorage,",
555
- " so NEVER write a runtime parser/validator (no parse<X>, no pObject, no `typeof`",
556
- " guards, no `as` casts).",
583
+ " the factory return type ARE the validation. Define each SEED in the view's",
584
+ " <feature>.constants.ts and pass it to mockResource. The mock API echoes your",
585
+ " typed SEED and useResource<T> types the response, so the contract is proven",
586
+ " end-to-end — NEVER write a runtime parser/validator (no parse<X>, no pObject,",
587
+ " no `typeof` guards, no `as` casts) even though it now crosses a fetch.",
557
588
  " – Result/ok/err [from @/lib/result] for any fallible op.",
558
589
  " – objectKeys(x)/objectEntries(x) [from @/lib/object] for TYPED keys of an",
559
590
  " `as const` object. NEVER write `Object.keys(x) as (keyof typeof x)[]` —",
@@ -569,10 +600,11 @@ const REACT_GUIDANCE = [
569
600
  " `as const` and then index it `MAP[key as keyof typeof MAP]` — that `as` is",
570
601
  " REJECTED. The map's KEY type, not a cast, is what makes the lookup type-check.",
571
602
  " So a feature is mostly: src/views/<Feature>/{<feature>.types.ts + a `satisfies`-typed",
572
- " SEED const + one-line createCollection + index.tsx + components/} calling",
573
- " useCollection/useForm. Far fewer lines,",
574
- " fewer bugs. Only write a custom service/hook if the SDK genuinely can't express",
575
- " it. A QueryClientProvider is already wired in src/main.tsx.",
603
+ " SEED const in <feature>.constants.ts + index.tsx + components/}, plus ONE",
604
+ " `mockResource('/api/x', SEED)` line in src/mocks/handlers.ts, calling",
605
+ " useResource/useForm. Far fewer lines, fewer bugs. Only write a custom hook if the",
606
+ " SDK genuinely can't express it. QueryClientProvider + the MSW worker are already",
607
+ " wired in src/main.tsx.",
576
608
  " • Style with Tailwind classes via className using theme tokens",
577
609
  " (bg-background, text-foreground, border-border), not raw colors.",
578
610
  " • Need charts? `recharts` is installed — import from 'recharts'. Need drag-and-",
@@ -758,87 +790,67 @@ export function sortBy<T extends object>(
758
790
  }
759
791
  `;
760
792
 
761
- const SDK_COLLECTION_TS = `// createCollectionone tested generic that IS a domain's data layer: typed async
762
- // CRUD over an in-memory store, seeded from a typed const, with simulated latency +
763
- // a Result return. A domain's service becomes one line:
764
- // export const items = createCollection("items", SEED_ITEMS)
793
+ const SDK_API_TS = `// The typed fetch client every data op goes over a REAL network call to a REST
794
+ // endpoint, intercepted in-browser by MSW (see src/mocks/). Returns Result instead
795
+ // of throwing, so callers handle failure explicitly. This is vendored + lint-exempt
796
+ // (the boundary cast on the JSON body lives here, ONCE — your code never casts).
765
797
  import type { Result } from "@/lib/result";
766
798
  import { err, ok } from "@/lib/result";
767
799
 
768
- export interface IEntity {
769
- readonly id: string;
770
- }
800
+ async function request<T>(
801
+ method: string,
802
+ path: string,
803
+ body?: unknown
804
+ ): Promise<Result<T, string>> {
805
+ try {
806
+ const res = await fetch(path, {
807
+ method,
808
+ headers: body === undefined ? undefined : { "Content-Type": "application/json" },
809
+ body: body === undefined ? undefined : JSON.stringify(body),
810
+ });
811
+
812
+ if (!res.ok) {
813
+ return err(method + " " + path + " failed: " + String(res.status));
814
+ }
771
815
 
772
- export interface ICollection<T extends IEntity> {
773
- readonly key: string;
774
- list: () => Promise<Result<readonly T[], string>>;
775
- get: (id: string) => Promise<Result<T, string>>;
776
- create: (draft: Omit<T, "id">) => Promise<Result<T, string>>;
777
- update: (id: string, patch: Partial<Omit<T, "id">>) => Promise<Result<T, string>>;
778
- remove: (id: string) => Promise<Result<true, string>>;
779
- }
816
+ if (res.status === 204) {
817
+ return ok(undefined as T);
818
+ }
780
819
 
781
- const LATENCY_MS = 120;
820
+ return ok((await res.json()) as T);
821
+ } catch (error) {
822
+ return err(error instanceof Error ? error.message : "network error");
823
+ }
824
+ }
782
825
 
783
- function delay(): Promise<void> {
784
- return new Promise((resolve) => setTimeout(resolve, LATENCY_MS));
826
+ export function apiGet<T>(path: string): Promise<Result<T, string>> {
827
+ return request<T>("GET", path);
785
828
  }
786
829
 
787
- export function createCollection<T extends IEntity>(
788
- key: string,
789
- seed: readonly T[]
790
- ): ICollection<T> {
791
- const store = new Map<string, T>();
792
- for (const entity of seed) {
793
- store.set(entity.id, entity);
794
- }
830
+ export function apiPost<T>(path: string, body: unknown): Promise<Result<T, string>> {
831
+ return request<T>("POST", path, body);
832
+ }
795
833
 
796
- let counter = store.size;
797
- const nextId = (): string => {
798
- counter += 1;
799
- return key + "-" + String(counter);
800
- };
834
+ export function apiPatch<T>(path: string, body: unknown): Promise<Result<T, string>> {
835
+ return request<T>("PATCH", path, body);
836
+ }
801
837
 
802
- return {
803
- key,
804
- async list() {
805
- await delay();
806
- return ok([...store.values()]);
807
- },
808
- async get(id) {
809
- await delay();
810
- const found = store.get(id);
811
- return found === undefined ? err(key + " " + id + " not found") : ok(found);
812
- },
813
- async create(draft) {
814
- await delay();
815
- const entity = { ...draft, id: nextId() } as T;
816
- store.set(entity.id, entity);
817
- return ok(entity);
818
- },
819
- async update(id, patch) {
820
- await delay();
821
- const current = store.get(id);
822
- if (current === undefined) {
823
- return err(key + " " + id + " not found");
824
- }
825
- const updated = { ...current, ...patch };
826
- store.set(id, updated);
827
- return ok(updated);
828
- },
829
- async remove(id) {
830
- await delay();
831
- return store.delete(id) ? ok(true) : err(key + " " + id + " not found");
832
- },
833
- };
838
+ export function apiDelete(path: string): Promise<Result<true, string>> {
839
+ return request<true>("DELETE", path);
834
840
  }
835
841
  `;
836
842
 
837
- const SDK_USE_COLLECTION_TS = `// useCollection — the TanStack Query layer for a collection, once: cached list,
843
+ const SDK_USE_RESOURCE_TS = `// useResource — the TanStack Query layer for a REST resource, once: cached list,
838
844
  // loading/error state, and create/update/remove mutations with OPTIMISTIC updates
839
- // + rollback + invalidation built in. A domain's hooks file becomes one line.
845
+ // + rollback + invalidation built in. Pass the resource's base path (the same one
846
+ // you registered with mockResource in src/mocks/handlers.ts):
847
+ // const { items, isLoading, mutations } = useResource<IDeal>("/api/deals")
840
848
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
841
- import type { ICollection, IEntity } from "@/lib/collection";
849
+ import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api";
850
+
851
+ export interface IEntity {
852
+ readonly id: string;
853
+ }
842
854
 
843
855
  export interface IMutationApi<T extends IEntity> {
844
856
  create: (draft: Omit<T, "id">) => void;
@@ -847,7 +859,7 @@ export interface IMutationApi<T extends IEntity> {
847
859
  isPending: boolean;
848
860
  }
849
861
 
850
- export interface ICollectionApi<T extends IEntity> {
862
+ export interface IResourceApi<T extends IEntity> {
851
863
  items: readonly T[];
852
864
  isLoading: boolean;
853
865
  error: string | undefined;
@@ -863,13 +875,13 @@ async function unwrap<T>(promise: Promise<{ ok: true; value: T } | { ok: false;
863
875
  return result.value;
864
876
  }
865
877
 
866
- export function useCollection<T extends IEntity>(collection: ICollection<T>): ICollectionApi<T> {
878
+ export function useResource<T extends IEntity>(path: string): IResourceApi<T> {
867
879
  const client = useQueryClient();
868
- const queryKey = [collection.key];
880
+ const queryKey = [path];
869
881
 
870
882
  const query = useQuery({
871
883
  queryKey,
872
- queryFn: () => unwrap(collection.list()),
884
+ queryFn: () => unwrap(apiGet<readonly T[]>(path)),
873
885
  });
874
886
 
875
887
  const invalidate = (): void => {
@@ -877,13 +889,13 @@ export function useCollection<T extends IEntity>(collection: ICollection<T>): IC
877
889
  };
878
890
 
879
891
  const create = useMutation({
880
- mutationFn: (draft: Omit<T, "id">) => unwrap(collection.create(draft)),
892
+ mutationFn: (draft: Omit<T, "id">) => unwrap(apiPost<T>(path, draft)),
881
893
  onSettled: invalidate,
882
894
  });
883
895
 
884
896
  const update = useMutation({
885
897
  mutationFn: (input: { readonly id: string; readonly patch: Partial<Omit<T, "id">> }) =>
886
- unwrap(collection.update(input.id, input.patch)),
898
+ unwrap(apiPatch<T>(path + "/" + input.id, input.patch)),
887
899
  onMutate: async (input) => {
888
900
  await client.cancelQueries({ queryKey });
889
901
  const previous = client.getQueryData<readonly T[]>(queryKey);
@@ -904,7 +916,7 @@ export function useCollection<T extends IEntity>(collection: ICollection<T>): IC
904
916
  });
905
917
 
906
918
  const remove = useMutation({
907
- mutationFn: (id: string) => unwrap(collection.remove(id)),
919
+ mutationFn: (id: string) => unwrap(apiDelete(path + "/" + id)),
908
920
  onSettled: invalidate,
909
921
  });
910
922
 
@@ -925,6 +937,79 @@ export function useCollection<T extends IEntity>(collection: ICollection<T>): IC
925
937
  }
926
938
  `;
927
939
 
940
+ const SDK_MOCKS_DB_TS = `// mockResource — one tested generic that IS a REST endpoint set: in-memory CRUD
941
+ // over a faker-seeded store, exposed as MSW handlers. Registering a resource is one
942
+ // line in src/mocks/handlers.ts:
943
+ // ...mockResource("/api/deals", SEED_DEALS)
944
+ // It serves GET (list), GET /:id, POST, PATCH /:id, DELETE /:id. Vendored + lint-
945
+ // exempt (the boundary casts on request bodies live here, ONCE). NEVER edit this.
946
+ import { http, HttpResponse, type RequestHandler } from "msw";
947
+ import { faker } from "@faker-js/faker";
948
+
949
+ export interface IEntity {
950
+ readonly id: string;
951
+ }
952
+
953
+ export function mockResource<T extends IEntity>(
954
+ path: string,
955
+ seed: readonly T[]
956
+ ): RequestHandler[] {
957
+ const store = new Map<string, T>();
958
+ for (const entity of seed) {
959
+ store.set(entity.id, entity);
960
+ }
961
+
962
+ return [
963
+ http.get(path, () => HttpResponse.json([...store.values()])),
964
+ http.get(path + "/:id", ({ params }) => {
965
+ const found = store.get(String(params.id));
966
+ return found === undefined
967
+ ? new HttpResponse(null, { status: 404 })
968
+ : HttpResponse.json(found);
969
+ }),
970
+ http.post(path, async ({ request }) => {
971
+ const draft = (await request.json()) as Omit<T, "id">;
972
+ const entity = { ...draft, id: faker.string.uuid() } as T;
973
+ store.set(entity.id, entity);
974
+ return HttpResponse.json(entity, { status: 201 });
975
+ }),
976
+ http.patch(path + "/:id", async ({ params, request }) => {
977
+ const current = store.get(String(params.id));
978
+ if (current === undefined) {
979
+ return new HttpResponse(null, { status: 404 });
980
+ }
981
+ const patch = (await request.json()) as Partial<Omit<T, "id">>;
982
+ const updated = { ...current, ...patch };
983
+ store.set(updated.id, updated);
984
+ return HttpResponse.json(updated);
985
+ }),
986
+ http.delete(path + "/:id", ({ params }) => {
987
+ const existed = store.delete(String(params.id));
988
+ return new HttpResponse(null, { status: existed ? 204 : 404 });
989
+ }),
990
+ ];
991
+ }
992
+ `;
993
+
994
+ const SDK_MOCKS_BROWSER_TS = `// The MSW worker, wired from your handlers. Vendored — NEVER edit. Register your
995
+ // resources in src/mocks/handlers.ts; main.tsx starts this before the app mounts.
996
+ import { setupWorker } from "msw/browser";
997
+ import { handlers } from "@/mocks/handlers";
998
+
999
+ export const worker = setupWorker(...handlers);
1000
+ `;
1001
+
1002
+ const MOCKS_HANDLERS_TS = `// YOUR mock API. Register each resource here with mockResource (one line per
1003
+ // resource), passing your faker-generated SEED. This is the ONLY mock file you
1004
+ // edit — src/mocks/db.ts and src/mocks/browser.ts are vendored.
1005
+ // import { mockResource } from "@/mocks/db";
1006
+ // import { SEED_DEALS } from "@/views/Deals/deals.constants";
1007
+ // export const handlers: RequestHandler[] = [...mockResource("/api/deals", SEED_DEALS)];
1008
+ import { type RequestHandler } from "msw";
1009
+
1010
+ export const handlers: RequestHandler[] = [];
1011
+ `;
1012
+
928
1013
  const SDK_USE_FORM_TS = `// useForm — declarative form state: values, per-field errors, async submit with
929
1014
  // loading/success/error status. A form becomes initial + validate + submit; the
930
1015
  // plumbing (touched, submitting, error/success handling) lives here, once.
@@ -995,9 +1080,12 @@ export const WEB_TEMPLATES: Record<WebFramework, IWebTemplate> = {
995
1080
  "src/lib/result.ts": SDK_RESULT_TS,
996
1081
  "src/lib/object.ts": SDK_OBJECT_TS,
997
1082
  "src/lib/sort.ts": SDK_SORT_TS,
998
- "src/lib/collection.ts": SDK_COLLECTION_TS,
999
- "src/lib/use-collection.ts": SDK_USE_COLLECTION_TS,
1083
+ "src/lib/api.ts": SDK_API_TS,
1084
+ "src/lib/use-resource.ts": SDK_USE_RESOURCE_TS,
1000
1085
  "src/lib/use-form.ts": SDK_USE_FORM_TS,
1086
+ "src/mocks/db.ts": SDK_MOCKS_DB_TS,
1087
+ "src/mocks/browser.ts": SDK_MOCKS_BROWSER_TS,
1088
+ "src/mocks/handlers.ts": MOCKS_HANDLERS_TS,
1001
1089
  "src/index.css": REACT_INDEX_CSS,
1002
1090
  "src/components/ui/button.tsx": BUTTON_TSX,
1003
1091
  "src/routes/__root.tsx": ROOT_ROUTE_TSX,
@@ -1005,7 +1093,14 @@ export const WEB_TEMPLATES: Record<WebFramework, IWebTemplate> = {
1005
1093
  "src/routeTree.gen.ts": ROUTE_TREE_GEN,
1006
1094
  "src/main.tsx": REACT_MAIN_TSX,
1007
1095
  },
1008
- eslintIgnore: ["src/components/ui/**", "src/lib/**", "**/*.gen.ts"],
1096
+ eslintIgnore: [
1097
+ "src/components/ui/**",
1098
+ "src/lib/**",
1099
+ "src/mocks/db.ts",
1100
+ "src/mocks/browser.ts",
1101
+ "public/mockServiceWorker.js",
1102
+ "**/*.gen.ts",
1103
+ ],
1009
1104
  guidance: REACT_GUIDANCE,
1010
1105
  },
1011
1106
  vanilla: {