solid2-agent-kit 0.14.0 → 0.16.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/README.md CHANGED
@@ -16,11 +16,11 @@ Supported agents: **Cursor** and **Claude Code**.
16
16
  | Always-applied hard rules | `.cursor/rules/solid-2.mdc` (glob-attached to `*.tsx`/`*.jsx`) | managed block in `CLAUDE.md` | 23 hard rules + banned Solid 1.x API table + eslint-plugin-solid lint-heritage table. Injected without the agent having to decide to read anything |
17
17
  | Agent skill | `.cursor/skills/solid-2/` | `.claude/skills/solid-2/` | Execution-model primer, decision tables, canonical patterns, a “still runs / write the other form” table, review checklist, official-doc URL index |
18
18
  | Shared agent context | managed block in `AGENTS.md` | same (Claude Code users can reference it from `CLAUDE.md`) | Core principles and pointers, always in context |
19
- | Mechanical gate | `solid2-kit check` | same | Comment-stripped regex guard over `src/**/*.{ts,tsx,jsx}` (or explicit `[files...]`): fails on props destructuring (including multi-line signatures), `{ ...props }` rest copies, `{list().map(...)}` in JSX, React imports/hooks/JSX props/`React.lazy`/`useOptimistic`/`onDoubleClick`, Solid 1.x imports/APIs/components/JSX namespaces, `vite-plugin-solid`, old-router `<Route>`/`<A>`/`<HashRouter>`/`<FileRoutes>`, `MetaProvider`, `Context.Provider`, camelCase style keys, `key` props, `value()!` hand-narrowing, hand-rolled loading signals, `=== undefined` readiness branches, SolidStart leftovers (`@solidjs/start`, `h3`, `createAsync`, `useSubmission`, `"use client"`), Next.js imports/`useRouter`/`typeof window`, `history.pushState`, `render(<App />)` (pass a function), `isPending(user())` / `latest(user())` (pass the accessor), `<Dynamic>` (use `dynamic()`), `import type { JSX } from "solid-js"` (no `JSX` namespace there — `Element` from `solid-js`, DOM `JSX` types from `@solidjs/web`), two-phase effects whose apply only writes a local signal (use a writable derivation), core `action(async ...)` with a plain async function instead of a generator (only `yield` re-enters the transaction), and `setStore(async ...)` on a `createStore` / `createOptimisticStore` setter (the draft is a synchronous transaction; dev throws `[ASYNC_STORE_SETTER]`) |
19
+ | Mechanical gate | `solid2-kit check` | same | Comment-stripped regex guard over `src/**/*.{ts,tsx,jsx}` (or explicit `[paths...]` — files are checked, directories are walked, a missing path fails): fails on props destructuring (including multi-line signatures), `{ ...props }` rest copies, `{list().map(...)}` in JSX, React imports/hooks/JSX props/`React.lazy`/`useOptimistic`/`onDoubleClick`, Solid 1.x imports/APIs/components/JSX namespaces (valueless `use:x` included), Solid 1.x `createEffect(fn)` / `createEffect(fn, init)` / `createMemo(fn, init)` signatures, `const [x] = createProjection(...)` (it returns the store, not a tuple), `vite-plugin-solid`, old-router `<Route>`/`<A>`/`<HashRouter>`/`<FileRoutes>`, `MetaProvider`, `Context.Provider`, camelCase style keys, `key` props (except on `@solidjs/meta` tags), `value()!` hand-narrowing, hand-rolled loading signals, `=== undefined` readiness branches, SolidStart leftovers (`@solidjs/start`, `h3`, `createAsync`, `useSubmission`, `"use client"`), Next.js imports/`useRouter`/`typeof window`, `history.pushState`, `render(<App />)` (pass a function), `isPending(user())` / `latest(user())` (pass the accessor), `<Dynamic>` (use `dynamic()`), `import type { JSX } from "solid-js"` (no `JSX` namespace there — import `JSX` from `@solidjs/web`, or use an aliased `Element` from `solid-js`), two-phase effects whose apply only writes a local signal (use a writable derivation), core `action(async ...)` with a plain async function instead of a generator (only `yield` re-enters the transaction), and `setStore(async ...)` on a `createStore` / `createOptimisticStore` setter (the draft is a synchronous transaction; dev throws `[ASYNC_STORE_SETTER]`) |
20
20
  | Edit-time hook | `postToolUse` entry in `.cursor/hooks.json` | `PostToolUse` entry in `.claude/settings.json` (matcher includes `Bash`) | Runs the mechanical gate automatically on **every agent file edit — including edits made through shell commands** (sed, heredocs, codemods; source paths mentioned in the command are checked after it ran) — and feeds findings straight back into the conversation (Cursor: `additional_context`; Claude Code: stderr + exit 2), so enforcement does not depend on the agent remembering to run `check`. Wired only when the kit is a local devDependency; opt out with `--no-hooks` |
21
21
  | Turn-end gate | — | `Stop` entry in `.claude/settings.json` | Whole-project `check` + `doctor` when the agent tries to end its turn: with findings in place the stop is blocked (exit 2) and the findings are fed back, so a turn cannot finish with React/Solid 1.x patterns left behind. Loop-safe via `stop_hook_active` (one forced continuation, never an infinite loop) |
22
22
  | Pre-execution guard | `preToolUse` + `beforeShellExecution` entries in `.cursor/hooks.json` | `PreToolUse` entry in `.claude/settings.json` | Denies actions **before they run**: edits/deletes of kit-owned guardrail files (an agent blocked by a gate will try to remove the gate), edits that would strip the kit's hook entries or managed blocks, uninspectable shell rewrites of those files, and `npm/pnpm/yarn/bun install` of banned dependencies (`react`, `vite-plugin-solid`, `eslint-plugin-solid`, SolidStart, ...) — caught proactively instead of post-hoc by `doctor` |
23
- | Project-wiring gate | `solid2-kit doctor` | same | Config drift the source gate cannot see: React / `vite-plugin-solid` / `eslint-plugin-solid` / SolidStart deps in `package.json`, a 1.x `solid-js` range, `jsx` ≠ `preserve` or `jsxImportSource` ≠ `@solidjs/web` in tsconfig, Solid 1.x wiring in root config files, and stale installed guidance (kit updated but `sync` not re-run) |
23
+ | Project-wiring gate | `solid2-kit doctor` | same | Config drift the source gate cannot see: React / `vite-plugin-solid` / `eslint-plugin-solid` / SolidStart deps in `package.json`, a Solid 1.x line of `solid-js` / `@solidjs/router` / `@solidjs/meta` / `@tanstack/solid-router` (declared, or installed from the `latest` tag — Solid 2 releases are on `next`; TanStack Router's on `rc`), `jsx` ≠ `preserve` or `jsxImportSource` ≠ `@solidjs/web` in tsconfig, Solid 1.x wiring in root config files, and stale installed guidance (kit updated but `sync` not re-run) |
24
24
  | Review command | `.cursor/commands/solid2-review.md` | `.claude/commands/solid2-review.md` | `/solid2-review` — on-demand deep review: runs both gates, then walks the diff against the skill checklist targeting the semantic failure classes regexes cannot see (post-`await` reads, effect misuse, unkeyed server rows, boundary placement, context snapshots) |
25
25
 
26
26
  Managed blocks are delimited with `<!-- solid2-agent-kit:*:start/end -->` markers; everything
@@ -40,10 +40,15 @@ npx github:lightsound/solid2-agent-kit init
40
40
  npx github:lightsound/solid2-agent-kit init --cursor
41
41
  npx github:lightsound/solid2-agent-kit init --claude
42
42
 
43
- # run the mechanical pattern gate (default source dir: src; or name files)
43
+ # run the mechanical pattern gate (default source dir: src; or name files and directories)
44
44
  npx github:lightsound/solid2-agent-kit check
45
45
  npx github:lightsound/solid2-agent-kit check --dir app
46
- npx github:lightsound/solid2-agent-kit check src/App.tsx
46
+ npx github:lightsound/solid2-agent-kit check src/App.tsx src/routes
47
+
48
+ # only the files a change touched: a missing path always fails (exit 2), so
49
+ # --diff-filter=d keeps deleted files out of the list; -z / -0 keep paths with
50
+ # spaces whole, -r skips the run on an empty (or delete-only) diff
51
+ git diff --name-only -z --diff-filter=d main | xargs -0 -r npx github:lightsound/solid2-agent-kit check
47
52
 
48
53
  # check the project wiring (deps, tsconfig, root configs)
49
54
  npx github:lightsound/solid2-agent-kit doctor
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // solid2-kit init [--cursor] [--claude] [--no-hooks] [--target <dir>]
6
6
  // solid2-kit sync [--cursor] [--claude] [--no-hooks] [--target <dir>] (alias of init)
7
- // solid2-kit check [--dir <srcdir>] [--target <dir>] [files...]
7
+ // solid2-kit check [--dir <srcdir>] [--target <dir>] [paths...]
8
8
  // solid2-kit doctor [--target <dir>]
9
9
  // solid2-kit hook (claude|cursor) (stdin: agent hook JSON payload)
10
10
  //
@@ -19,6 +19,7 @@ import {
19
19
  readFileSync,
20
20
  readdirSync,
21
21
  rmSync,
22
+ statSync,
22
23
  writeFileSync,
23
24
  } from 'node:fs';
24
25
  import { dirname, join, relative, resolve } from 'node:path';
@@ -404,6 +405,95 @@ function actionAsyncFindings(content) {
404
405
  }));
405
406
  }
406
407
 
408
+ // Solid 1.x signatures: `createEffect(fn)` (Solid 2 needs compute + apply),
409
+ // `createEffect(fn, initialValue)`, and `createMemo(fn, initialValue[, options])`
410
+ // (Solid 2's second memo argument is the options object). A second argument
411
+ // that is a primitive or array literal is an initial value; functions,
412
+ // identifiers, and object literals are left alone. A name this file imports
413
+ // from another library (effector's one-argument `createEffect`) or declares
414
+ // itself is not Solid's.
415
+ const INITIAL_VALUE_LITERAL = /^(?:-?\d|['"`[]|true\b|false\b|null\b|undefined\b)/;
416
+ const NOT_SOLID_CORE_MODULE = /^(?!(?:solid-js|@solidjs\/signals)$)/;
417
+
418
+ // Blank type-argument lists (`Record<string, number>`, `Map<K, () => void>`)
419
+ // so their commas do not split arguments. `<` counts only right after an
420
+ // identifier: formatted comparisons have a space before it.
421
+ function blankTypeArguments(text) {
422
+ let previous;
423
+ do {
424
+ previous = text;
425
+ text = text.replace(/(?<=[\w$])<(?:=>|[^<>=])*>/g, (typeArgs) => ' '.repeat(typeArgs.length));
426
+ } while (text !== previous);
427
+ return text;
428
+ }
429
+
430
+ function solid1SignatureFindings(content) {
431
+ const foreign = importedNames(content, NOT_SOLID_CORE_MODULE);
432
+ const matches = [];
433
+ for (const call of content.matchAll(
434
+ /(?<![.\w$])create(Effect|Memo)\s*(?:<(?:=>|[^<>]|<(?:=>|[^<>])*>)*>)?\s*\(/g,
435
+ )) {
436
+ const name = `create${call[1]}`;
437
+ if (foreign.has(name)) continue;
438
+ if (new RegExp(`\\b(?:function\\s*\\*?|const|let|var|class)\\s+${name}\\b`).test(content)) continue;
439
+ const open = call.index + call[0].length - 1;
440
+ const close = scanBalanced(content, open);
441
+ if (close === -1) continue;
442
+ const args = splitTopLevelArgs(blankTypeArguments(content.slice(open + 1, close)))
443
+ .map((arg) => arg.trim())
444
+ .filter(Boolean);
445
+ const initialValue = args.length >= 2 && INITIAL_VALUE_LITERAL.test(args[1]);
446
+ const legacy =
447
+ call[1] === 'Effect' ? args.length === 1 || initialValue : args.length >= 3 || initialValue;
448
+ if (legacy) matches.push({ index: call.index });
449
+ }
450
+ return matches;
451
+ }
452
+
453
+ // `key` is meaningless in Solid JSX except on the Solid Meta tags, where it
454
+ // names the tag's identity (`<Meta key="og-image" …>`). The enclosing tag is
455
+ // the nearest `<Name` (or `<Namespace.Name`) before the attribute.
456
+ const META_MODULE = /^@solidjs\/meta$/;
457
+
458
+ function reactKeyPropFindings(content) {
459
+ const metaTags = importedNames(content, META_MODULE);
460
+ const metaNamespaces = new Set(
461
+ [...content.matchAll(/\bimport\s+\*\s+as\s+([\w$]+)\s+from\s*['"]@solidjs\/meta['"]/g)].map((m) => m[1]),
462
+ );
463
+ const matches = [];
464
+ for (const attr of content.matchAll(/\skey=\{/g)) {
465
+ const opener = [...content.slice(0, attr.index).matchAll(/<([A-Za-z][\w$.]*)/g)].pop();
466
+ const tag = opener?.[1] ?? '';
467
+ if (metaTags.has(tag) || metaNamespaces.has(tag.split('.')[0])) continue;
468
+ matches.push({ index: attr.index });
469
+ }
470
+ return matches;
471
+ }
472
+
473
+ // TanStack Router for Solid exports names the Next.js / Solid Router 1.x rules
474
+ // target (`useRouter()`, `notFound()`, `<Navigate>`). A match is exempt only
475
+ // when this file binds that exact local name from one of these modules, so a
476
+ // stray Next or Router 1.x call in the same file is still reported. TanStack
477
+ // Router 1.x (Solid 1) and 2.x (Solid 2) export the same names from the same
478
+ // module, so the major is invisible here; doctor's tanstack-router-version
479
+ // reads it from package.json / node_modules.
480
+ const TANSTACK_ROUTER_MODULE = /^@tanstack\/(?:solid-router|router-core)$/;
481
+
482
+ // Local names bound by `import [Default,] { a, b as c } from "<module>"`.
483
+ function importedNames(content, moduleName) {
484
+ const names = new Set();
485
+ for (const decl of content.matchAll(
486
+ /\bimport\s+(?:type\s+)?(?:[\w$]+\s*,\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g,
487
+ )) {
488
+ if (!moduleName.test(decl[2])) continue;
489
+ for (const specifier of decl[1].split(',')) {
490
+ const local = specifier.trim().replace(/^type\s+/, '').split(/\s+as\s+/).pop();
491
+ if (local) names.add(local);
492
+ }
493
+ }
494
+ return names;
495
+ }
496
+
407
497
  const CHECKS = [
408
498
  {
409
499
  id: 'props-destructure-param',
@@ -432,7 +522,19 @@ const CHECKS = [
432
522
  id: 'jsx-namespace-import',
433
523
  pattern: /import\s+(?:type\s+)?\{[^}]*\bJSX\b[^}]*\}\s*from\s+['"]solid-js['"]/g,
434
524
  message:
435
- 'Solid 2 does not export a `JSX` namespace from "solid-js" (TS2305). Children/return types are `Element` from "solid-js"; DOM-specific JSX types (JSX.IntrinsicElements, JSX.CSSProperties) come from "@solidjs/web".',
525
+ 'Solid 2 does not export a `JSX` namespace from "solid-js" (TS2305). Import `JSX` from "@solidjs/web" (JSX.Element, JSX.IntrinsicElements, JSX.CSSProperties), or use `Element` from "solid-js" (aliased, e.g. `type Element as SolidElement`, where the DOM Element is also used).',
526
+ },
527
+ {
528
+ id: 'solid1-effect-memo-signature',
529
+ find: solid1SignatureFindings,
530
+ message:
531
+ 'Solid 1.x signature. createEffect takes (compute, apply) — a single argument is an error in Solid 2. createMemo(fn, initialValue) is gone: the second argument is the options object; give the compute a default parameter instead (createMemo((prev = 0) => ...)).',
532
+ },
533
+ {
534
+ id: 'projection-destructure',
535
+ pattern: /\b(?:const|let|var)\s*\[[^\]]*\]\s*=\s*createProjection\b/g,
536
+ message:
537
+ 'createProjection returns the store itself, not a [store, setter] tuple — destructuring it takes the first row. Write `const rows = createProjection(...)`.',
436
538
  },
437
539
  {
438
540
  // A two-phase createEffect whose apply is nothing but one (optionally
@@ -486,8 +588,9 @@ const CHECKS = [
486
588
  },
487
589
  {
488
590
  id: 'react-key-prop',
489
- pattern: /\skey=\{/g,
490
- message: 'React `key` prop has no meaning in Solid. Row identity belongs on <For keyed={...}>.',
591
+ find: reactKeyPropFindings,
592
+ message:
593
+ 'React `key` prop has no meaning in Solid. Row identity belongs on <For keyed={...}>. (Only the @solidjs/meta tags take `key`, as the tag identity.)',
491
594
  },
492
595
  {
493
596
  // `{todos().map((t) => <Row />)}` renders then recreates every row.
@@ -552,14 +655,21 @@ const CHECKS = [
552
655
  message: 'Solid 1.x Vite plugin. Import `solid` from "@solidjs/vite-plugin".',
553
656
  },
554
657
  {
658
+ // Valueless attributes count too (`<input use:autofocus />`): followed by
659
+ // `/>`, `>`, or another attribute, unlike an unspaced object key
660
+ // (`{ on:true }`, `{ on:x = false }`, `{ attr:v as string }`); `as="font"`
661
+ // and SVG `in="…"` are still attributes. `prop:` is still a Solid 2
662
+ // namespace and is not listed.
555
663
  id: 'solid1-jsx-namespace',
556
- pattern: /\b(?:use|on|oncapture|attr|bool):[A-Za-z][\w-]*=/g,
664
+ pattern:
665
+ /(?<=\s)(?:use|on|oncapture|attr|bool):[A-Za-z][\w-]*(?==|\s*\/?>|\s+(?!(?:as|satisfies|in|instanceof)\b(?!\s*=))[A-Za-z_${])/g,
557
666
  message:
558
667
  'Solid 1.x JSX namespace. Use ref callbacks (and directive factories), camelCase event props, and standard attributes.',
559
668
  },
560
669
  {
561
670
  id: 'solid1-router',
562
671
  pattern: /<(?:HashRouter|MemoryRouter|Route|Navigate|A|FileRoutes|StartClient|StartServer)\b/g,
672
+ exemptImportsFrom: TANSTACK_ROUTER_MODULE,
563
673
  message:
564
674
  'Solid Router 0.x/1.x or SolidStart JSX. Define routes with createRouter({ routes }) / fileRoutes(pageRoutes) and plain <a href={Router.paths...}> (link state: automatic aria-current/data-active/data-pending + CSS, or useLinkState).',
565
675
  },
@@ -606,6 +716,7 @@ const CHECKS = [
606
716
  id: 'next-nav',
607
717
  pattern:
608
718
  /(?<![.\w])(?:useRouter|usePathname|revalidatePath|revalidateTag|notFound|hydrateRoot)\s*\(/g,
719
+ exemptImportsFrom: TANSTACK_ROUTER_MODULE,
609
720
  message:
610
721
  'Next.js / React DOM leftover. In-app navigation is useNavigate / <a href={Router.paths...}>; 404 is httpStatus(404); hydrate is hydrate(() => <App />, root).',
611
722
  },
@@ -716,11 +827,16 @@ function stripComments(content) {
716
827
  return out;
717
828
  }
718
829
 
830
+ // Dependencies and dot-directories (.git, build caches, installed agent
831
+ // guidance) are never project sources, so `check .` stays meaningful.
719
832
  function* walk(dir) {
720
833
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
721
834
  const path = join(dir, entry.name);
722
- if (entry.isDirectory()) yield* walk(path);
723
- else if (SOURCE_FILE.test(entry.name)) yield path;
835
+ if (entry.isDirectory()) {
836
+ if (entry.name !== 'node_modules' && !entry.name.startsWith('.')) yield* walk(path);
837
+ } else if (SOURCE_FILE.test(entry.name)) {
838
+ yield path;
839
+ }
724
840
  }
725
841
  }
726
842
 
@@ -733,7 +849,9 @@ function fileFindings(file, relativeTo) {
733
849
  const lines = raw.split('\n');
734
850
  const findings = [];
735
851
  for (const rule of CHECKS) {
852
+ const exempt = rule.exemptImportsFrom ? importedNames(content, rule.exemptImportsFrom) : null;
736
853
  for (const match of rule.find ? rule.find(content) : content.matchAll(rule.pattern)) {
854
+ if (exempt?.has(match[0].match(/[\w$]+/)[0])) continue;
737
855
  const lineNumber = content.slice(0, match.index).split('\n').length;
738
856
  findings.push(
739
857
  `${relative(relativeTo, file) || file}:${lineNumber} [${rule.id}] ${rule.message}\n > ${lines[lineNumber - 1].trim()}`,
@@ -745,12 +863,35 @@ function fileFindings(file, relativeTo) {
745
863
 
746
864
  function check() {
747
865
  const target = resolve(flagValue('--target', '.'));
748
- const fileArgs = positionalArgs();
866
+ const pathArgs = positionalArgs();
749
867
 
750
868
  let files;
751
- if (fileArgs.length > 0) {
752
- // Explicit file mode (used by agent hooks): check only the named sources.
753
- files = fileArgs.map((file) => resolve(target, file)).filter((file) => SOURCE_FILE.test(file));
869
+ let scanned;
870
+ let walked = true;
871
+ if (pathArgs.length > 0) {
872
+ // Explicit path mode: named directories are walked, named files are
873
+ // checked when they are .ts/.tsx/.jsx sources.
874
+ files = [];
875
+ scanned = pathArgs;
876
+ walked = false;
877
+ // A missing path is always an error: a deleted file, a misspelled file, and
878
+ // a renamed source directory look the same here, and only the list's
879
+ // producer knows which it was.
880
+ for (const arg of pathArgs) {
881
+ const path = resolve(target, arg);
882
+ if (!existsSync(path)) {
883
+ console.error(
884
+ `solid2-kit check — path not found: ${path} (drop deleted files from a changed-file list with \`git diff --name-only --diff-filter=d\`)`,
885
+ );
886
+ process.exit(2);
887
+ }
888
+ if (statSync(path).isDirectory()) {
889
+ walked = true;
890
+ files.push(...walk(path));
891
+ } else if (SOURCE_FILE.test(path)) {
892
+ files.push(path);
893
+ }
894
+ }
754
895
  } else {
755
896
  const srcDir = resolve(target, flagValue('--dir', 'src'));
756
897
  if (!existsSync(srcDir)) {
@@ -758,6 +899,22 @@ function check() {
758
899
  process.exit(2);
759
900
  }
760
901
  files = [...walk(srcDir)];
902
+ scanned = [relative(target, srcDir) || '.'];
903
+ }
904
+
905
+ // A walk that found no sources points at the wrong tree; passing it would
906
+ // read as a clean gate in CI and in agent loops. A list of named files with
907
+ // no sources among them (changed-file pipelines on a docs-only change) has
908
+ // nothing to gate and passes.
909
+ if (files.length === 0 && !walked) {
910
+ console.log('solid2-kit check — OK (no .ts/.tsx/.jsx sources among the named files; nothing to check).');
911
+ return;
912
+ }
913
+ if (files.length === 0) {
914
+ console.error(
915
+ `solid2-kit check — no .ts/.tsx/.jsx sources found in ${scanned.join(', ')}; nothing was checked. Point --dir or [paths...] at the source tree.`,
916
+ );
917
+ process.exit(2);
761
918
  }
762
919
 
763
920
  let findings = 0;
@@ -1063,6 +1220,40 @@ const BANNED_DEPS = {
1063
1220
  vinxi: 'SolidStart 1.x toolchain. Solid 2 uses @solidjs/vite-plugin directly.',
1064
1221
  };
1065
1222
 
1223
+ // Packages whose npm `latest` dist-tag is still the Solid 1.x line (solid-js
1224
+ // 1.9, @solidjs/router 1.0, @solidjs/meta 0.29, @tanstack/solid-router 1.x):
1225
+ // a bare `pnpm add <name>` installs it. The Solid 2 releases are published
1226
+ // under `next` (TanStack: `rc`, 2.0.0-rc.x).
1227
+ const SOLID2_LINE_PACKAGES = [
1228
+ { name: 'solid-js', id: 'solid-js-version', solid1: /^[\s^~=v]*[01](?:\.|$)/, fix: 'Install solid-js@next (^2).' },
1229
+ {
1230
+ name: '@solidjs/router',
1231
+ id: 'router-version',
1232
+ solid1: /^[\s^~=v]*[01](?:\.|$)/,
1233
+ fix: 'Router 1.x is JSX <Route> with a solid-js ^1 peer; install @solidjs/router@next (2.x, createRouter({ routes })).',
1234
+ },
1235
+ {
1236
+ name: '@solidjs/meta',
1237
+ id: 'meta-version',
1238
+ solid1: /^[\s^~=v]*0(?:\.|$)/,
1239
+ fix: 'Meta 0.x is built for Solid 1 (<MetaProvider> required); install @solidjs/meta@next (1.x).',
1240
+ },
1241
+ {
1242
+ name: '@tanstack/solid-router',
1243
+ id: 'tanstack-router-version',
1244
+ solid1: /^[\s^~=v]*[01](?:\.|$)/,
1245
+ fix: 'TanStack Router 1.x has a solid-js ^1.9 peer; install @tanstack/solid-router@rc (2.x, solid-js 2 peer).',
1246
+ },
1247
+ ];
1248
+
1249
+ function installedVersion(target, name) {
1250
+ try {
1251
+ return JSON.parse(readFileSync(join(target, 'node_modules', name, 'package.json'), 'utf8')).version;
1252
+ } catch {
1253
+ return undefined;
1254
+ }
1255
+ }
1256
+
1066
1257
  const CONFIG_SOURCE = /\.(?:m|c)?[jt]s$/;
1067
1258
 
1068
1259
  // Files installed by this kit that carry a version marker. When the kit
@@ -1079,9 +1270,21 @@ function doctorFindings(target) {
1079
1270
  for (const [name, message] of Object.entries(BANNED_DEPS)) {
1080
1271
  if (name in deps) report(`dep-${name.replace(/[@/]/g, '')}`, `package.json depends on "${name}". ${message}`);
1081
1272
  }
1082
- const solidRange = deps['solid-js'];
1083
- if (typeof solidRange === 'string' && /^[\s^~=v]*[01]\./.test(solidRange)) {
1084
- report('solid-js-version', `package.json pins solid-js "${solidRange}" — this kit teaches Solid 2.x; upgrade to ^2.`);
1273
+ for (const { name, id, solid1, fix } of SOLID2_LINE_PACKAGES) {
1274
+ const range = deps[name];
1275
+ if (typeof range === 'string' && solid1.test(range)) {
1276
+ report(id, `package.json pins ${name} "${range}" — the Solid 1.x line; this kit teaches Solid 2.x. ${fix}`);
1277
+ continue;
1278
+ }
1279
+ // A range such as "latest" or "*" only shows its line once resolved.
1280
+ if (range === undefined) continue;
1281
+ const installed = installedVersion(target, name);
1282
+ if (installed && solid1.test(installed)) {
1283
+ report(
1284
+ id,
1285
+ `node_modules has ${name} ${installed} (package.json: "${range}") — the Solid 1.x line; this kit teaches Solid 2.x. ${fix}`,
1286
+ );
1287
+ }
1085
1288
  }
1086
1289
 
1087
1290
  for (const entry of readdirSync(target, { withFileTypes: true })) {
@@ -1183,7 +1386,7 @@ switch (command) {
1183
1386
  'Usage:',
1184
1387
  ' solid2-kit init [--cursor] [--claude] [--no-hooks] [--target <dir>] install/update guidance + edit hooks (default: both tools)',
1185
1388
  ' solid2-kit sync [--cursor] [--claude] [--no-hooks] [--target <dir>] alias of init (idempotent)',
1186
- ' solid2-kit check [--dir <srcdir>] [--target <dir>] [files...] mechanical React/Solid 1.x pattern gate (default dir: src)',
1389
+ ' solid2-kit check [--dir <srcdir>] [--target <dir>] [paths...] mechanical React/Solid 1.x pattern gate (default dir: src)',
1187
1390
  ' solid2-kit doctor [--target <dir>] project-wiring gate: deps, tsconfig, root configs',
1188
1391
  ' solid2-kit hook (claude|cursor) edit-time gate for agent hooks (stdin: hook JSON payload)',
1189
1392
  ].join('\n'),
@@ -26,10 +26,29 @@ Steps:
26
26
  `Promise<User>` props;
27
27
  - `latest(selectedId)` as the default highlight (hold is the default);
28
28
  `isPending` treated as a global spinner instead of a per-expression question;
29
- - treating `<Errored>` as a terminal ErrorBoundary, or routing per-row mutation
30
- failures through it (those belong in the action / a projection-folded map);
29
+ - treating `<Errored>` as a terminal ErrorBoundary, or expecting it to catch
30
+ mutation failures (an error thrown out of an `action` reverts the overlay and
31
+ rejects the call — catch it in the action / a projection-folded map, or `.catch`
32
+ the call in the handler);
33
+ - effect `apply` functions with expression bodies (`(t) => (document.title = t)`,
34
+ `(v) => setX(v)`) — any return value but a cleanup halts reactivity; `onCleanup`
35
+ inside `apply` (never runs) or inside `onSettled`, and memos/effects/`flush()`
36
+ inside an `onSettled` callback (all throw);
37
+ - writes to a store outside its setter (`todos.push(x)`, `todo.done = ...` — silently
38
+ ignored) and `Map`/`Set`/`Date` fields mutated in a draft (not tracked);
39
+ - app-wide state as a module-level signal/store instead of a provider at the root
40
+ of `App` (shared across SSR requests);
41
+ - value-form `createOptimisticStore([])` / `createOptimistic(v)` holding durable
42
+ data (writes vanish on settle) instead of the function form refreshed after `yield`;
43
+ - route `preload` returning data read as `props.data` (captured once per match)
44
+ instead of `void getX(params.id)` + `createMemo(() => getX(props.params.id))`;
45
+ - `throw redirect()` / `return reload()` in server functions called without Solid
46
+ Router `action`/`query` (the caller gets a raw `Response` or `null`, never its
47
+ data), and POST forms built on a bare function's `.url` (TS2339) instead of a
48
+ router `action`;
31
49
  error reporting done as a side effect inside `fallback` instead of
32
- `configureClientErrors` / `configureServerErrors` / `render(..., { onError })`;
50
+ `configureClientErrors` / `configureServerErrors` / `render(fn, root, undefined, { onError })`
51
+ (options are `render`'s 4th argument — a 3rd-argument object is `init`);
33
52
  - store setter callbacks that `await` (the draft closes when the callback returns —
34
53
  `[ASYNC_STORE_SETTER]`), store setters called at component-body top level, and
35
54
  `latest()` used as a null-safe read of an unsettled source;
@@ -5,4 +5,4 @@
5
5
  - After editing TSX, run the `solid2-kit check` mechanical gate — it fails on React/Solid 1.x tokens and props destructuring. Use the project's `lint:solid` script if one exists; otherwise `npx solid2-agent-kit check` (with the kit as a devDependency) or `npx github:lightsound/solid2-agent-kit check`. An edit-time hook may also run this gate automatically after each file edit — treat its findings as errors to fix immediately, not warnings. Do not install `eslint-plugin-solid` (built for Solid 1.x; misreads Solid 2 idioms).
6
6
  - After editing `package.json`, `tsconfig*.json`, or root config files (Vite, ESLint), run `solid2-kit doctor` — it fails on React/Solid 1.x wiring: `react`/`vite-plugin-solid`/`eslint-plugin-solid`/SolidStart dependencies, 1.x `solid-js` ranges, and `jsx`/`jsxImportSource` values other than `preserve`/`@solidjs/web`.
7
7
  - The kit's guidance and gate files (`.cursor/rules/solid-2.mdc`, the `solid-2` skill directories, hook entries, managed blocks in this file and `CLAUDE.md`) are guardrails, not obstacles. Never edit, weaken, bypass, or delete them to make a failure go away — fix the flagged code. If a guardrail seems wrong, stop and tell the user.
8
- - Solid 2.0 shipped recently and breaks with 1.x — never trust pre-2.0 Solid docs, tutorials, or training knowledge. Verify APIs against https://v2-rebuild--solid-docs-v2.netlify.app/llms.txt (markdown mirror of https://v2.solidjs.com/, which blocks non-browser fetchers). If the project has `@solidjs/router`, server functions, or `@solidjs/meta`, use those official pages (see the skill's official-docs index) — not React Router, Next.js, SolidStart 1.x, or Solid Router 0.x/1.x JSX `<Route>`/`<A>`. Core `action` from `solid-js` is a generator transaction; router `action`/`query` from `@solidjs/router` are POST forms and a read cache. Core `refresh(source)`, router `revalidate(key)`, and server-function `return reload(...)` are three different APIs. Pass the accessor to `isPending(user)`, not `isPending(user())`. Pass async values as values (`user={user()}`) — JSX props are lazy; types stay `User`, not `Promise<User>`. `<Loading>` wraps the read, not the memo. Nested fetches run in parallel (components do not suspend). `isPending` is a per-expression question (`isPending(() => props.story)` / `isPending(selectedId)`), not a global spinner; `latest(selectedId)` is opt-in so highlight and content stay consistent by default. `<Errored>` heals when the source succeeds. Pass a function to `render`/`hydrate`: `render(() => <App />, root)`. Component teardown is `onSettled`, not `onCleanup`. One router instance; in-app navigation is `useNavigate` / `<a href>`, not `window.location`. Types: `solid-js` exports no `JSX` namespace — children/return types are `Element` from `solid-js`; DOM `JSX` types come from `@solidjs/web`. Reset-a-signal-on-change is a writable derivation (`createSignal(() => { source(); return initial; })`), never an effect calling the setter. Client mode mounts with `render()` into an empty body — no hydration mismatch exists, so never defer initial values to post-mount React-style (that can overwrite persisted state). Client → server is additive (same setters + `action` + server functions): do not rewrite App with loading/error branches, disable optimistic rows until ack, invent tRPC, or refetch in `hydrate`/`onSettled`. The graph continues across the wire (`live()` owns the client connection; `<Reveal>` not a delayed stream). A server bundle uses `handleRequest` / Fetchable `fetch`; client-only start is static `dist/client`. One browser-only value takes `ssrSource` on the memo/signal/derived store (`client`/`hybrid`), not a `clientOnly` component split. Router link state is automatic (`aria-current`/`data-active`/`data-pending` + CSS; `useLinkState`/`useIsRouting` in JSX); search params via `useSearchParams`; router forms validate on the server (`throw respond({issues},{400})`) with inline errors from `useSubmissions`. Confirmations on another channel — fire-and-forget sends, and mutations whose result is read through `live()` — wait with `yield until(predicate, {timeout})`, not `refresh()`. Declare `live(GET(fn))` (`live()` outermost). Debounce at the handler; inputs feeding a request bind `latest(query)`. Dev diagnostics are errors: `[STRICT_READ_UNTRACKED]` means a read outside a tracking scope, `[ASYNC_STORE_SETTER]` means a store setter callback awaited (the draft is a synchronous transaction — await first, then write), and a held write needs `isPending`/`latest`/optimistic/`affects` feedback or dev + attribution reports `[SILENT_HOLD]`. Error monitoring is `configureClientErrors` / `configureServerErrors` (or `render(..., { onError })`), not a side effect inside `<Errored fallback>`; the server hook must return a reference or nothing, never the error itself. `<Dynamic>` is deprecated — use `dynamic()`. Observability is on by default in development: whenever a change touches stores, lists, async, actions, or effects, keep the dev console free of `[CODE]` findings and run an attribution session (`/__solid/diagnostics` `begin` → interact → `whyDidRun` / `costs` → `end` with `@solidjs/diagnostics` installed, otherwise `isDev`-guarded `attribution.enable({ log: false })` + `why()` / `costs()`) before finishing. `vite dev` is always the dev build; `solid({ observe: true })` is a production opt-in.
8
+ - Solid 2.0 shipped recently and breaks with 1.x — never trust pre-2.0 Solid docs, tutorials, or training knowledge. Verify APIs against https://v2-rebuild--solid-docs-v2.netlify.app/llms.txt (markdown mirror of https://v2.solidjs.com/, which blocks non-browser fetchers). If the project has `@solidjs/router`, server functions, or `@solidjs/meta`, use those official pages (see the skill's official-docs index) — not React Router, Next.js, SolidStart 1.x, or Solid Router 0.x/1.x JSX `<Route>`/`<A>`. Core `action` from `solid-js` is a generator transaction; router `action`/`query` from `@solidjs/router` are POST forms and a read cache. Core `refresh(source)`, router `revalidate(key)`, and server-function `return reload(...)` are three different APIs. Pass the accessor to `isPending(user)`, not `isPending(user())`. Pass async values as values (`user={user()}`) — JSX props are lazy; types stay `User`, not `Promise<User>`. `<Loading>` wraps the read, not the memo. Nested fetches run in parallel (components do not suspend). `isPending` is a per-expression question (`isPending(() => props.story)` / `isPending(selectedId)`), not a global spinner; `latest(selectedId)` is opt-in so highlight and content stay consistent by default. `<Errored>` heals when the source succeeds. Pass a function to `render`/`hydrate`: `render(() => <App />, root)`. Component teardown is `onSettled`, not `onCleanup` — return the cleanup; `onCleanup`, memo/effect creation, and `flush()` throw inside the `onSettled` callback in dev. Effect `apply` functions use block bodies (`(t) => { document.title = t; }`): any return value other than a cleanup function halts reactivity. Stores are written only through the setter's draft — writes to the store itself are silently ignored, and `Map`/`Set`/`Date` inside a store are not tracked. Shared state, app-wide included, is created in a provider at the root of `App` (`createContext<T>()` + `useX()`), never a module-level signal/store (one instance serves every SSR request). Errors thrown out of an `action` never reach `<Errored>` — catch them in the action or `.catch` the call. One router instance; in-app navigation is `useNavigate` / `<a href>`, not `window.location`. Types: `solid-js` exports no `JSX` namespace — markup types are `JSX.Element` from `@solidjs/web` or `Element` from `solid-js` (alias it `SolidElement` next to DOM code). Reset-a-signal-on-change is a writable derivation (`createSignal(() => { source(); return initial; })`), never an effect calling the setter. Client mode mounts with `render()` into an empty body — no hydration mismatch exists, so never defer initial values to post-mount React-style (that can overwrite persisted state). Client → server is additive (same setters + `action` + server functions): do not rewrite App with loading/error branches, disable optimistic rows until ack, invent tRPC, or refetch in `hydrate`/`onSettled`. The graph continues across the wire (`live()` owns the client connection; `<Reveal>` not a delayed stream). A server bundle uses `handleRequest` / Fetchable `fetch`; client-only start is static `dist/client`. One browser-only value takes `ssrSource` on the memo/signal/derived store (`client`/`hybrid`), not a `clientOnly` component split. Router link state is automatic (`aria-current`/`data-active`/`data-pending` + CSS; `useLinkState`/`useIsRouting` in JSX); search params via `useSearchParams`; router forms validate on the server (`throw respond({issues},{400})`) with inline errors from `useSubmissions`. Confirmations on another channel — fire-and-forget sends, and mutations whose result is read through `live()` — wait with `yield until(predicate, {timeout})`, not `refresh()`. Declare `live(GET(fn))` (`live()` outermost). Debounce at the handler; inputs feeding a request bind `latest(query)`. Dev diagnostics are errors: `[STRICT_READ_UNTRACKED]` means a read outside a tracking scope, `[ASYNC_STORE_SETTER]` means a store setter callback awaited (the draft is a synchronous transaction — await first, then write), and a held write needs `isPending`/`latest`/optimistic/`affects` feedback or dev + attribution reports `[SILENT_HOLD]`. Error monitoring is `configureClientErrors` / `configureServerErrors` (or per root `render(fn, root, undefined, { onError })` — options are the 4th argument), not a side effect inside `<Errored fallback>`; the server hook must return a reference or nothing, never the error itself. `<Dynamic>` is deprecated — use `dynamic()`. Observability is on by default in development: whenever a change touches stores, lists, async, actions, or effects, keep the dev console free of `[CODE]` findings and run an attribution session (`/__solid/diagnostics` `begin` → interact → `whyDidRun` / `costs` → `end` with `@solidjs/diagnostics` installed, otherwise `isDev`-guarded `attribution.enable({ log: false })` + `why()` / `costs()`) before finishing. `vite dev` is always the dev build; `solid({ observe: true })` is a production opt-in.