@agjs/tsforge 0.2.9 → 0.3.0
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 +1 -1
- package/scripts/benchmark-catalog.ts +1 -1
- package/src/agent/model-agent.ts +2 -1
- package/src/browser/oracle.ts +27 -21
- package/src/detect-gate.ts +44 -8
- package/src/lib/scope/scope.constants.ts +20 -0
- package/src/lib/scope/scope.ts +8 -1
- package/src/loop/feedback/rule-docs.ts +5 -0
- package/src/loop/loop.constants.ts +4 -0
- package/src/loop/run.ts +7 -2
- package/src/loop/session.ts +6 -5
- package/src/loop/tools/file-ops.ts +28 -1
- package/src/loop/tools/scaffold-routes.ts +1 -1
- package/src/meta-rules/context.ts +8 -1
- package/src/rule-packs/react-component-architecture/index.ts +3 -0
- package/src/rule-packs/react-component-architecture/rules/no-loading-text-use-skeleton.ts +63 -0
- package/src/web-components.ts +19 -0
- package/src/web-templates.ts +197 -102
package/package.json
CHANGED
|
@@ -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
|
|
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/\`.
|
package/src/agent/model-agent.ts
CHANGED
|
@@ -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 ??
|
|
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.
|
package/src/browser/oracle.ts
CHANGED
|
@@ -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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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");
|
package/src/detect-gate.ts
CHANGED
|
@@ -349,17 +349,53 @@ 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
|
-
|
|
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
|
-
|
|
357
|
-
cwd,
|
|
358
|
-
stdout: "inherit",
|
|
359
|
-
stderr: "inherit",
|
|
360
|
-
});
|
|
364
|
+
await initMswWorker(cwd);
|
|
361
365
|
|
|
362
|
-
return
|
|
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`: that rewrites package.json (non-prettier, fails the gate's
|
|
389
|
+
// format check) just to record the worker dir. The worker lands at the default
|
|
390
|
+
// `/mockServiceWorker.js`, which `worker.start()` finds without any config.
|
|
391
|
+
await Bun.spawn(["bunx", "msw", "init", "public"], {
|
|
392
|
+
cwd,
|
|
393
|
+
stdout: "inherit",
|
|
394
|
+
stderr: "inherit",
|
|
395
|
+
}).exited;
|
|
396
|
+
} catch {
|
|
397
|
+
// best-effort — a missing worker surfaces as a clear runtime error in the gate
|
|
398
|
+
}
|
|
363
399
|
}
|
|
364
400
|
|
|
365
401
|
/** 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;
|
package/src/lib/scope/scope.ts
CHANGED
|
@@ -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 {
|
|
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 ??
|
|
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.
|
package/src/loop/session.ts
CHANGED
|
@@ -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
|
|
267
|
-
"
|
|
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 + `
|
|
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 ??
|
|
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 +
|
|
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
|
-
|
|
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
|
+
);
|
package/src/web-components.ts
CHANGED
|
@@ -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
|
|
package/src/web-templates.ts
CHANGED
|
@@ -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)
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
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
|
|
519
|
-
" BLOCKS — app-shell (sidebar+nav layout, renders <Outlet/>), page-
|
|
520
|
-
" (label+control+error), form-actions, toolbar, empty-state. COMPOSE
|
|
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
|
-
" •
|
|
529
|
-
"
|
|
530
|
-
"
|
|
531
|
-
|
|
532
|
-
"
|
|
533
|
-
"
|
|
534
|
-
"
|
|
535
|
-
"
|
|
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.
|
|
555
|
-
"
|
|
556
|
-
"
|
|
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
|
|
573
|
-
"
|
|
574
|
-
" fewer bugs. Only write a custom
|
|
575
|
-
" it.
|
|
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
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
//
|
|
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
|
-
|
|
769
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
|
|
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
|
|
784
|
-
return
|
|
826
|
+
export function apiGet<T>(path: string): Promise<Result<T, string>> {
|
|
827
|
+
return request<T>("GET", path);
|
|
785
828
|
}
|
|
786
829
|
|
|
787
|
-
export function
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
|
|
803
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
878
|
+
export function useResource<T extends IEntity>(path: string): IResourceApi<T> {
|
|
867
879
|
const client = useQueryClient();
|
|
868
|
-
const queryKey = [
|
|
880
|
+
const queryKey = [path];
|
|
869
881
|
|
|
870
882
|
const query = useQuery({
|
|
871
883
|
queryKey,
|
|
872
|
-
queryFn: () => unwrap(
|
|
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(
|
|
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(
|
|
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(
|
|
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/
|
|
999
|
-
"src/lib/use-
|
|
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: [
|
|
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: {
|