@agjs/tsforge 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agjs/tsforge",
3
3
  "type": "module",
4
- "version": "0.2.5",
4
+ "version": "0.2.6",
5
5
  "license": "MIT",
6
6
  "description": "TypeScript coding harness with a deterministic gate, stack-aware guardrails, and stream-level correction.",
7
7
  "repository": {
@@ -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
- export function buildWebGate(framework: WebFramework): IGate {
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
- `"${ESLINT_BIN}" --no-config-lookup -c "${STRICT_WEB_CONFIG}" ${ignores} --format json .`.replace(
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(framework: WebFramework): IGate {
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
- `"${ESLINT_BIN}" --no-config-lookup -c "${STRICT_WEB_CONFIG}" ${ignores} --format json .`.replace(
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(framework: WebFramework): string {
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
- `"${ESLINT_BIN}" --no-config-lookup -c "${STRICT_WEB_CONFIG}" ${ignores} --fix .`.replace(
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: `${envPrefix}bun "${ESLINT_BIN}" --no-config-lookup -c "${STRICT_CONFIG}" --format json .`,
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
  }
@@ -19,7 +19,21 @@ function interpolateEnv(
19
19
  }
20
20
 
21
21
  function style(cfg: IOpenAICompatibleConfig): ReasoningStyle {
22
- return cfg.reasoning ?? "qwen";
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. */
@@ -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 { getComponentName, isComponentFile, isInShadcnUi } from "../utils";
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
- readonly requiredSiblings?: readonly string[];
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 = "missingSiblings";
17
-
18
- const DEFAULT_SIBLINGS = [
19
- "<Name>.hooks.ts",
20
- "<Name>.types.ts",
21
- "<Name>.stories.tsx",
22
- "<Name>.test.ts",
23
- "index.ts",
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
- "Enforce required sibling files in component folders (hooks, types, stories, test, index)",
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
- missingSiblings:
61
- "Component '{{name}}' is missing required siblings: {{missing}}",
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
- if (isInShadcnUi(filename)) {
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 (!componentName) {
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
- const dir = dirname(filename);
96
- const requiredSiblings = options.requiredSiblings ?? DEFAULT_SIBLINGS;
97
-
98
- const missing: string[] = [];
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": {
@@ -365,64 +365,66 @@ export function Separator({
365
365
  `,
366
366
  table: `import { cn } from "@/lib/utils";
367
367
 
368
- export function Table({
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
- ...props
371
- }: React.TableHTMLAttributes<HTMLTableElement>): React.JSX.Element {
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)} {...props} />
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";
@@ -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 — boringstack, ONE thing per file (judged on this, not just",
441
- "compiling). NEVER put more than one component in a file, and NEVER inline types",
442
- "or constants in a component file:",
443
- " • Organize by DOMAIN: each feature/area of the app is its own folder under",
444
- " src/, named for the domain it holds. Inside a domain folder <d>/ put:",
445
- " <d>.types.ts that domain's interfaces/types (I-prefixed)",
446
- " – <d>.constants.ts its `as const` registries/config",
447
- " – one component per .tsx file (PascalCase, named for the component)",
448
- "(NO <d>.hooks.ts query wrapper the SDK's useCollection IS the data hook;",
449
- " add a hook file ONLY for genuine derived/computed state, never to fetch)",
450
- " index.ts a barrel re-exporting the folder's public surface",
451
- " Types ALWAYS live in a `.types.ts`, constants in a `.constants.ts` — never",
452
- " inline, and never one mega src/types.ts for the whole app. Types shared",
453
- " across domains src/shared/shared.types.ts.",
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. THEN fill each",
471
- " route's placeholder component with the real UI, ONE feature at a time.",
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 domain wiring.",
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 domain's whole",
517
- " service: typed async CRUD + Result + latency. <d>.service.ts is ONE line:",
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 <d>.hooks.ts query wrapper.",
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 domain is mostly: <d>.types.ts + a `satisfies`-typed SEED const + one-line",
558
- " createCollection + components that call useCollection/useForm. Far fewer lines,",
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. @/<domain>/<domain>.types, @/components/ui/button).",
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
  },