@agjs/tsforge 0.2.5 → 0.2.7
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/src/cli.ts +5 -10
- package/src/detect-gate.ts +49 -23
- package/src/inference/request.ts +15 -1
- package/src/loop/loop.constants.ts +18 -4
- package/src/loop/loop.types.ts +5 -0
- package/src/loop/rule-docs.generated.json +136 -1
- package/src/loop/run.ts +1 -0
- package/src/loop/session.ts +8 -2
- package/src/loop/turn.ts +76 -1
- package/src/rule-packs/react-component-architecture/index.ts +3 -0
- package/src/rule-packs/react-component-architecture/rules/component-file-purity.ts +105 -0
- package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +35 -60
- package/src/rule-packs/react-component-architecture/utils.ts +62 -0
- package/src/rule-packs/rule-metadata.ts +1 -0
- package/src/web-components.ts +54 -52
- package/src/web-templates.ts +39 -24
- package/strict.web.eslint.config.mjs +1 -1
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -2,13 +2,7 @@
|
|
|
2
2
|
import { join, isAbsolute } from "node:path";
|
|
3
3
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
5
|
-
import {
|
|
6
|
-
runTask,
|
|
7
|
-
RUN_STATUS,
|
|
8
|
-
Session,
|
|
9
|
-
PLAN_APPROVED_NOTE,
|
|
10
|
-
LOOP_LIMITS,
|
|
11
|
-
} from "./loop";
|
|
5
|
+
import { runTask, RUN_STATUS, Session, PLAN_APPROVED_NOTE } from "./loop";
|
|
12
6
|
import {
|
|
13
7
|
PROVIDER_LIMITS,
|
|
14
8
|
PROVIDER_DEFAULTS,
|
|
@@ -997,9 +991,10 @@ async function repl(args: ICliArgs): Promise<number> {
|
|
|
997
991
|
session.setFix(buildWebFix(framework));
|
|
998
992
|
session.setIncrementalCheck(buildWebTscCheck());
|
|
999
993
|
session.guide(webGuidance(framework));
|
|
1000
|
-
// A from-scratch web build needs
|
|
1001
|
-
//
|
|
1002
|
-
|
|
994
|
+
// A from-scratch web build legitimately needs many turns. Don't pin a low
|
|
995
|
+
// ceiling here — the interactive session already rides the high runaway
|
|
996
|
+
// backstop (interactiveBackstopTurns) and stops on the progress guards, so a
|
|
997
|
+
// long, converging build is never cut off mid-write.
|
|
1003
998
|
};
|
|
1004
999
|
|
|
1005
1000
|
// The `scaffold_web` tool invokes this when the AGENT decides to build a web app
|
package/src/detect-gate.ts
CHANGED
|
@@ -367,7 +367,43 @@ export async function installWebDeps(cwd: string): Promise<boolean> {
|
|
|
367
367
|
* TanStack Router's routeTree.gen.ts) exists before tsc; `vite build` is itself
|
|
368
368
|
* the bundler oracle — it resolves imports, compiles JSX/Tailwind, fails on
|
|
369
369
|
* anything broken. */
|
|
370
|
-
|
|
370
|
+
/** The packs the WEB eslint config must load by default so the React component
|
|
371
|
+
* architecture rules (component-folder-structure, component-file-purity,
|
|
372
|
+
* no-jsx-computation, …) actually run on a generated app. The web scaffold's
|
|
373
|
+
* stack is fixed (React + TanStack), so this set is deterministic; callers may
|
|
374
|
+
* pass a detected/overridden set instead. Without this the web gate ran the
|
|
375
|
+
* bundled config with ZERO packs and the whole architecture layer was inert. */
|
|
376
|
+
export const WEB_PACKS: readonly string[] = [
|
|
377
|
+
"typescript-core",
|
|
378
|
+
"react",
|
|
379
|
+
"react-component-architecture",
|
|
380
|
+
"tanstack-query",
|
|
381
|
+
];
|
|
382
|
+
|
|
383
|
+
/** Build the `KEY=val ` shell prefix that hands packs (+ rule overrides) to a
|
|
384
|
+
* bundled eslint config, which reads them from the environment at load time.
|
|
385
|
+
* JSON.stringify emits no spaces, so this survives a later whitespace-collapse. */
|
|
386
|
+
function packEnvPrefix(
|
|
387
|
+
packs?: readonly string[],
|
|
388
|
+
ruleOverrides?: Readonly<Record<string, "error" | "warn" | "off">>
|
|
389
|
+
): string {
|
|
390
|
+
const envParts: string[] = [];
|
|
391
|
+
|
|
392
|
+
if (packs !== undefined && packs.length > 0) {
|
|
393
|
+
envParts.push(`TSFORGE_PACKS=${packs.join(",")}`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (ruleOverrides !== undefined && Object.keys(ruleOverrides).length > 0) {
|
|
397
|
+
envParts.push(`TSFORGE_RULE_OVERRIDES=${JSON.stringify(ruleOverrides)}`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return envParts.length > 0 ? `${envParts.join(" ")} ` : "";
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function buildWebGate(
|
|
404
|
+
framework: WebFramework,
|
|
405
|
+
packs: readonly string[] = WEB_PACKS
|
|
406
|
+
): IGate {
|
|
371
407
|
const template = WEB_TEMPLATES[framework];
|
|
372
408
|
const ignores = template.eslintIgnore
|
|
373
409
|
.map((glob) => `--ignore-pattern "${glob}"`)
|
|
@@ -375,7 +411,7 @@ export function buildWebGate(framework: WebFramework): IGate {
|
|
|
375
411
|
const build = `bun run build`;
|
|
376
412
|
const tsc = `"${TSC_BIN}" --noEmit -p tsconfig.json`;
|
|
377
413
|
const lint =
|
|
378
|
-
|
|
414
|
+
`${packEnvPrefix(packs)}bun "${ESLINT_BIN}" --no-config-lookup -c "${STRICT_WEB_CONFIG}" ${ignores} --format json .`.replace(
|
|
379
415
|
/\s+/g,
|
|
380
416
|
" "
|
|
381
417
|
);
|
|
@@ -415,14 +451,17 @@ export function buildWebGate(framework: WebFramework): IGate {
|
|
|
415
451
|
* ALONE — caught small and isolated, before any component is built — instead of
|
|
416
452
|
* as a 20-error avalanche at the very end (the Linear-clone failure mode).
|
|
417
453
|
*/
|
|
418
|
-
export function buildWebTypeGate(
|
|
454
|
+
export function buildWebTypeGate(
|
|
455
|
+
framework: WebFramework,
|
|
456
|
+
packs: readonly string[] = WEB_PACKS
|
|
457
|
+
): IGate {
|
|
419
458
|
const template = WEB_TEMPLATES[framework];
|
|
420
459
|
const ignores = template.eslintIgnore
|
|
421
460
|
.map((glob) => `--ignore-pattern "${glob}"`)
|
|
422
461
|
.join(" ");
|
|
423
462
|
const tsc = `"${TSC_BIN}" --noEmit -p tsconfig.json`;
|
|
424
463
|
const lint =
|
|
425
|
-
|
|
464
|
+
`${packEnvPrefix(packs)}bun "${ESLINT_BIN}" --no-config-lookup -c "${STRICT_WEB_CONFIG}" ${ignores} --format json .`.replace(
|
|
426
465
|
/\s+/g,
|
|
427
466
|
" "
|
|
428
467
|
);
|
|
@@ -447,13 +486,16 @@ export function buildWebTscCheck(): string {
|
|
|
447
486
|
* unfixable rules (`any`/`as`/`!`) still need the model. Best-effort: exits ignored,
|
|
448
487
|
* `;` so prettier runs even when eslint reports remaining (unfixable) errors.
|
|
449
488
|
*/
|
|
450
|
-
export function buildWebFix(
|
|
489
|
+
export function buildWebFix(
|
|
490
|
+
framework: WebFramework,
|
|
491
|
+
packs: readonly string[] = WEB_PACKS
|
|
492
|
+
): string {
|
|
451
493
|
const ignores = WEB_TEMPLATES[framework].eslintIgnore
|
|
452
494
|
.map((glob) => `--ignore-pattern "${glob}"`)
|
|
453
495
|
.join(" ");
|
|
454
496
|
|
|
455
497
|
const lintFix =
|
|
456
|
-
|
|
498
|
+
`${packEnvPrefix(packs)}bun "${ESLINT_BIN}" --no-config-lookup -c "${STRICT_WEB_CONFIG}" ${ignores} --fix .`.replace(
|
|
457
499
|
/\s+/g,
|
|
458
500
|
" "
|
|
459
501
|
);
|
|
@@ -691,24 +733,8 @@ function lintPart(
|
|
|
691
733
|
packs?: readonly string[],
|
|
692
734
|
ruleOverrides?: Readonly<Record<string, "error" | "warn" | "off">>
|
|
693
735
|
): IGate {
|
|
694
|
-
const envParts: string[] = [];
|
|
695
|
-
|
|
696
|
-
if (packs !== undefined && packs.length > 0) {
|
|
697
|
-
envParts.push(`TSFORGE_PACKS=${packs.join(",")}`);
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
if (
|
|
701
|
-
ruleOverrides !== undefined &&
|
|
702
|
-
typeof ruleOverrides === "object" &&
|
|
703
|
-
Object.keys(ruleOverrides).length > 0
|
|
704
|
-
) {
|
|
705
|
-
envParts.push(`TSFORGE_RULE_OVERRIDES=${JSON.stringify(ruleOverrides)}`);
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
const envPrefix = envParts.length > 0 ? `${envParts.join(" ")} ` : "";
|
|
709
|
-
|
|
710
736
|
return {
|
|
711
|
-
command: `${
|
|
737
|
+
command: `${packEnvPrefix(packs, ruleOverrides)}bun "${ESLINT_BIN}" --no-config-lookup -c "${STRICT_CONFIG}" --format json .`,
|
|
712
738
|
label: "strict TypeScript (tsforge)",
|
|
713
739
|
};
|
|
714
740
|
}
|
package/src/inference/request.ts
CHANGED
|
@@ -19,7 +19,21 @@ function interpolateEnv(
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function style(cfg: IOpenAICompatibleConfig): ReasoningStyle {
|
|
22
|
-
|
|
22
|
+
if (cfg.reasoning !== undefined) {
|
|
23
|
+
return cfg.reasoning;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Auto-detect DeepSeek when not explicitly configured, so its thinking-mode
|
|
27
|
+
// round-trip works out of the box: DeepSeek requires each prior assistant
|
|
28
|
+
// turn's `reasoning_content` replayed, and 400s otherwise ("The
|
|
29
|
+
// reasoning_content in the thinking mode must be passed back to the API").
|
|
30
|
+
// Without this, a DeepSeek model added with just { baseUrl, model } gets the
|
|
31
|
+
// `qwen` default, which strips reasoning_content on replay → that 400.
|
|
32
|
+
if (`${cfg.baseUrl} ${cfg.model}`.toLowerCase().includes("deepseek")) {
|
|
33
|
+
return "deepseek";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return "qwen";
|
|
23
37
|
}
|
|
24
38
|
|
|
25
39
|
/** Provider-specific reasoning/thinking fields for the request body. */
|
|
@@ -31,17 +31,31 @@ export const LOOP_LIMITS = {
|
|
|
31
31
|
*/
|
|
32
32
|
maxEditLines: 50,
|
|
33
33
|
/**
|
|
34
|
-
* Give up after the gate shows the EXACT same error
|
|
35
|
-
* row (genuine spinning)
|
|
34
|
+
* Give up after the gate shows the EXACT same error SET this many edits in a
|
|
35
|
+
* row (genuine spinning) — the coarse net. The finer `samePersist` guard
|
|
36
|
+
* (below) usually trips first; this catches a stable-but-shuffling set.
|
|
36
37
|
*/
|
|
37
|
-
gateStuckRepeats:
|
|
38
|
+
gateStuckRepeats: 6,
|
|
39
|
+
/**
|
|
40
|
+
* The PRIMARY no-progress guard: give up when a SINGLE error — same (file,rule)
|
|
41
|
+
* key — survives this many consecutive gate cycles, i.e. the model keeps failing
|
|
42
|
+
* at the same thing N attempts running, even while OTHER errors churn around it.
|
|
43
|
+
* This (not a raw turn count) is how the loop decides it's genuinely stuck.
|
|
44
|
+
*/
|
|
45
|
+
samePersist: 5,
|
|
38
46
|
/**
|
|
39
47
|
* Above this many chars of combined file content, the seed prompt sends a
|
|
40
48
|
* navigable project MAP instead of full dumps. Below it, full dumps.
|
|
41
49
|
*/
|
|
42
50
|
mapThresholdChars: 12000,
|
|
43
|
-
/** Hard backstop on model turns per task
|
|
51
|
+
/** Hard backstop on model turns per HEADLESS task (eval/cron — no human to
|
|
52
|
+
* intervene). Interactive sessions use `interactiveBackstopTurns` instead. */
|
|
44
53
|
maxTurns: 40,
|
|
54
|
+
/** Interactive runaway safety only — NOT the primary stop. A human is present
|
|
55
|
+
* and can interrupt, and the progress guards (`samePersist` / `gateStuckRepeats`)
|
|
56
|
+
* pull the agent out the moment it stops converging, so this is set high enough
|
|
57
|
+
* that normal long, productive back-and-forth never trips it. */
|
|
58
|
+
interactiveBackstopTurns: 250,
|
|
45
59
|
/** Turn budget for a from-scratch WEB build (heavy gate, many files): used by
|
|
46
60
|
* headless web builds AND applied when an interactive session scaffolds via
|
|
47
61
|
* `scaffold_web` — measured: a todo app was still WRITING components when it
|
package/src/loop/loop.types.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface ILoopEvent {
|
|
|
37
37
|
* reads to tell a type error from a lint rule, not just a count. */
|
|
38
38
|
rules?: readonly string[];
|
|
39
39
|
passed?: boolean;
|
|
40
|
+
/** For `stuck` events: a human-readable blocker diagnosis. */
|
|
41
|
+
detail?: string;
|
|
40
42
|
file?: string;
|
|
41
43
|
/** For `create` events: the new file's content (rendered as a code block). */
|
|
42
44
|
content?: string;
|
|
@@ -82,6 +84,9 @@ export interface IRunResult {
|
|
|
82
84
|
/** Model turns used. */
|
|
83
85
|
cycles: number;
|
|
84
86
|
reason?: StuckReason;
|
|
87
|
+
/** When stuck: a human-readable blocker diagnosis (the persistent rule/file +
|
|
88
|
+
* last error) so an interactive session can hand back something actionable. */
|
|
89
|
+
detail?: string;
|
|
85
90
|
/** Edits/creates applied to editable files (measure edit churn). */
|
|
86
91
|
edits?: number;
|
|
87
92
|
/** Times an edit RAISED the gate error count (regressions). */
|
|
@@ -109,6 +109,21 @@
|
|
|
109
109
|
"bad": "[1, 2, 3].reduce((arr, num) => arr.concat(num * 2), [] as number[]);\n\n['a', 'b'].reduce(\n (accumulator, name) => ({\n ...accumulator,\n [name]: true,\n }),\n {} as Record<string, boolean>,",
|
|
110
110
|
"good": "[1, 2, 3].reduce<number[]>((arr, num) => arr.concat(num * 2), []);\n\n['a', 'b'].reduce<Record<string, boolean>>(\n (accumulator, name) => ({\n ...accumulator,\n [name]: true,\n }),\n {},"
|
|
111
111
|
},
|
|
112
|
+
"tsforge/id-param-requires-object-authz": {
|
|
113
|
+
"what": "Warn when a handler reads `params.id` and queries the database without an authorization check in the same function.",
|
|
114
|
+
"bad": "// Example that violates the rule",
|
|
115
|
+
"good": "// Corrected version"
|
|
116
|
+
},
|
|
117
|
+
"tsforge/mutating-route-requires-authz": {
|
|
118
|
+
"what": "POST/PUT/PATCH/DELETE route handlers must call an authorization helper before mutating state.",
|
|
119
|
+
"bad": "// Example that violates the rule",
|
|
120
|
+
"good": "// Corrected version"
|
|
121
|
+
},
|
|
122
|
+
"tsforge/server-action-requires-authz": {
|
|
123
|
+
"what": "Files with `\"use server\"` that perform database mutations must call an authorization helper in the same function.",
|
|
124
|
+
"bad": "// Example that violates the rule",
|
|
125
|
+
"good": "// Corrected version"
|
|
126
|
+
},
|
|
112
127
|
"tsforge/job-name-must-be-constant": {
|
|
113
128
|
"what": "Disallow string-literal job names in `<queue>.add(name, ...)` calls — use a constant identifier so all consumers share one source of truth.",
|
|
114
129
|
"bad": "// Example that violates the rule",
|
|
@@ -219,6 +234,16 @@
|
|
|
219
234
|
"bad": "// Example that violates the rule",
|
|
220
235
|
"good": "// Corrected version"
|
|
221
236
|
},
|
|
237
|
+
"tsforge/update-delete-account-scoped-must-filter-scope": {
|
|
238
|
+
"what": "Require Drizzle `.update()` / `.delete()` against account-scoped tables to filter by a scope column in `.where()`.",
|
|
239
|
+
"bad": "// Example that violates the rule",
|
|
240
|
+
"good": "// Corrected version"
|
|
241
|
+
},
|
|
242
|
+
"tsforge/update-delete-must-have-where": {
|
|
243
|
+
"what": "Require every Drizzle `.update()` and `.delete()` call to include a `.where()` clause — unscoped writes affect every row.",
|
|
244
|
+
"bad": "// Example that violates the rule",
|
|
245
|
+
"good": "// Corrected version"
|
|
246
|
+
},
|
|
222
247
|
"tsforge/consistent-status-via-set": {
|
|
223
248
|
"what": "Inside Elysia route handlers, set HTTP status via `set.status = N`, not by returning a `new Response(body, { status: N })`.",
|
|
224
249
|
"bad": "// Example that violates the rule",
|
|
@@ -319,11 +344,26 @@
|
|
|
319
344
|
"bad": "// Example that violates the rule",
|
|
320
345
|
"good": "// Corrected version"
|
|
321
346
|
},
|
|
347
|
+
"tsforge/auth-cookie-must-set-maxage-or-expires": {
|
|
348
|
+
"what": "Auth-cookie writes should set `maxAge` or `expires` so session cookies do not live forever by default.",
|
|
349
|
+
"bad": "// Example that violates the rule",
|
|
350
|
+
"good": "// Corrected version"
|
|
351
|
+
},
|
|
352
|
+
"tsforge/auth-cookie-must-set-samesite": {
|
|
353
|
+
"what": "Auth-cookie writes must set `sameSite` (`strict` or `lax`) — missing SameSite allows cross-site cookie delivery.",
|
|
354
|
+
"bad": "// Example that violates the rule",
|
|
355
|
+
"good": "// Corrected version"
|
|
356
|
+
},
|
|
322
357
|
"tsforge/bcrypt-rounds-min": {
|
|
323
358
|
"what": "Disallow `bcrypt.hash` / `bcrypt.hashSync` calls with a numeric-literal rounds value below the configured minimum (default 10).",
|
|
324
359
|
"bad": "// Example that violates the rule",
|
|
325
360
|
"good": "// Corrected version"
|
|
326
361
|
},
|
|
362
|
+
"tsforge/jwt-must-verify-not-decode": {
|
|
363
|
+
"what": "Disallow `jwt.decode` / `decodeJwt` — decoding without verification accepts forged tokens. Use `jwt.verify` or `jwtVerify` instead.",
|
|
364
|
+
"bad": "// Example that violates the rule",
|
|
365
|
+
"good": "// Corrected version"
|
|
366
|
+
},
|
|
327
367
|
"tsforge/no-import-build-output": {
|
|
328
368
|
"what": "Disallow importing from build/output directories within the project. Source must import source, not compiled artifacts, to avoid stale-code drift and broken module boundaries.",
|
|
329
369
|
"bad": "// Example that violates the rule",
|
|
@@ -354,6 +394,11 @@
|
|
|
354
394
|
"bad": "// Example that violates the rule",
|
|
355
395
|
"good": "// Corrected version"
|
|
356
396
|
},
|
|
397
|
+
"tsforge/mutation-should-revalidate-cache": {
|
|
398
|
+
"what": "After database mutations in server actions or route handlers, call `revalidatePath` or `revalidateTag` so cached pages reflect the change.",
|
|
399
|
+
"bad": "// Example that violates the rule",
|
|
400
|
+
"good": "// Corrected version"
|
|
401
|
+
},
|
|
357
402
|
"tsforge/no-html-img-element": {
|
|
358
403
|
"what": "Prefer next/image over raw <img> elements for optimized responsive images and Core Web Vitals.",
|
|
359
404
|
"bad": "// Example that violates the rule",
|
|
@@ -374,6 +419,11 @@
|
|
|
374
419
|
"bad": "// Example that violates the rule",
|
|
375
420
|
"good": "// Corrected version"
|
|
376
421
|
},
|
|
422
|
+
"tsforge/no-secret-props-to-client": {
|
|
423
|
+
"what": "Warn when Server Components pass secret-looking props to JSX — values may cross the client boundary.",
|
|
424
|
+
"bad": "// Example that violates the rule",
|
|
425
|
+
"good": "// Corrected version"
|
|
426
|
+
},
|
|
377
427
|
"tsforge/no-sensitive-next-public-env": {
|
|
378
428
|
"what": "Disallow NEXT_PUBLIC_* env vars whose names suggest secrets — public build-time vars are visible in the client bundle.",
|
|
379
429
|
"bad": "// Example that violates the rule",
|
|
@@ -384,6 +434,16 @@
|
|
|
384
434
|
"bad": "// Example that violates the rule",
|
|
385
435
|
"good": "// Corrected version"
|
|
386
436
|
},
|
|
437
|
+
"tsforge/server-action-requires-authz-and-validation": {
|
|
438
|
+
"what": "Server actions (`\"use server\"`) that mutate the database must call authorization helpers and validate input with `.parse()` / `.safeParse()`.",
|
|
439
|
+
"bad": "// Example that violates the rule",
|
|
440
|
+
"good": "// Corrected version"
|
|
441
|
+
},
|
|
442
|
+
"tsforge/server-only-modules-import-server-only": {
|
|
443
|
+
"what": "App-router server modules must import `\"server-only\"` so accidental client bundling fails at build time.",
|
|
444
|
+
"bad": "// Example that violates the rule",
|
|
445
|
+
"good": "// Corrected version"
|
|
446
|
+
},
|
|
387
447
|
"tsforge/pkce-required-for-oidc": {
|
|
388
448
|
"what": "OIDC providers must use PKCE: `buildAuthorizationURL` must call `generateCodeVerifier()` and pass it to `createAuthorizationURL`.",
|
|
389
449
|
"bad": "// Example that violates the rule",
|
|
@@ -399,8 +459,13 @@
|
|
|
399
459
|
"bad": "// Example that violates the rule",
|
|
400
460
|
"good": "// Corrected version"
|
|
401
461
|
},
|
|
462
|
+
"tsforge/component-file-purity": {
|
|
463
|
+
"what": "A component .tsx contains only imports and the component itself — types go to <feature>.types.ts, constants to <feature>.constants.ts, helpers to src/lib",
|
|
464
|
+
"bad": "// Example that violates the rule",
|
|
465
|
+
"good": "// Corrected version"
|
|
466
|
+
},
|
|
402
467
|
"tsforge/component-folder-structure": {
|
|
403
|
-
"what": "
|
|
468
|
+
"what": "A component .tsx must live in src/views/<Feature>/components/ (feature component), src/components/ui/ (shared primitive), or be the view root src/views/<Feature>/index.tsx",
|
|
404
469
|
"bad": "// Example that violates the rule",
|
|
405
470
|
"good": "// Corrected version"
|
|
406
471
|
},
|
|
@@ -469,6 +534,31 @@
|
|
|
469
534
|
"bad": "// Example that violates the rule",
|
|
470
535
|
"good": "// Corrected version"
|
|
471
536
|
},
|
|
537
|
+
"tsforge/no-prototype-polluting-merge": {
|
|
538
|
+
"what": "Disallow merging request body/query/params into objects — enables prototype pollution.",
|
|
539
|
+
"bad": "// Example that violates the rule",
|
|
540
|
+
"good": "// Corrected version"
|
|
541
|
+
},
|
|
542
|
+
"tsforge/no-user-controlled-fetch-url": {
|
|
543
|
+
"what": "Disallow fetch/axios requests to non-literal URLs — dynamic URLs enable SSRF.",
|
|
544
|
+
"bad": "// Example that violates the rule",
|
|
545
|
+
"good": "// Corrected version"
|
|
546
|
+
},
|
|
547
|
+
"tsforge/no-user-controlled-redirect": {
|
|
548
|
+
"what": "Disallow redirects to non-literal URLs — user-controlled redirects enable open redirects.",
|
|
549
|
+
"bad": "// Example that violates the rule",
|
|
550
|
+
"good": "// Corrected version"
|
|
551
|
+
},
|
|
552
|
+
"tsforge/upload-must-set-limits": {
|
|
553
|
+
"what": "Multipart upload handlers should declare `limits` or `maxFileSize` to bound request size.",
|
|
554
|
+
"bad": "// Example that violates the rule",
|
|
555
|
+
"good": "// Corrected version"
|
|
556
|
+
},
|
|
557
|
+
"tsforge/webhook-must-verify-signature-before-parse": {
|
|
558
|
+
"what": "Webhook handlers must verify signatures before calling `.json()` on the request body.",
|
|
559
|
+
"bad": "// Example that violates the rule",
|
|
560
|
+
"good": "// Corrected version"
|
|
561
|
+
},
|
|
472
562
|
"tsforge/catch-must-handle": {
|
|
473
563
|
"what": "Catch blocks must log, rethrow, or propagate errors — not silently return empty defaults on failure.",
|
|
474
564
|
"bad": "// Example that violates the rule",
|
|
@@ -499,6 +589,16 @@
|
|
|
499
589
|
"bad": "// Example that violates the rule",
|
|
500
590
|
"good": "// Corrected version"
|
|
501
591
|
},
|
|
592
|
+
"tsforge/caught-error-log-requires-cause": {
|
|
593
|
+
"what": "When logging a caught error, include a `cause` field in the structured payload so downstream tools preserve the error chain.",
|
|
594
|
+
"bad": "// Example that violates the rule",
|
|
595
|
+
"good": "// Corrected version"
|
|
596
|
+
},
|
|
597
|
+
"tsforge/logger-not-console": {
|
|
598
|
+
"what": "Service modules should use the structured logger instead of `console.*` — console output is unstructured and hard to search.",
|
|
599
|
+
"bad": "// Example that violates the rule",
|
|
600
|
+
"good": "// Corrected version"
|
|
601
|
+
},
|
|
502
602
|
"tsforge/mask-pii-fields": {
|
|
503
603
|
"what": "Disallow unmasked PII (email, phone, password, token, ...) in structured-logger payloads — the #1 way data leaks quietly.",
|
|
504
604
|
"bad": "// Example that violates the rule",
|
|
@@ -519,14 +619,49 @@
|
|
|
519
619
|
"bad": "// Example that violates the rule",
|
|
520
620
|
"good": "// Corrected version"
|
|
521
621
|
},
|
|
622
|
+
"tsforge/fake-timers-must-be-restored": {
|
|
623
|
+
"what": "When a test file calls `useFakeTimers()`, it must also call `useRealTimers()` so later tests are not affected.",
|
|
624
|
+
"bad": "// Example that violates the rule",
|
|
625
|
+
"good": "// Corrected version"
|
|
626
|
+
},
|
|
627
|
+
"tsforge/no-conditional-expect": {
|
|
628
|
+
"what": "Disallow `expect()` inside conditionals — tests must fail when assertions are skipped.",
|
|
629
|
+
"bad": "// Example that violates the rule",
|
|
630
|
+
"good": "// Corrected version"
|
|
631
|
+
},
|
|
522
632
|
"tsforge/no-focused-tests": {
|
|
523
633
|
"what": "Disallow focused tests (`test.only`, `it.only`, `fdescribe`, ...) — the canonical 'I forgot to remove this before committing' leak.",
|
|
524
634
|
"bad": "// Example that violates the rule",
|
|
525
635
|
"good": "// Corrected version"
|
|
526
636
|
},
|
|
637
|
+
"tsforge/no-real-network-in-unit-tests": {
|
|
638
|
+
"what": "Unit tests should not perform real network I/O — mock HTTP clients or move the test to an integration suite.",
|
|
639
|
+
"bad": "// Example that violates the rule",
|
|
640
|
+
"good": "// Corrected version"
|
|
641
|
+
},
|
|
527
642
|
"tsforge/test-file-mirrors-source": {
|
|
528
643
|
"what": "Every test file under `tests/` must mirror a source file under `src/`. Catches orphaned tests left behind after refactors and renames.",
|
|
529
644
|
"bad": "// Example that violates the rule",
|
|
530
645
|
"good": "// Corrected version"
|
|
646
|
+
},
|
|
647
|
+
"tsforge/exported-functions-require-return-type": {
|
|
648
|
+
"what": "Exported functions should declare an explicit return type at module boundaries.",
|
|
649
|
+
"bad": "// Example that violates the rule",
|
|
650
|
+
"good": "// Corrected version"
|
|
651
|
+
},
|
|
652
|
+
"tsforge/fetch-must-check-ok": {
|
|
653
|
+
"what": "HTTP fetch responses must check `.ok` or status before calling `.json()`.",
|
|
654
|
+
"bad": "// Example that violates the rule",
|
|
655
|
+
"good": "// Corrected version"
|
|
656
|
+
},
|
|
657
|
+
"tsforge/json-parse-must-validate": {
|
|
658
|
+
"what": "Disallow bare JSON.parse on untrusted input — validate through a schema library.",
|
|
659
|
+
"bad": "// Example that violates the rule",
|
|
660
|
+
"good": "// Corrected version"
|
|
661
|
+
},
|
|
662
|
+
"tsforge/no-unsafe-boundary-cast": {
|
|
663
|
+
"what": "Disallow type assertions immediately after parsing untrusted boundary input.",
|
|
664
|
+
"bad": "// Example that violates the rule",
|
|
665
|
+
"good": "// Corrected version"
|
|
531
666
|
}
|
|
532
667
|
}
|
package/src/loop/run.ts
CHANGED
package/src/loop/session.ts
CHANGED
|
@@ -446,6 +446,7 @@ export class Session {
|
|
|
446
446
|
this.state = {
|
|
447
447
|
prevGateErrors: [],
|
|
448
448
|
gateNoProgress: 0,
|
|
449
|
+
errorAge: new Map(),
|
|
449
450
|
lastGateCount: -1,
|
|
450
451
|
edits: 0,
|
|
451
452
|
regressions: 0,
|
|
@@ -698,8 +699,13 @@ export class Session {
|
|
|
698
699
|
*/
|
|
699
700
|
async send(text: string, opts: ISendOptions = {}): Promise<ISendResult> {
|
|
700
701
|
const { ctx, report } = this;
|
|
702
|
+
// Interactive ceiling is a RUNAWAY backstop, not the primary stop — the
|
|
703
|
+
// progress guards (samePersist / gateNoProgress) pull the agent out the moment
|
|
704
|
+
// it stops converging. Set high so normal long back-and-forth never trips it.
|
|
701
705
|
const maxTurns =
|
|
702
|
-
this.maxTurnsOverride ??
|
|
706
|
+
this.maxTurnsOverride ??
|
|
707
|
+
this.cfg.maxTurns ??
|
|
708
|
+
LOOP_LIMITS.interactiveBackstopTurns;
|
|
703
709
|
const sendStart = performance.now();
|
|
704
710
|
|
|
705
711
|
// Thread cancellation to the tool `run` commands and the gate (not just the
|
|
@@ -1505,7 +1511,7 @@ export class Session {
|
|
|
1505
1511
|
kind: "stuck",
|
|
1506
1512
|
task: SESSION_ID,
|
|
1507
1513
|
cycles: maxTurns,
|
|
1508
|
-
message: `stuck (hit ${maxTurns}-turn
|
|
1514
|
+
message: `stuck (hit the ${maxTurns}-turn runaway backstop — progress guards never tripped, which is unusual; re-steer or narrow the task)`,
|
|
1509
1515
|
});
|
|
1510
1516
|
|
|
1511
1517
|
return { status: "stuck", turns: maxTurns };
|
package/src/loop/turn.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
sameErrorSet,
|
|
9
9
|
type ErrorParser,
|
|
10
10
|
type ErrorSet,
|
|
11
|
+
type IErrorItem,
|
|
11
12
|
} from "../validate";
|
|
12
13
|
import { isInScope } from "../lib/scope";
|
|
13
14
|
import { fileExists, resolveScopeFiles } from "../lib/fs";
|
|
@@ -126,6 +127,9 @@ export interface ILoopCtx {
|
|
|
126
127
|
export interface ILoopState {
|
|
127
128
|
prevGateErrors: ErrorSet;
|
|
128
129
|
gateNoProgress: number;
|
|
130
|
+
/** Per-error-key (file:rule) survival count: how many consecutive gate cycles
|
|
131
|
+
* each error has persisted. Drives the primary `samePersist` no-progress stop. */
|
|
132
|
+
errorAge: Map<string, number>;
|
|
129
133
|
lastGateCount: number;
|
|
130
134
|
edits: number;
|
|
131
135
|
regressions: number;
|
|
@@ -688,6 +692,46 @@ function autoFixNotice(files: string[]): string {
|
|
|
688
692
|
);
|
|
689
693
|
}
|
|
690
694
|
|
|
695
|
+
/**
|
|
696
|
+
* Advance each error's per-(file:rule) survival count and return the first error
|
|
697
|
+
* that has now persisted for `samePersist` consecutive gate cycles — the model
|
|
698
|
+
* keeps failing at the SAME thing — or null. Rebuilds the map from the CURRENT
|
|
699
|
+
* keys, so a fixed error's age drops out (no stale growth) and an error that
|
|
700
|
+
* comes back later starts fresh. Catches "stuck on X" even while OTHER errors
|
|
701
|
+
* churn around it (which the whole-set `gateNoProgress` guard misses).
|
|
702
|
+
*/
|
|
703
|
+
export function trackErrorAges(
|
|
704
|
+
state: ILoopState,
|
|
705
|
+
gateErrors: ErrorSet
|
|
706
|
+
): IErrorItem | null {
|
|
707
|
+
const next = new Map<string, number>();
|
|
708
|
+
let stuck: IErrorItem | null = null;
|
|
709
|
+
|
|
710
|
+
for (const e of gateErrors) {
|
|
711
|
+
const age = (state.errorAge.get(e.key) ?? 0) + 1;
|
|
712
|
+
|
|
713
|
+
next.set(e.key, age);
|
|
714
|
+
|
|
715
|
+
if (age >= LOOP_LIMITS.samePersist && stuck === null) {
|
|
716
|
+
stuck = e;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
state.errorAge = next;
|
|
721
|
+
|
|
722
|
+
return stuck;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** The blocker diagnosis surfaced when a single error persists too long — names
|
|
726
|
+
* the rule + file + attempt count + the last message, so an interactive session
|
|
727
|
+
* hands back something the user can act on. */
|
|
728
|
+
export function persistDetail(e: IErrorItem): string {
|
|
729
|
+
const where = e.file !== undefined ? ` in ${e.file}` : "";
|
|
730
|
+
const rule = e.rule ?? "the same error";
|
|
731
|
+
|
|
732
|
+
return `stuck on ${rule}${where} after ${String(LOOP_LIMITS.samePersist)} attempts (last: ${e.message.slice(0, 140)})`;
|
|
733
|
+
}
|
|
734
|
+
|
|
691
735
|
/**
|
|
692
736
|
* The deterministic gate — the only authority on "done". Auto-fix, run the
|
|
693
737
|
* optional fix command, validate, and return a terminal result (done/stuck) or
|
|
@@ -817,17 +861,47 @@ export async function settleGate(
|
|
|
817
861
|
};
|
|
818
862
|
}
|
|
819
863
|
|
|
864
|
+
// PRIMARY no-progress stop: the model keeps failing at the SAME (file,rule)
|
|
865
|
+
// for `samePersist` cycles running — even if other errors churn. Hand back a
|
|
866
|
+
// concrete blocker rather than spinning to a raw turn cap.
|
|
867
|
+
const persisted = trackErrorAges(state, gateErrors);
|
|
868
|
+
|
|
869
|
+
if (persisted !== null) {
|
|
870
|
+
const detail = persistDetail(persisted);
|
|
871
|
+
|
|
872
|
+
report({
|
|
873
|
+
kind: "stuck",
|
|
874
|
+
task: task.id,
|
|
875
|
+
cycles: turn,
|
|
876
|
+
detail,
|
|
877
|
+
message: `task ${task.id}: ${detail}`,
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
return {
|
|
881
|
+
task: task.id,
|
|
882
|
+
redConfirmed: true,
|
|
883
|
+
status: RUN_STATUS.stuck,
|
|
884
|
+
cycles: turn,
|
|
885
|
+
reason: STUCK_REASON.stalled,
|
|
886
|
+
detail,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Coarser secondary net: the WHOLE error set unchanged this many cycles.
|
|
820
891
|
state.gateNoProgress = sameErrorSet(state.prevGateErrors, gateErrors)
|
|
821
892
|
? state.gateNoProgress + 1
|
|
822
893
|
: 0;
|
|
823
894
|
state.prevGateErrors = gateErrors;
|
|
824
895
|
|
|
825
896
|
if (state.gateNoProgress >= LOOP_LIMITS.gateStuckRepeats) {
|
|
897
|
+
const detail = `gate unchanged ${String(LOOP_LIMITS.gateStuckRepeats)} cycles (${String(gateErrors.length)} error(s) not converging)`;
|
|
898
|
+
|
|
826
899
|
report({
|
|
827
900
|
kind: "stuck",
|
|
828
901
|
task: task.id,
|
|
829
902
|
cycles: turn,
|
|
830
|
-
|
|
903
|
+
detail,
|
|
904
|
+
message: `task ${task.id}: stuck — ${detail}`,
|
|
831
905
|
});
|
|
832
906
|
|
|
833
907
|
return {
|
|
@@ -836,6 +910,7 @@ export async function settleGate(
|
|
|
836
910
|
status: RUN_STATUS.stuck,
|
|
837
911
|
cycles: turn,
|
|
838
912
|
reason: STUCK_REASON.stalled,
|
|
913
|
+
detail,
|
|
839
914
|
};
|
|
840
915
|
}
|
|
841
916
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { TSESLint } from "@typescript-eslint/utils";
|
|
2
2
|
|
|
3
3
|
import { dangerousHtmlRequiresSanitizeRule } from "./rules/dangerous-html-requires-sanitize";
|
|
4
|
+
import { componentFilePurityRule } from "./rules/component-file-purity";
|
|
4
5
|
import { componentFolderStructureRule } from "./rules/component-folder-structure";
|
|
5
6
|
import { forwardrefDisplayNameRule } from "./rules/forwardref-display-name";
|
|
6
7
|
import { indexMustReexportDefaultRule } from "./rules/index-must-reexport-default";
|
|
@@ -17,6 +18,7 @@ import { noStateInComponentBodyRule } from "./rules/no-state-in-component-body";
|
|
|
17
18
|
import type { IRulePack } from "../rule-packs.types";
|
|
18
19
|
|
|
19
20
|
const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
|
|
21
|
+
"component-file-purity": componentFilePurityRule,
|
|
20
22
|
"component-folder-structure": componentFolderStructureRule,
|
|
21
23
|
"dangerous-html-requires-sanitize": dangerousHtmlRequiresSanitizeRule,
|
|
22
24
|
"forwardref-display-name": forwardrefDisplayNameRule,
|
|
@@ -39,6 +41,7 @@ export const reactComponentArchitecturePack: IRulePack = {
|
|
|
39
41
|
"Component structure, composition, and file organization for React",
|
|
40
42
|
rules,
|
|
41
43
|
rulesConfig: {
|
|
44
|
+
"component-file-purity": "error",
|
|
42
45
|
"component-folder-structure": "error",
|
|
43
46
|
"dangerous-html-requires-sanitize": "error",
|
|
44
47
|
"forwardref-display-name": "error",
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
import {
|
|
5
|
+
isComponentDeclaration,
|
|
6
|
+
isInShadcnUi,
|
|
7
|
+
isRouteFile,
|
|
8
|
+
isStoryFile,
|
|
9
|
+
isTestFile,
|
|
10
|
+
unwrapExport,
|
|
11
|
+
} from "../utils";
|
|
12
|
+
|
|
13
|
+
export const RULE_NAME = "component-file-purity";
|
|
14
|
+
|
|
15
|
+
type MessageIds = "inlineType" | "inlineConstant" | "inlineHelper";
|
|
16
|
+
|
|
17
|
+
/** Map an offending top-level declaration to the message that tells the model
|
|
18
|
+
* where it belongs. Returns null for declarations that are allowed to sit
|
|
19
|
+
* beside a component (none today — the component itself is filtered earlier). */
|
|
20
|
+
function messageForDeclaration(node: TSESTree.Node): MessageIds | null {
|
|
21
|
+
switch (node.type) {
|
|
22
|
+
case AST_NODE_TYPES.TSInterfaceDeclaration:
|
|
23
|
+
case AST_NODE_TYPES.TSTypeAliasDeclaration:
|
|
24
|
+
case AST_NODE_TYPES.TSEnumDeclaration:
|
|
25
|
+
return "inlineType";
|
|
26
|
+
case AST_NODE_TYPES.VariableDeclaration:
|
|
27
|
+
return "inlineConstant";
|
|
28
|
+
case AST_NODE_TYPES.FunctionDeclaration:
|
|
29
|
+
return "inlineHelper";
|
|
30
|
+
default:
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const componentFilePurityRule = createRule<[], MessageIds>({
|
|
36
|
+
name: RULE_NAME,
|
|
37
|
+
meta: {
|
|
38
|
+
type: "problem",
|
|
39
|
+
docs: {
|
|
40
|
+
description:
|
|
41
|
+
"A component .tsx contains only imports and the component itself — types go to <feature>.types.ts, constants to <feature>.constants.ts, helpers to src/lib",
|
|
42
|
+
},
|
|
43
|
+
schema: [],
|
|
44
|
+
messages: {
|
|
45
|
+
inlineType:
|
|
46
|
+
"No inline types in a component file — move this to <feature>.types.ts (or src/shared/shared.types.ts if cross-feature) and import it.",
|
|
47
|
+
inlineConstant:
|
|
48
|
+
"No inline constants in a component file — move this to <feature>.constants.ts and import it. A component file holds only imports and the component.",
|
|
49
|
+
inlineHelper:
|
|
50
|
+
"No inline helper functions in a component file — move pure helpers (formatters, etc.) to src/lib and import them. A component file holds only imports and the component.",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
defaultOptions: [],
|
|
54
|
+
create(context) {
|
|
55
|
+
const filename = context.filename;
|
|
56
|
+
|
|
57
|
+
if (!filename.endsWith(".tsx")) {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Primitives (cva variant consts) and route shells (`const Route = …`)
|
|
62
|
+
// legitimately carry non-component module declarations. Tests/stories too.
|
|
63
|
+
if (
|
|
64
|
+
isInShadcnUi(filename) ||
|
|
65
|
+
isRouteFile(filename) ||
|
|
66
|
+
isStoryFile(filename) ||
|
|
67
|
+
isTestFile(filename)
|
|
68
|
+
) {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
Program(program) {
|
|
74
|
+
const unwrapped = program.body.map((s) => ({
|
|
75
|
+
stmt: s,
|
|
76
|
+
decl: unwrapExport(s),
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
// Only enforce purity on files that actually define a component; a .tsx
|
|
80
|
+
// with no component isn't a "component file" this rule governs.
|
|
81
|
+
const hasComponent = unwrapped.some((u) =>
|
|
82
|
+
isComponentDeclaration(u.decl)
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (!hasComponent) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const { decl } of unwrapped) {
|
|
90
|
+
if (isComponentDeclaration(decl)) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const messageId = messageForDeclaration(decl);
|
|
95
|
+
|
|
96
|
+
if (messageId === null) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
context.report({ node: decl, messageId });
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
});
|
|
@@ -1,44 +1,34 @@
|
|
|
1
|
-
import { existsSync } from "fs";
|
|
2
|
-
import { dirname, join } from "path";
|
|
3
1
|
import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
|
|
4
2
|
|
|
5
3
|
import { createRule } from "../../create-rule";
|
|
6
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getComponentName,
|
|
6
|
+
isComponentFile,
|
|
7
|
+
isInShadcnUi,
|
|
8
|
+
isRouteFile,
|
|
9
|
+
} from "../utils";
|
|
7
10
|
|
|
8
11
|
export const RULE_NAME = "component-folder-structure";
|
|
9
12
|
|
|
10
13
|
export interface ComponentFolderStructureOptions {
|
|
11
|
-
|
|
14
|
+
/** Substrings; a component file whose path matches any is left alone. */
|
|
12
15
|
readonly ignorePaths?: readonly string[];
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
type RuleOptions = [ComponentFolderStructureOptions];
|
|
16
|
-
type MessageIds = "
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
const DEFAULT_IGNORE_PATHS = [
|
|
27
|
-
"src/components/ui/",
|
|
28
|
-
"tests/",
|
|
29
|
-
"e2e/",
|
|
30
|
-
".storybook/",
|
|
31
|
-
"node_modules",
|
|
32
|
-
];
|
|
19
|
+
type MessageIds = "wrongLocation";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_IGNORE_PATHS = ["tests/", "e2e/", ".storybook/", "node_modules"];
|
|
22
|
+
|
|
23
|
+
/** A feature component lives at src/views/<Feature>/components/<X>.tsx (nesting
|
|
24
|
+
* under components/ is allowed). The view root is src/views/<Feature>/index.tsx
|
|
25
|
+
* (lowercase ⇒ not a PascalCase component file, so it never reaches here). */
|
|
26
|
+
const FEATURE_COMPONENT = /(^|\/)src\/views\/[^/]+\/components\//;
|
|
33
27
|
|
|
34
28
|
const optionSchema: JSONSchema4 = {
|
|
35
29
|
type: "object",
|
|
36
30
|
additionalProperties: false,
|
|
37
31
|
properties: {
|
|
38
|
-
requiredSiblings: {
|
|
39
|
-
type: "array",
|
|
40
|
-
items: { type: "string" },
|
|
41
|
-
},
|
|
42
32
|
ignorePaths: {
|
|
43
33
|
type: "array",
|
|
44
34
|
items: { type: "string" },
|
|
@@ -53,20 +43,15 @@ export const componentFolderStructureRule = createRule<RuleOptions, MessageIds>(
|
|
|
53
43
|
type: "problem",
|
|
54
44
|
docs: {
|
|
55
45
|
description:
|
|
56
|
-
"
|
|
46
|
+
"A component .tsx must live in src/views/<Feature>/components/ (feature component), src/components/ui/ (shared primitive), or be the view root src/views/<Feature>/index.tsx",
|
|
57
47
|
},
|
|
58
48
|
schema: [optionSchema],
|
|
59
49
|
messages: {
|
|
60
|
-
|
|
61
|
-
"Component '{{name}}' is
|
|
50
|
+
wrongLocation:
|
|
51
|
+
"Component '{{name}}' is in the wrong place. Put it in src/views/<Feature>/components/{{name}}.tsx (a feature component), src/components/ui/ (a shared primitive), or make it the view root src/views/<Feature>/index.tsx — do NOT scatter components under {{dir}}.",
|
|
62
52
|
},
|
|
63
53
|
},
|
|
64
|
-
defaultOptions: [
|
|
65
|
-
{
|
|
66
|
-
requiredSiblings: DEFAULT_SIBLINGS,
|
|
67
|
-
ignorePaths: DEFAULT_IGNORE_PATHS,
|
|
68
|
-
},
|
|
69
|
-
],
|
|
54
|
+
defaultOptions: [{ ignorePaths: DEFAULT_IGNORE_PATHS }],
|
|
70
55
|
create(context, [options]) {
|
|
71
56
|
const filename = context.filename;
|
|
72
57
|
|
|
@@ -80,42 +65,32 @@ export const componentFolderStructureRule = createRule<RuleOptions, MessageIds>(
|
|
|
80
65
|
return {};
|
|
81
66
|
}
|
|
82
67
|
|
|
83
|
-
|
|
68
|
+
// Allowed homes: shared primitives, generated route shells, feature
|
|
69
|
+
// components. Anything else is a scattered/mis-placed component.
|
|
70
|
+
if (
|
|
71
|
+
isInShadcnUi(filename) ||
|
|
72
|
+
isRouteFile(filename) ||
|
|
73
|
+
FEATURE_COMPONENT.test(filename)
|
|
74
|
+
) {
|
|
84
75
|
return {};
|
|
85
76
|
}
|
|
86
77
|
|
|
87
78
|
const componentName = getComponentName(filename);
|
|
88
79
|
|
|
89
|
-
if (
|
|
80
|
+
if (componentName === null) {
|
|
90
81
|
return {};
|
|
91
82
|
}
|
|
92
83
|
|
|
84
|
+
const slash = filename.lastIndexOf("/");
|
|
85
|
+
const dir = slash === -1 ? "." : filename.slice(0, slash);
|
|
86
|
+
|
|
93
87
|
return {
|
|
94
88
|
"Program:exit"(node) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
for (const sibling of requiredSiblings) {
|
|
101
|
-
const siblingPath = sibling.replace("<Name>", componentName);
|
|
102
|
-
const fullPath = join(dir, siblingPath);
|
|
103
|
-
|
|
104
|
-
if (!existsSync(fullPath)) {
|
|
105
|
-
missing.push(siblingPath);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (missing.length > 0) {
|
|
110
|
-
context.report({
|
|
111
|
-
node,
|
|
112
|
-
messageId: "missingSiblings",
|
|
113
|
-
data: {
|
|
114
|
-
name: componentName,
|
|
115
|
-
missing: missing.join(", "),
|
|
116
|
-
},
|
|
117
|
-
});
|
|
118
|
-
}
|
|
89
|
+
context.report({
|
|
90
|
+
node,
|
|
91
|
+
messageId: "wrongLocation",
|
|
92
|
+
data: { name: componentName, dir },
|
|
93
|
+
});
|
|
119
94
|
},
|
|
120
95
|
};
|
|
121
96
|
},
|
|
@@ -82,6 +82,68 @@ export function isInShadcnUi(filename: string): boolean {
|
|
|
82
82
|
return filename.includes("/components/ui/");
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Detect if a path is a TanStack route file (generated/hand-wired shells under
|
|
87
|
+
* src/routes/). These legitimately hold a non-component `const Route =
|
|
88
|
+
* createFileRoute(...)` and are exempt from the component-purity/location rules.
|
|
89
|
+
*/
|
|
90
|
+
export function isRouteFile(filename: string): boolean {
|
|
91
|
+
return /(^|\/)src\/routes\//.test(filename);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A name is a component name when it is PascalCase (starts with an uppercase). */
|
|
95
|
+
export function isComponentName(name: string): boolean {
|
|
96
|
+
return /^[A-Z]/.test(name);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** True when a node is a function expression/arrow (a component's init shape). */
|
|
100
|
+
function isFunctionInit(node: TSESTree.Expression | null | undefined): boolean {
|
|
101
|
+
return (
|
|
102
|
+
node?.type === AST_NODE_TYPES.ArrowFunctionExpression ||
|
|
103
|
+
node?.type === AST_NODE_TYPES.FunctionExpression
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Given a top-level statement (already unwrapped from any `export`), report
|
|
109
|
+
* whether it DECLARES a React component — a PascalCase `function`, or a
|
|
110
|
+
* `const PascalCase = (…) => …` whose init is a function. A `const Route =
|
|
111
|
+
* createFileRoute(...)(...)` is NOT a component (its init is a call), so route
|
|
112
|
+
* files don't trip this.
|
|
113
|
+
*/
|
|
114
|
+
export function isComponentDeclaration(node: TSESTree.Node): boolean {
|
|
115
|
+
if (node.type === AST_NODE_TYPES.FunctionDeclaration && node.id !== null) {
|
|
116
|
+
return isComponentName(node.id.name);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (node.type === AST_NODE_TYPES.VariableDeclaration) {
|
|
120
|
+
return node.declarations.some(
|
|
121
|
+
(d) =>
|
|
122
|
+
d.id.type === AST_NODE_TYPES.Identifier &&
|
|
123
|
+
isComponentName(d.id.name) &&
|
|
124
|
+
isFunctionInit(d.init)
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Unwrap a top-level statement from its `export`/`export default` wrapper, so
|
|
132
|
+
* callers classify the underlying declaration uniformly. */
|
|
133
|
+
export function unwrapExport(
|
|
134
|
+
statement: TSESTree.ProgramStatement
|
|
135
|
+
): TSESTree.Node {
|
|
136
|
+
if (
|
|
137
|
+
(statement.type === AST_NODE_TYPES.ExportNamedDeclaration ||
|
|
138
|
+
statement.type === AST_NODE_TYPES.ExportDefaultDeclaration) &&
|
|
139
|
+
statement.declaration !== null
|
|
140
|
+
) {
|
|
141
|
+
return statement.declaration;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return statement;
|
|
145
|
+
}
|
|
146
|
+
|
|
85
147
|
/**
|
|
86
148
|
* Extract component name from filename (e.g., Button.tsx → Button)
|
|
87
149
|
*/
|
|
@@ -18,6 +18,7 @@ const RULE_ENTRIES: Readonly<Record<string, IRuleCatalogEntry>> = {
|
|
|
18
18
|
falsePositiveRisk: "medium",
|
|
19
19
|
},
|
|
20
20
|
"component-folder-structure": { tier: "architecture", tags: ["react"] },
|
|
21
|
+
"component-file-purity": { tier: "architecture", tags: ["react"] },
|
|
21
22
|
"no-state-in-component-body": { tier: "architecture", tags: ["react"] },
|
|
22
23
|
"no-inline-jsx-functions": { tier: "architecture", tags: ["react"] },
|
|
23
24
|
"no-anonymous-useEffect": {
|
package/src/web-components.ts
CHANGED
|
@@ -365,64 +365,66 @@ export function Separator({
|
|
|
365
365
|
`,
|
|
366
366
|
table: `import { cn } from "@/lib/utils";
|
|
367
367
|
|
|
368
|
-
|
|
368
|
+
// A column-driven table: pass your typed rows + a column spec and it renders.
|
|
369
|
+
// This is the table — there is NO per-feature wrapper (no DealsTable/UsersTable).
|
|
370
|
+
// Define columns as a feature constant (e.g. dashboard.constants.ts) and render
|
|
371
|
+
// <Table columns={dealColumns} data={deals} rowKey={(d) => d.id} />.
|
|
372
|
+
export interface IColumn<T> {
|
|
373
|
+
readonly header: string;
|
|
374
|
+
readonly cell: (row: T) => React.ReactNode;
|
|
375
|
+
readonly className?: string;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function Table<T>({
|
|
379
|
+
columns,
|
|
380
|
+
data,
|
|
381
|
+
rowKey,
|
|
369
382
|
className,
|
|
370
|
-
|
|
371
|
-
|
|
383
|
+
}: {
|
|
384
|
+
readonly columns: readonly IColumn<T>[];
|
|
385
|
+
readonly data: readonly T[];
|
|
386
|
+
readonly rowKey: (row: T) => string;
|
|
387
|
+
readonly className?: string;
|
|
388
|
+
}): React.JSX.Element {
|
|
372
389
|
return (
|
|
373
390
|
<div className="relative w-full overflow-auto $DELTA">
|
|
374
|
-
<table className={cn("w-full caption-bottom text-sm", className)}
|
|
391
|
+
<table className={cn("w-full caption-bottom text-sm", className)}>
|
|
392
|
+
<thead className="[&_tr]:border-b">
|
|
393
|
+
<tr className="border-b transition-colors hover:bg-muted/50">
|
|
394
|
+
{columns.map((col) => (
|
|
395
|
+
<th
|
|
396
|
+
key={col.header}
|
|
397
|
+
className={cn(
|
|
398
|
+
"h-10 px-2 text-left align-middle font-medium text-muted-foreground",
|
|
399
|
+
col.className
|
|
400
|
+
)}
|
|
401
|
+
>
|
|
402
|
+
{col.header}
|
|
403
|
+
</th>
|
|
404
|
+
))}
|
|
405
|
+
</tr>
|
|
406
|
+
</thead>
|
|
407
|
+
<tbody className="[&_tr:last-child]:border-0">
|
|
408
|
+
{data.map((row) => (
|
|
409
|
+
<tr
|
|
410
|
+
key={rowKey(row)}
|
|
411
|
+
className="border-b transition-colors hover:bg-muted/50"
|
|
412
|
+
>
|
|
413
|
+
{columns.map((col) => (
|
|
414
|
+
<td
|
|
415
|
+
key={col.header}
|
|
416
|
+
className={cn("p-2 align-middle", col.className)}
|
|
417
|
+
>
|
|
418
|
+
{col.cell(row)}
|
|
419
|
+
</td>
|
|
420
|
+
))}
|
|
421
|
+
</tr>
|
|
422
|
+
))}
|
|
423
|
+
</tbody>
|
|
424
|
+
</table>
|
|
375
425
|
</div>
|
|
376
426
|
);
|
|
377
427
|
}
|
|
378
|
-
|
|
379
|
-
export function TableHeader({
|
|
380
|
-
className,
|
|
381
|
-
...props
|
|
382
|
-
}: React.HTMLAttributes<HTMLTableSectionElement>): React.JSX.Element {
|
|
383
|
-
return <thead className={cn("[&_tr]:border-b", className)} {...props} />;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
export function TableBody({
|
|
387
|
-
className,
|
|
388
|
-
...props
|
|
389
|
-
}: React.HTMLAttributes<HTMLTableSectionElement>): React.JSX.Element {
|
|
390
|
-
return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
export function TableRow({
|
|
394
|
-
className,
|
|
395
|
-
...props
|
|
396
|
-
}: React.HTMLAttributes<HTMLTableRowElement>): React.JSX.Element {
|
|
397
|
-
return (
|
|
398
|
-
<tr
|
|
399
|
-
className={cn("border-b transition-colors hover:bg-muted/50", className)}
|
|
400
|
-
{...props}
|
|
401
|
-
/>
|
|
402
|
-
);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
export function TableHead({
|
|
406
|
-
className,
|
|
407
|
-
...props
|
|
408
|
-
}: React.ThHTMLAttributes<HTMLTableCellElement>): React.JSX.Element {
|
|
409
|
-
return (
|
|
410
|
-
<th
|
|
411
|
-
className={cn(
|
|
412
|
-
"h-10 px-2 text-left align-middle font-medium text-muted-foreground",
|
|
413
|
-
className
|
|
414
|
-
)}
|
|
415
|
-
{...props}
|
|
416
|
-
/>
|
|
417
|
-
);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
export function TableCell({
|
|
421
|
-
className,
|
|
422
|
-
...props
|
|
423
|
-
}: React.TdHTMLAttributes<HTMLTableCellElement>): React.JSX.Element {
|
|
424
|
-
return <td className={cn("p-2 align-middle", className)} {...props} />;
|
|
425
|
-
}
|
|
426
428
|
`,
|
|
427
429
|
// ─── composition blocks (molecules) ────────────────────────────────────────
|
|
428
430
|
"app-shell": `import { Link, Outlet, useLocation } from "@tanstack/react-router";
|
package/src/web-templates.ts
CHANGED
|
@@ -437,20 +437,29 @@ const REACT_GUIDANCE = [
|
|
|
437
437
|
"TanStack (Router + Query) — ALREADY scaffolded and its dependencies INSTALLED.",
|
|
438
438
|
"Build the app by adding/editing files under src/. Do NOT touch vite.config.ts,",
|
|
439
439
|
"index.html, tsconfig.json, components.json, or the build setup.",
|
|
440
|
-
"FILE LAYOUT —
|
|
441
|
-
"
|
|
442
|
-
"or
|
|
443
|
-
" •
|
|
444
|
-
"
|
|
445
|
-
"
|
|
446
|
-
"
|
|
447
|
-
" –
|
|
448
|
-
"
|
|
449
|
-
"
|
|
450
|
-
"
|
|
451
|
-
"
|
|
452
|
-
"
|
|
453
|
-
"
|
|
440
|
+
"FILE LAYOUT — VIEWS. ONE thing per file, and the GATE ENFORCES IT (lint errors,",
|
|
441
|
+
"not style): a component .tsx holds ONLY imports + the component — NO inline types,",
|
|
442
|
+
"constants, or helper functions. Every screen/feature is a VIEW:",
|
|
443
|
+
" • src/views/<Feature>/ — one folder per feature (PascalCase, e.g. Dashboard). Put:",
|
|
444
|
+
" – index.tsx — THE VIEW: the composition root. It imports its pieces from",
|
|
445
|
+
" ./components and the shared primitives in @/components/ui and assembles the",
|
|
446
|
+
" screen. This is the only component allowed at the feature root.",
|
|
447
|
+
" – components/<X>.tsx — the feature's own components, ONE component per file",
|
|
448
|
+
" (PascalCase). Create one ONLY when a piece needs local state, is reused in",
|
|
449
|
+
" the view, or is big enough to stand alone — otherwise compose primitives",
|
|
450
|
+
" directly in index.tsx. Do NOT wrap a single primitive in a feature name",
|
|
451
|
+
" (NO `DealsTable` around <Table> — render <Table> with deal columns instead).",
|
|
452
|
+
" – <feature>.types.ts — the feature's interfaces/types (I-prefixed).",
|
|
453
|
+
" – <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",
|
|
455
|
+
" hook; add a hook file ONLY for genuine derived/computed state, never to fetch.)",
|
|
456
|
+
" • A component .tsx (index.tsx or components/<X>.tsx) = imports + the component,",
|
|
457
|
+
" nothing else. A constant (label map, column spec) → <feature>.constants.ts. A type",
|
|
458
|
+
" → <feature>.types.ts (shared across features → src/shared/shared.types.ts). A pure",
|
|
459
|
+
" helper (formatCurrency, timeAgo) → src/lib/<name>.ts. Putting any of these atop a",
|
|
460
|
+
" component is a GATE ERROR (component-file-purity / component-folder-structure).",
|
|
461
|
+
" • Shared, reusable UI primitives live in @/components/ui (scaffold_ui) — they are",
|
|
462
|
+
" feature-agnostic. Anything feature-specific is a view component, never a primitive.",
|
|
454
463
|
" • NO RUNTIME VALIDATION / PARSING — there is NO backend, network, or uploaded",
|
|
455
464
|
" data here; EVERY value originates from your own typed code + seed, so TypeScript",
|
|
456
465
|
" has already proven its shape. The TYPE SYSTEM is the only validation. NEVER create",
|
|
@@ -467,8 +476,9 @@ const REACT_GUIDANCE = [
|
|
|
467
476
|
" create/edit pages (e.g. /deals/create). It writes every src/routes/*.tsx stub AND",
|
|
468
477
|
" the real home at src/routes/index.tsx and regenerates the route tree, so the whole",
|
|
469
478
|
" app navigates and every <Link to>/navigate target type-checks from that point on.",
|
|
470
|
-
" NEVER hand-write or hand-edit route files or createFileRoute paths.
|
|
471
|
-
"
|
|
479
|
+
" NEVER hand-write or hand-edit route files or createFileRoute paths. A route file is",
|
|
480
|
+
" a THIN SHELL: it renders its view (e.g. `import { Dashboard } from",
|
|
481
|
+
" '@/views/Dashboard'`), no UI logic of its own. Build the views ONE feature at a time.",
|
|
472
482
|
" – shadcn/ui primitives stay in @/components/ui (Button exists; add more there",
|
|
473
483
|
" following cva + cn() + tokens).",
|
|
474
484
|
" • src/routeTree.gen.ts is AUTO-GENERATED by the Vite build from your route",
|
|
@@ -510,15 +520,19 @@ const REACT_GUIDANCE = [
|
|
|
510
520
|
" (label+control+error), form-actions, toolbar, empty-state. COMPOSE these:",
|
|
511
521
|
" layout = app-shell; a list view = page-header + toolbar + table + empty-state;",
|
|
512
522
|
" a form = field × N + form-actions. NEVER hand-roll a component OR this view",
|
|
513
|
-
" chrome — it wastes time and breaks theme coherence. Write only
|
|
523
|
+
" chrome — it wastes time and breaks theme coherence. Write only feature wiring.",
|
|
524
|
+
" `table` is COLUMN-DRIVEN: `<Table columns={dealColumns} data={deals} rowKey={(d)",
|
|
525
|
+
" => d.id} />`, where `dealColumns: readonly IColumn<IDeal>[]` is a feature CONSTANT",
|
|
526
|
+
" (in <feature>.constants.ts). Each column is `{ header, cell: (row) => …, className? }`.",
|
|
527
|
+
" Do NOT build a per-feature table component — pass columns to the one <Table>.",
|
|
514
528
|
" • HARNESS SDK — USE IT, do NOT hand-roll the data layer (this is the biggest",
|
|
515
529
|
" speed+quality lever). A tested generic toolkit is already in src/lib/:",
|
|
516
|
-
" – createCollection(key, SEED) [from @/lib/collection] IS a
|
|
517
|
-
" service: typed async CRUD + Result + latency. <
|
|
518
|
-
" `export const items = createCollection('items', SEED_ITEMS)`.",
|
|
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)`.",
|
|
519
533
|
" – useCollection(collection) [from @/lib/use-collection] IS the data hook:",
|
|
520
534
|
" cached list, isLoading/error, and create/update/remove mutations WITH",
|
|
521
|
-
" optimistic updates + rollback. Do NOT write a <
|
|
535
|
+
" optimistic updates + rollback. Do NOT write a <feature>.hooks.ts query wrapper.",
|
|
522
536
|
" – useForm({ initial, validate, submit }) [from @/lib/use-form] IS form state:",
|
|
523
537
|
" values, per-field errors, async submit status. Do NOT hand-roll form state.",
|
|
524
538
|
" – SEED DATA — GENERATE with faker. NEVER hand-write literal arrays, and NEVER",
|
|
@@ -554,8 +568,9 @@ const REACT_GUIDANCE = [
|
|
|
554
568
|
" `KIND_LABEL[activity.kind]` needs NO cast. NEVER write the map as a bare",
|
|
555
569
|
" `as const` and then index it `MAP[key as keyof typeof MAP]` — that `as` is",
|
|
556
570
|
" REJECTED. The map's KEY type, not a cast, is what makes the lookup type-check.",
|
|
557
|
-
" So a
|
|
558
|
-
"
|
|
571
|
+
" 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,",
|
|
559
574
|
" fewer bugs. Only write a custom service/hook if the SDK genuinely can't express",
|
|
560
575
|
" it. A QueryClientProvider is already wired in src/main.tsx.",
|
|
561
576
|
" • Style with Tailwind classes via className using theme tokens",
|
|
@@ -563,7 +578,7 @@ const REACT_GUIDANCE = [
|
|
|
563
578
|
" • Need charts? `recharts` is installed — import from 'recharts'. Need drag-and-",
|
|
564
579
|
" drop? `@dnd-kit/core` + `@dnd-kit/sortable` are installed. Do NOT add other",
|
|
565
580
|
" deps (only these + the scaffold's are installed; the build can't fetch more).",
|
|
566
|
-
"Imports use the @/ alias (e.g.
|
|
581
|
+
"Imports use the @/ alias (e.g. @/views/<Feature>/<feature>.types, @/components/ui/button).",
|
|
567
582
|
"Do NOT write a checks.json or any browser interaction test. The gate already",
|
|
568
583
|
"builds the app with Vite and renders it in a real browser, FAILING on any",
|
|
569
584
|
"runtime/console error — that IS the acceptance. Spend your effort on a working,",
|
|
@@ -145,6 +145,7 @@ export default tseslint.config(
|
|
|
145
145
|
"boringstack/one-component-per-file": "error",
|
|
146
146
|
"react/jsx-key": "error",
|
|
147
147
|
"react/no-array-index-key": "error",
|
|
148
|
+
"react/button-has-type": "error",
|
|
148
149
|
"react-hooks/rules-of-hooks": "error",
|
|
149
150
|
"react-hooks/exhaustive-deps": "warn",
|
|
150
151
|
"prefer-const": "error",
|
|
@@ -207,7 +208,6 @@ export default tseslint.config(
|
|
|
207
208
|
"jsx-a11y/click-events-have-key-events": "warn",
|
|
208
209
|
"jsx-a11y/no-static-element-interactions": "warn",
|
|
209
210
|
"jsx-a11y/label-has-associated-control": "error",
|
|
210
|
-
"jsx-a11y/button-has-type": "error",
|
|
211
211
|
"jsx-a11y/no-noninteractive-tabindex": "error",
|
|
212
212
|
},
|
|
213
213
|
},
|