solid2-agent-kit 0.4.5 → 0.8.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 +81 -5
- package/bin/solid2-kit.mjs +714 -28
- package/files/commands/solid2-review.md +30 -0
- package/files/shared/agents-section.md +4 -2
- package/files/shared/rules-body.md +17 -3
- package/files/skills/solid-2/SKILL.md +56 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -16,7 +16,12 @@ 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 |
|
|
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`), and two-phase effects whose apply only writes a local signal (use a writable derivation) |
|
|
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
|
+
| 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
|
+
| 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) |
|
|
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) |
|
|
20
25
|
|
|
21
26
|
Managed blocks are delimited with `<!-- solid2-agent-kit:*:start/end -->` markers; everything
|
|
22
27
|
outside them is yours. Kit-owned files (`solid-2.mdc`, the skill directories) are overwritten
|
|
@@ -35,11 +40,16 @@ npx github:lightsound/solid2-agent-kit init
|
|
|
35
40
|
npx github:lightsound/solid2-agent-kit init --cursor
|
|
36
41
|
npx github:lightsound/solid2-agent-kit init --claude
|
|
37
42
|
|
|
38
|
-
# run the mechanical pattern gate (default source dir: src)
|
|
43
|
+
# run the mechanical pattern gate (default source dir: src; or name files)
|
|
39
44
|
npx github:lightsound/solid2-agent-kit check
|
|
40
45
|
npx github:lightsound/solid2-agent-kit check --dir app
|
|
46
|
+
npx github:lightsound/solid2-agent-kit check src/App.tsx
|
|
41
47
|
|
|
42
|
-
#
|
|
48
|
+
# check the project wiring (deps, tsconfig, root configs)
|
|
49
|
+
npx github:lightsound/solid2-agent-kit doctor
|
|
50
|
+
|
|
51
|
+
# pull in kit updates later (idempotent; replaces managed blocks, kit-owned
|
|
52
|
+
# files, and hook entries)
|
|
43
53
|
npx github:lightsound/solid2-agent-kit sync
|
|
44
54
|
```
|
|
45
55
|
|
|
@@ -58,6 +68,71 @@ Update later with `bun update solid2-agent-kit` (or the npm/pnpm equivalent) fol
|
|
|
58
68
|
`npx solid2-agent-kit sync`. Outside a project that has the kit installed, prefer the
|
|
59
69
|
`npx github:...` form — it always tracks this repo directly.
|
|
60
70
|
|
|
71
|
+
## Edit-time hooks (automatic enforcement)
|
|
72
|
+
|
|
73
|
+
The rules and skill teach; `check` verifies — but only if someone runs it. The hook layer
|
|
74
|
+
closes that gap: `init`/`sync` wire `solid2-kit hook` into both agents' hook systems, so the
|
|
75
|
+
mechanical gate runs on **every file the agent edits** and violations come back as feedback
|
|
76
|
+
the agent must act on, in the same turn it introduced them:
|
|
77
|
+
|
|
78
|
+
- **Cursor** — a `postToolUse` entry in `.cursor/hooks.json`; findings are injected into the
|
|
79
|
+
conversation as `additional_context` right after the tool result.
|
|
80
|
+
- **Claude Code** — a `PostToolUse` entry in `.claude/settings.json` (matcher
|
|
81
|
+
`Edit|MultiEdit|Write|Bash`); findings go to stderr with exit 2, which Claude Code feeds
|
|
82
|
+
back to the model as a correction prompt.
|
|
83
|
+
|
|
84
|
+
Agents also edit files *through the shell* (sed, heredocs, `mv`, codemod scripts), which
|
|
85
|
+
never hits the Edit/Write tools — so shell-shaped tool payloads are scanned for source
|
|
86
|
+
paths mentioned in the command, and those files are checked after the command ran.
|
|
87
|
+
|
|
88
|
+
Claude Code additionally gets a **turn-end gate**: a `Stop` hook re-runs the whole
|
|
89
|
+
mechanical check (over `src`/`app`/`lib`) plus `doctor` when the agent tries to finish its
|
|
90
|
+
turn, and blocks the stop while findings remain. `stop_hook_active` is honored, so an agent
|
|
91
|
+
that cannot satisfy the gate is forced to continue exactly once, never looped forever.
|
|
92
|
+
(Cursor's `stop` hook cannot feed back to the agent, so there the last line of defense is
|
|
93
|
+
the always-attached rules plus CI.)
|
|
94
|
+
|
|
95
|
+
Both agents also get a **pre-execution guard** (Claude: `PreToolUse`; Cursor: `preToolUse`
|
|
96
|
+
and `beforeShellExecution`). A blocked agent's classic next move is to decide the gate
|
|
97
|
+
itself is wrong — edit the rules file, delete the skill, or strip the hook entries. The
|
|
98
|
+
guard denies, before the tool call runs: writes to kit-owned files (rules `.mdc`, skill
|
|
99
|
+
directories, review commands, the kit under `node_modules`); edits to `.cursor/hooks.json`
|
|
100
|
+
/ `.claude/settings.json` / `AGENTS.md` / `CLAUDE.md` that would remove the kit's entries
|
|
101
|
+
or managed blocks (marker-preserving edits pass — agents can still add the user's own
|
|
102
|
+
hooks and sections); shell rewrites of those files (`sed`/`rm`/redirects — uninspectable,
|
|
103
|
+
so the deny message redirects to the Edit tool); and package-manager installs of
|
|
104
|
+
dependencies `doctor` bans (exact token match, so `react` is denied but `react-aria` is
|
|
105
|
+
not). The deny message always tells the agent what to do instead — fix the flagged code,
|
|
106
|
+
run `solid2-kit sync`, or stop and ask the user. Pre-execution events never run the
|
|
107
|
+
content check itself: fixing a currently-bad file is always allowed.
|
|
108
|
+
|
|
109
|
+
When the kit is installed as a devDependency, `init` also wires a `lint:solid` script
|
|
110
|
+
(`solid2-kit check && solid2-kit doctor`) into the project's `package.json` (only if the
|
|
111
|
+
script does not already exist), so agents and CI can run both gates by name.
|
|
112
|
+
|
|
113
|
+
The hook command is `node node_modules/solid2-agent-kit/bin/solid2-kit.mjs hook <agent>`,
|
|
114
|
+
so it only works when the kit is installed as a devDependency (see above); `init` skips the
|
|
115
|
+
wiring and prints a note otherwise. Merging is idempotent — kit entries are refreshed in
|
|
116
|
+
place on `sync`, user entries are preserved. Opt out with `init --no-hooks`. Hooks never
|
|
117
|
+
break the agent loop: clean edits, read tools, and malformed payloads exit silently, and
|
|
118
|
+
reports are capped at 40 findings.
|
|
119
|
+
|
|
120
|
+
Note on gate precision: `check` (and therefore the hooks) matches against
|
|
121
|
+
**comment-stripped** source, so prose like `// migrated off createResource` never trips
|
|
122
|
+
the gate, while string checks (`"use client"`) still work.
|
|
123
|
+
|
|
124
|
+
## CI for consuming projects
|
|
125
|
+
|
|
126
|
+
Run both gates on every PR so nothing an agent (or human) merges can reintroduce React or
|
|
127
|
+
Solid 1.x patterns:
|
|
128
|
+
|
|
129
|
+
```yaml
|
|
130
|
+
- uses: actions/setup-node@v4
|
|
131
|
+
with: { node-version: 22 }
|
|
132
|
+
- run: npm ci
|
|
133
|
+
- run: npx solid2-agent-kit check && npx solid2-agent-kit doctor
|
|
134
|
+
```
|
|
135
|
+
|
|
61
136
|
## Why not eslint-plugin-solid?
|
|
62
137
|
|
|
63
138
|
`eslint-plugin-solid` was built for Solid 1.x. Its analyzer predates the Solid 2 model and
|
|
@@ -88,12 +163,13 @@ markdown with an `.md` suffix).
|
|
|
88
163
|
## Layout
|
|
89
164
|
|
|
90
165
|
```
|
|
91
|
-
bin/solid2-kit.mjs CLI: init / sync / check
|
|
166
|
+
bin/solid2-kit.mjs CLI: init / sync / check / doctor / hook
|
|
92
167
|
files/shared/rules-body.md hard rules (rendered into .mdc and CLAUDE.md)
|
|
93
168
|
files/shared/agents-section.md AGENTS.md managed-block content
|
|
94
169
|
files/skills/solid-2/ SKILL.md + references/official-docs.md
|
|
170
|
+
files/commands/solid2-review.md /solid2-review command (Cursor + Claude Code)
|
|
95
171
|
scripts/check-docs-drift.mjs weekly docs cross-check
|
|
96
|
-
tests/
|
|
172
|
+
tests/ check/doctor fixtures + hook and init-merge tests (`npm test`)
|
|
97
173
|
```
|
|
98
174
|
|
|
99
175
|
## Publishing (npm)
|
package/bin/solid2-kit.mjs
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
// solid2-agent-kit CLI — installs and maintains Solid 2.0 guidance for AI
|
|
3
3
|
// coding agents (Cursor and Claude Code), and runs a mechanical pattern gate.
|
|
4
4
|
//
|
|
5
|
-
// solid2-kit init [--cursor] [--claude] [--target <dir>]
|
|
6
|
-
// solid2-kit sync [--cursor] [--claude] [--target <dir>] (alias of init)
|
|
7
|
-
// solid2-kit check [--dir <srcdir>] [--target <dir>]
|
|
5
|
+
// solid2-kit init [--cursor] [--claude] [--no-hooks] [--target <dir>]
|
|
6
|
+
// solid2-kit sync [--cursor] [--claude] [--no-hooks] [--target <dir>] (alias of init)
|
|
7
|
+
// solid2-kit check [--dir <srcdir>] [--target <dir>] [files...]
|
|
8
|
+
// solid2-kit doctor [--target <dir>]
|
|
9
|
+
// solid2-kit hook (claude|cursor) (stdin: agent hook JSON payload)
|
|
8
10
|
//
|
|
9
|
-
// `init`/`sync` are idempotent: kit-owned files are overwritten
|
|
10
|
-
// blocks in AGENTS.md / CLAUDE.md are replaced in place
|
|
11
|
+
// `init`/`sync` are idempotent: kit-owned files are overwritten, managed
|
|
12
|
+
// blocks in AGENTS.md / CLAUDE.md are replaced in place, and hook entries in
|
|
13
|
+
// .cursor/hooks.json / .claude/settings.json are merged without duplicates.
|
|
11
14
|
|
|
12
15
|
import {
|
|
13
16
|
cpSync,
|
|
@@ -32,6 +35,20 @@ function flagValue(name, fallback) {
|
|
|
32
35
|
return index === -1 || index + 1 >= args.length ? fallback : args[index + 1];
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
const VALUE_FLAGS = new Set(['--dir', '--target']);
|
|
39
|
+
|
|
40
|
+
function positionalArgs() {
|
|
41
|
+
const out = [];
|
|
42
|
+
for (let i = 1; i < args.length; i += 1) {
|
|
43
|
+
if (args[i].startsWith('--')) {
|
|
44
|
+
if (VALUE_FLAGS.has(args[i])) i += 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
out.push(args[i]);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
// --- init / sync ------------------------------------------------------------
|
|
36
53
|
|
|
37
54
|
function countOccurrences(haystack, needle) {
|
|
@@ -74,6 +91,104 @@ function writeKitOwnedFile(filePath, body) {
|
|
|
74
91
|
return filePath;
|
|
75
92
|
}
|
|
76
93
|
|
|
94
|
+
// Hook commands run from the project root without node_modules/.bin on PATH,
|
|
95
|
+
// so they invoke the locally installed kit through node directly. `init` only
|
|
96
|
+
// wires hooks when that file exists (kit installed as a dependency).
|
|
97
|
+
const KIT_LOCAL_BIN = 'node_modules/solid2-agent-kit/bin/solid2-kit.mjs';
|
|
98
|
+
|
|
99
|
+
function readJsonConfig(filePath) {
|
|
100
|
+
if (!existsSync(filePath)) return {};
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
103
|
+
} catch {
|
|
104
|
+
console.warn(
|
|
105
|
+
`solid2-kit — warning: ${filePath} is not valid JSON. File left untouched; fix it and re-run sync to wire the edit hook.`,
|
|
106
|
+
);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function writeJsonConfig(filePath, config) {
|
|
112
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
113
|
+
writeFileSync(filePath, `${JSON.stringify(config, null, 2)}\n`);
|
|
114
|
+
return filePath;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Replace the existing kit-owned entry (identified by the marker in its
|
|
118
|
+
// command) or append a fresh one — sync then updates matchers/commands that
|
|
119
|
+
// changed between kit versions while leaving user entries untouched.
|
|
120
|
+
function upsertHookEntry(entries, marker, entry, matches) {
|
|
121
|
+
const index = entries.findIndex((existing) => matches(existing, marker));
|
|
122
|
+
if (index === -1) entries.push(entry);
|
|
123
|
+
else entries[index] = entry;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const cursorEntryMatches = (entry, marker) =>
|
|
127
|
+
typeof entry?.command === 'string' && entry.command.includes(marker);
|
|
128
|
+
const claudeEntryMatches = (entry, marker) =>
|
|
129
|
+
(entry?.hooks ?? []).some((item) => typeof item?.command === 'string' && item.command.includes(marker));
|
|
130
|
+
|
|
131
|
+
// Cursor hooks: postToolUse (post-edit check injected as additional_context)
|
|
132
|
+
// plus preToolUse / beforeShellExecution (pre-execution guard: guardrail
|
|
133
|
+
// tampering and banned-dependency installs are denied before they run).
|
|
134
|
+
function upsertCursorHook(target) {
|
|
135
|
+
const filePath = join(target, '.cursor/hooks.json');
|
|
136
|
+
const config = readJsonConfig(filePath);
|
|
137
|
+
if (config === null) return null;
|
|
138
|
+
config.version ??= 1;
|
|
139
|
+
config.hooks ??= {};
|
|
140
|
+
const command = `node ${KIT_LOCAL_BIN} hook cursor`;
|
|
141
|
+
for (const event of ['postToolUse', 'preToolUse', 'beforeShellExecution']) {
|
|
142
|
+
upsertHookEntry((config.hooks[event] ??= []), 'solid2-kit.mjs hook cursor', { command }, cursorEntryMatches);
|
|
143
|
+
}
|
|
144
|
+
return writeJsonConfig(filePath, config);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Claude Code hooks: PostToolUse (`hook claude` prints findings to stderr and
|
|
148
|
+
// exits 2, which Claude Code feeds back into the model as correction context;
|
|
149
|
+
// Bash is matched so shell edits via sed/heredocs are gated too), PreToolUse
|
|
150
|
+
// (pre-execution guard, may deny) and Stop (whole-project final gate — the
|
|
151
|
+
// agent cannot end its turn with findings).
|
|
152
|
+
function upsertClaudeHook(target) {
|
|
153
|
+
const filePath = join(target, '.claude/settings.json');
|
|
154
|
+
const config = readJsonConfig(filePath);
|
|
155
|
+
if (config === null) return null;
|
|
156
|
+
config.hooks ??= {};
|
|
157
|
+
const command = `node ${KIT_LOCAL_BIN} hook claude`;
|
|
158
|
+
const toolMatcher = 'Edit|MultiEdit|Write|Bash';
|
|
159
|
+
upsertHookEntry(
|
|
160
|
+
(config.hooks.PreToolUse ??= []),
|
|
161
|
+
'solid2-kit.mjs hook claude',
|
|
162
|
+
{ matcher: toolMatcher, hooks: [{ type: 'command', command, timeout: 30 }] },
|
|
163
|
+
claudeEntryMatches,
|
|
164
|
+
);
|
|
165
|
+
upsertHookEntry(
|
|
166
|
+
(config.hooks.PostToolUse ??= []),
|
|
167
|
+
'solid2-kit.mjs hook claude',
|
|
168
|
+
{ matcher: toolMatcher, hooks: [{ type: 'command', command, timeout: 30 }] },
|
|
169
|
+
claudeEntryMatches,
|
|
170
|
+
);
|
|
171
|
+
upsertHookEntry(
|
|
172
|
+
(config.hooks.Stop ??= []),
|
|
173
|
+
'solid2-kit.mjs hook claude',
|
|
174
|
+
{ hooks: [{ type: 'command', command, timeout: 60 }] },
|
|
175
|
+
claudeEntryMatches,
|
|
176
|
+
);
|
|
177
|
+
return writeJsonConfig(filePath, config);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Wire a `lint:solid` script (referenced by the AGENTS.md guidance) into the
|
|
181
|
+
// consuming project so agents and CI can run both gates by name.
|
|
182
|
+
function upsertLintScript(target) {
|
|
183
|
+
const filePath = join(target, 'package.json');
|
|
184
|
+
if (!existsSync(filePath)) return null;
|
|
185
|
+
const pkg = readJsonConfig(filePath);
|
|
186
|
+
if (pkg === null) return null;
|
|
187
|
+
if (pkg.scripts?.['lint:solid']) return null;
|
|
188
|
+
(pkg.scripts ??= {})['lint:solid'] = 'solid2-kit check && solid2-kit doctor';
|
|
189
|
+
return writeJsonConfig(filePath, pkg);
|
|
190
|
+
}
|
|
191
|
+
|
|
77
192
|
function copySkill(targetSkillDir) {
|
|
78
193
|
// The skill directory is kit-owned: remove it first so files deleted in
|
|
79
194
|
// newer kit versions do not linger after a sync.
|
|
@@ -92,6 +207,7 @@ function init() {
|
|
|
92
207
|
|
|
93
208
|
const rulesBody = readFileSync(join(KIT_ROOT, 'files/shared/rules-body.md'), 'utf8');
|
|
94
209
|
const agentsSection = readFileSync(join(KIT_ROOT, 'files/shared/agents-section.md'), 'utf8');
|
|
210
|
+
const reviewCommand = readFileSync(join(KIT_ROOT, 'files/commands/solid2-review.md'), 'utf8');
|
|
95
211
|
const written = [];
|
|
96
212
|
|
|
97
213
|
if (wantCursor) {
|
|
@@ -109,10 +225,12 @@ function init() {
|
|
|
109
225
|
].join('\n');
|
|
110
226
|
written.push(writeKitOwnedFile(join(target, '.cursor/rules/solid-2.mdc'), mdc));
|
|
111
227
|
written.push(copySkill(join(target, '.cursor/skills/solid-2')));
|
|
228
|
+
written.push(writeKitOwnedFile(join(target, '.cursor/commands/solid2-review.md'), reviewCommand));
|
|
112
229
|
}
|
|
113
230
|
|
|
114
231
|
if (wantClaude) {
|
|
115
232
|
written.push(copySkill(join(target, '.claude/skills/solid-2')));
|
|
233
|
+
written.push(writeKitOwnedFile(join(target, '.claude/commands/solid2-review.md'), reviewCommand));
|
|
116
234
|
// Claude Code has no glob-attached rules mechanism; CLAUDE.md is always
|
|
117
235
|
// loaded, so the full rules body lives there in a managed block.
|
|
118
236
|
written.push(
|
|
@@ -134,12 +252,108 @@ function init() {
|
|
|
134
252
|
),
|
|
135
253
|
);
|
|
136
254
|
|
|
255
|
+
// Edit-time hooks: run the mechanical gate automatically on every agent
|
|
256
|
+
// file edit, so enforcement does not depend on the agent remembering to
|
|
257
|
+
// run `solid2-kit check`.
|
|
258
|
+
let hooksNote = null;
|
|
259
|
+
if (!args.includes('--no-hooks')) {
|
|
260
|
+
if (existsSync(join(target, KIT_LOCAL_BIN))) {
|
|
261
|
+
if (wantCursor) written.push(upsertCursorHook(target));
|
|
262
|
+
if (wantClaude) written.push(upsertClaudeHook(target));
|
|
263
|
+
written.push(upsertLintScript(target));
|
|
264
|
+
} else {
|
|
265
|
+
hooksNote =
|
|
266
|
+
'edit-time hooks not wired: install the kit as a devDependency ("solid2-agent-kit": "github:lightsound/solid2-agent-kit") so `node node_modules/solid2-agent-kit/bin/solid2-kit.mjs` resolves, then re-run sync. Pass --no-hooks to silence this note.';
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
137
270
|
console.log(`solid2-agent-kit v${VERSION} — installed for ${[wantCursor && 'Cursor', wantClaude && 'Claude Code'].filter(Boolean).join(' + ')}:`);
|
|
138
271
|
for (const path of written.filter(Boolean)) console.log(` ${relative(target, path) || '.'}`);
|
|
272
|
+
if (hooksNote) console.log(` note: ${hooksNote}`);
|
|
139
273
|
}
|
|
140
274
|
|
|
141
275
|
// --- check ------------------------------------------------------------------
|
|
142
276
|
|
|
277
|
+
// Balanced-scan helpers for the effect-sync check: regexes cannot pair the
|
|
278
|
+
// parentheses of a createEffect(...) call, so scan (string-aware; comments
|
|
279
|
+
// are already stripped) for the matching closer from an opening bracket.
|
|
280
|
+
function scanBalanced(content, openIndex) {
|
|
281
|
+
let depth = 0;
|
|
282
|
+
let quote = null;
|
|
283
|
+
for (let i = openIndex; i < content.length; i += 1) {
|
|
284
|
+
const ch = content[i];
|
|
285
|
+
if (quote !== null) {
|
|
286
|
+
if (ch === '\\') i += 1;
|
|
287
|
+
else if (ch === quote) quote = null;
|
|
288
|
+
} else if (ch === "'" || ch === '"' || ch === '`') {
|
|
289
|
+
quote = ch;
|
|
290
|
+
} else if (ch === '(' || ch === '{' || ch === '[') {
|
|
291
|
+
depth += 1;
|
|
292
|
+
} else if (ch === ')' || ch === '}' || ch === ']') {
|
|
293
|
+
depth -= 1;
|
|
294
|
+
if (depth === 0) return i;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return -1;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function splitTopLevelArgs(argsText) {
|
|
301
|
+
const parts = [];
|
|
302
|
+
let depth = 0;
|
|
303
|
+
let quote = null;
|
|
304
|
+
let start = 0;
|
|
305
|
+
for (let i = 0; i < argsText.length; i += 1) {
|
|
306
|
+
const ch = argsText[i];
|
|
307
|
+
if (quote !== null) {
|
|
308
|
+
if (ch === '\\') i += 1;
|
|
309
|
+
else if (ch === quote) quote = null;
|
|
310
|
+
} else if (ch === "'" || ch === '"' || ch === '`') {
|
|
311
|
+
quote = ch;
|
|
312
|
+
} else if (ch === '(' || ch === '{' || ch === '[') {
|
|
313
|
+
depth += 1;
|
|
314
|
+
} else if (ch === ')' || ch === '}' || ch === ']') {
|
|
315
|
+
depth -= 1;
|
|
316
|
+
} else if (ch === ',' && depth === 0) {
|
|
317
|
+
parts.push(argsText.slice(start, i));
|
|
318
|
+
start = i + 1;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
parts.push(argsText.slice(start));
|
|
322
|
+
return parts;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// An apply phase that is nothing but one (optionally if-guarded) call to a
|
|
326
|
+
// local signal setter is "a signal plus an effect that syncs it" — rule 4.
|
|
327
|
+
// Restricting to setters declared via createSignal destructuring in the same
|
|
328
|
+
// file keeps external set* functions (setDefaultOptions, setTimeout) out.
|
|
329
|
+
function soleLocalSetterApply(apply, localSetters) {
|
|
330
|
+
const head = apply.match(
|
|
331
|
+
/^(?:async\s*)?(?:\([^()]*\)|[\w$]+)\s*=>\s*\{?\s*(?:if\s*\((?:[^()]|\([^()]*\))*\)\s*\{?\s*)?(set[A-Z][\w$]*)\s*\(/,
|
|
332
|
+
);
|
|
333
|
+
if (!head || !localSetters.has(head[1])) return false;
|
|
334
|
+
const close = scanBalanced(apply, head[0].length - 1);
|
|
335
|
+
if (close === -1) return false;
|
|
336
|
+
return /^[\s;}]*$/.test(apply.slice(close + 1));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const LOCAL_SETTER_DECL = /\[\s*[\w$]+\s*,\s*(set[A-Z][\w$]*)\s*\]\s*=\s*createSignal\b/g;
|
|
340
|
+
|
|
341
|
+
function effectSyncFindings(content) {
|
|
342
|
+
const localSetters = new Set([...content.matchAll(LOCAL_SETTER_DECL)].map((m) => m[1]));
|
|
343
|
+
if (localSetters.size === 0) return [];
|
|
344
|
+
const matches = [];
|
|
345
|
+
for (const call of content.matchAll(/\bcreateEffect\s*(?:<[^>()]*>)?\s*\(/g)) {
|
|
346
|
+
const open = call.index + call[0].length - 1;
|
|
347
|
+
const close = scanBalanced(content, open);
|
|
348
|
+
if (close === -1) continue;
|
|
349
|
+
const args = splitTopLevelArgs(content.slice(open + 1, close));
|
|
350
|
+
if (args.length >= 2 && soleLocalSetterApply(args[1].trim(), localSetters)) {
|
|
351
|
+
matches.push({ index: call.index });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return matches;
|
|
355
|
+
}
|
|
356
|
+
|
|
143
357
|
const CHECKS = [
|
|
144
358
|
{
|
|
145
359
|
id: 'props-destructure-param',
|
|
@@ -164,6 +378,20 @@ const CHECKS = [
|
|
|
164
378
|
message:
|
|
165
379
|
'Solid 1.x import path. Stores/merge/omit come from "solid-js"; render/hydrate/Portal/Dynamic come from "@solidjs/web".',
|
|
166
380
|
},
|
|
381
|
+
{
|
|
382
|
+
id: 'jsx-namespace-import',
|
|
383
|
+
pattern: /import\s+(?:type\s+)?\{[^}]*\bJSX\b[^}]*\}\s*from\s+['"]solid-js['"]/g,
|
|
384
|
+
message:
|
|
385
|
+
'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".',
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
// A two-phase createEffect whose apply is nothing but one (optionally
|
|
389
|
+
// if-guarded) call to a setter declared via createSignal in this file.
|
|
390
|
+
id: 'effect-sync-signal',
|
|
391
|
+
find: effectSyncFindings,
|
|
392
|
+
message:
|
|
393
|
+
'Effect that only syncs a local signal — "state + effect" (rule 4). Derive the value instead; to reset state when another reactive value changes, use a writable derivation: createSignal(() => { source(); return initial; }).',
|
|
394
|
+
},
|
|
167
395
|
{
|
|
168
396
|
id: 'react-jsx-prop',
|
|
169
397
|
pattern: /\b(?:className|htmlFor)=/g,
|
|
@@ -367,8 +595,59 @@ const CHECKS = [
|
|
|
367
595
|
},
|
|
368
596
|
];
|
|
369
597
|
|
|
370
|
-
// .ts / .tsx sources, excluding .d.ts declaration files.
|
|
371
|
-
const SOURCE_FILE = /(?<!\.d)\.tsx
|
|
598
|
+
// .ts / .tsx / .jsx sources, excluding .d.ts declaration files.
|
|
599
|
+
const SOURCE_FILE = /(?<!\.d)\.(?:tsx?|jsx)$/;
|
|
600
|
+
|
|
601
|
+
// Blank comment interiors (preserving newlines and offsets) so banned tokens
|
|
602
|
+
// mentioned in comments ("migrated off createResource") do not trip the gate.
|
|
603
|
+
// String and template contents are preserved — some checks match string
|
|
604
|
+
// literals ("use client"). This is a heuristic scanner, not a JS lexer: a
|
|
605
|
+
// regex literal containing escaped slashes or `//` in JSX text can blank the
|
|
606
|
+
// remainder of that one line (false negatives only, never false positives).
|
|
607
|
+
function stripComments(content) {
|
|
608
|
+
let out = '';
|
|
609
|
+
let state = 'code';
|
|
610
|
+
for (let i = 0; i < content.length; i += 1) {
|
|
611
|
+
const ch = content[i];
|
|
612
|
+
const next = content[i + 1];
|
|
613
|
+
if (state === 'code') {
|
|
614
|
+
if (ch === '/' && (next === '/' || next === '*')) {
|
|
615
|
+
state = next === '/' ? 'line' : 'block';
|
|
616
|
+
out += ' ';
|
|
617
|
+
i += 1;
|
|
618
|
+
} else {
|
|
619
|
+
if (ch === "'") state = 'single';
|
|
620
|
+
else if (ch === '"') state = 'double';
|
|
621
|
+
else if (ch === '`') state = 'template';
|
|
622
|
+
out += ch;
|
|
623
|
+
}
|
|
624
|
+
} else if (state === 'line') {
|
|
625
|
+
if (ch === '\n') state = 'code';
|
|
626
|
+
out += ch === '\n' ? ch : ' ';
|
|
627
|
+
} else if (state === 'block') {
|
|
628
|
+
if (ch === '*' && next === '/') {
|
|
629
|
+
state = 'code';
|
|
630
|
+
out += ' ';
|
|
631
|
+
i += 1;
|
|
632
|
+
} else {
|
|
633
|
+
out += ch === '\n' ? ch : ' ';
|
|
634
|
+
}
|
|
635
|
+
} else {
|
|
636
|
+
out += ch;
|
|
637
|
+
if (ch === '\\') {
|
|
638
|
+
out += next ?? '';
|
|
639
|
+
i += 1;
|
|
640
|
+
} else if (
|
|
641
|
+
(state === 'single' && ch === "'") ||
|
|
642
|
+
(state === 'double' && ch === '"') ||
|
|
643
|
+
(state === 'template' && ch === '`')
|
|
644
|
+
) {
|
|
645
|
+
state = 'code';
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return out;
|
|
650
|
+
}
|
|
372
651
|
|
|
373
652
|
function* walk(dir) {
|
|
374
653
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -378,36 +657,435 @@ function* walk(dir) {
|
|
|
378
657
|
}
|
|
379
658
|
}
|
|
380
659
|
|
|
660
|
+
// One formatted finding per pattern match: "path:line [id] message\n > source".
|
|
661
|
+
// Patterns run against comment-stripped content (same offsets); the quoted
|
|
662
|
+
// source line comes from the original file.
|
|
663
|
+
function fileFindings(file, relativeTo) {
|
|
664
|
+
const raw = readFileSync(file, 'utf8');
|
|
665
|
+
const content = stripComments(raw);
|
|
666
|
+
const lines = raw.split('\n');
|
|
667
|
+
const findings = [];
|
|
668
|
+
for (const rule of CHECKS) {
|
|
669
|
+
for (const match of rule.find ? rule.find(content) : content.matchAll(rule.pattern)) {
|
|
670
|
+
const lineNumber = content.slice(0, match.index).split('\n').length;
|
|
671
|
+
findings.push(
|
|
672
|
+
`${relative(relativeTo, file) || file}:${lineNumber} [${rule.id}] ${rule.message}\n > ${lines[lineNumber - 1].trim()}`,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return findings;
|
|
677
|
+
}
|
|
678
|
+
|
|
381
679
|
function check() {
|
|
382
680
|
const target = resolve(flagValue('--target', '.'));
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
681
|
+
const fileArgs = positionalArgs();
|
|
682
|
+
|
|
683
|
+
let files;
|
|
684
|
+
if (fileArgs.length > 0) {
|
|
685
|
+
// Explicit file mode (used by agent hooks): check only the named sources.
|
|
686
|
+
files = fileArgs.map((file) => resolve(target, file)).filter((file) => SOURCE_FILE.test(file));
|
|
687
|
+
} else {
|
|
688
|
+
const srcDir = resolve(target, flagValue('--dir', 'src'));
|
|
689
|
+
if (!existsSync(srcDir)) {
|
|
690
|
+
console.error(`solid2-kit check — source directory not found: ${srcDir} (use --dir)`);
|
|
691
|
+
process.exit(2);
|
|
692
|
+
}
|
|
693
|
+
files = [...walk(srcDir)];
|
|
387
694
|
}
|
|
388
695
|
|
|
389
696
|
let findings = 0;
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
697
|
+
for (const file of files) {
|
|
698
|
+
for (const finding of fileFindings(file, target)) {
|
|
699
|
+
findings += 1;
|
|
700
|
+
console.error(finding);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (findings > 0) {
|
|
705
|
+
console.error(`\nsolid2-kit check — ${findings} finding(s) in ${files.length} file(s).`);
|
|
706
|
+
process.exit(1);
|
|
707
|
+
}
|
|
708
|
+
console.log(`solid2-kit check — OK (${files.length} files scanned).`);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// --- hook -------------------------------------------------------------------
|
|
712
|
+
// Edit-time gate wired by `init` into the agents' hook systems. Reads the
|
|
713
|
+
// hook JSON payload from stdin, runs the mechanical checks on the edited
|
|
714
|
+
// source files, and feeds findings back through each agent's channel:
|
|
715
|
+
// claude — Claude Code PostToolUse: findings on stderr + exit 2 (Claude
|
|
716
|
+
// sees the stderr and fixes; other exit codes are ignored). Also
|
|
717
|
+
// handles the Stop event as a whole-project final gate: exit 2
|
|
718
|
+
// blocks the agent from ending its turn with violations in place.
|
|
719
|
+
// cursor — Cursor postToolUse: findings as {"additional_context"} JSON on
|
|
720
|
+
// stdout (injected into the conversation after the tool result).
|
|
721
|
+
// Both agents also edit files through shell commands (sed, heredocs, mv,
|
|
722
|
+
// codemods), which never hit the Edit/Write tools — so shell-shaped tool
|
|
723
|
+
// payloads are scanned for source paths mentioned in the command and those
|
|
724
|
+
// files are checked after the command ran.
|
|
725
|
+
// Hooks must never break the agent loop: malformed payloads exit 0 silently.
|
|
726
|
+
|
|
727
|
+
const WRITE_TOOL = /write|edit|replace|apply|patch/i;
|
|
728
|
+
const SHELL_TOOL = /shell|bash|terminal|command|exec/i;
|
|
729
|
+
const MAX_REPORTED = 40;
|
|
730
|
+
|
|
731
|
+
function formatReport(header, findings) {
|
|
732
|
+
const shown = findings.slice(0, MAX_REPORTED);
|
|
733
|
+
const more = findings.length - shown.length;
|
|
734
|
+
return `${header}\n\n${shown.join('\n')}${more > 0 ? `\n…and ${more} more finding(s).` : ''}`;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function isCheckableSource(path) {
|
|
738
|
+
return SOURCE_FILE.test(path) && !path.includes('\n') && !path.includes('node_modules');
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function collectSourcePaths(value, found = new Set()) {
|
|
742
|
+
if (typeof value === 'string') {
|
|
743
|
+
if (isCheckableSource(value) && existsSync(value)) found.add(value);
|
|
744
|
+
} else if (Array.isArray(value)) {
|
|
745
|
+
for (const item of value) collectSourcePaths(item, found);
|
|
746
|
+
} else if (value && typeof value === 'object') {
|
|
747
|
+
for (const item of Object.values(value)) collectSourcePaths(item, found);
|
|
748
|
+
}
|
|
749
|
+
return found;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Path-shaped tokens ending in a source extension, resolved against the
|
|
753
|
+
// command's cwd. Existence filtering keeps false positives at zero.
|
|
754
|
+
function collectCommandPaths(command, cwd, found = new Set()) {
|
|
755
|
+
if (typeof command !== 'string') return found;
|
|
756
|
+
for (const match of command.matchAll(/[^\s'"`;|&()<>=]+\.(?:tsx?|jsx)\b/g)) {
|
|
757
|
+
const token = match[0];
|
|
758
|
+
if (!isCheckableSource(token)) continue;
|
|
759
|
+
const path = resolve(cwd, token);
|
|
760
|
+
if (existsSync(path)) found.add(path);
|
|
761
|
+
}
|
|
762
|
+
return found;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// --- pre-execution guard (self-defense + proactive denial) -------------------
|
|
766
|
+
// Agents that keep getting blocked by a gate tend to conclude the gate is
|
|
767
|
+
// wrong and edit or delete it. Pre-execution hooks (Claude PreToolUse,
|
|
768
|
+
// Cursor preToolUse / beforeShellExecution) can deny a tool call before it
|
|
769
|
+
// runs, so this guard refuses:
|
|
770
|
+
// - edits/deletes of kit-owned guardrail files (rules, skill, commands,
|
|
771
|
+
// the kit itself under node_modules),
|
|
772
|
+
// - edits that would remove the kit's markers from hook configs or the
|
|
773
|
+
// AGENTS.md / CLAUDE.md managed blocks,
|
|
774
|
+
// - shell rewrites of those files (sed/rm/redirects — uninspectable),
|
|
775
|
+
// - installing dependencies doctor would flag anyway (react,
|
|
776
|
+
// vite-plugin-solid, eslint-plugin-solid, SolidStart, ...).
|
|
777
|
+
// Everything else is allowed; this gate never inspects file *content* being
|
|
778
|
+
// written (that is the post-edit hook's job).
|
|
779
|
+
|
|
780
|
+
const GUARDRAIL_PATH =
|
|
781
|
+
/\.cursor\/rules\/solid-2\.mdc|(?:\.cursor|\.claude)\/skills\/solid-2|(?:\.cursor|\.claude)\/commands\/solid2-review\.md|node_modules\/solid2-agent-kit\//;
|
|
782
|
+
const MARKER_FILE = /(?:\.claude\/settings\.json|\.cursor\/hooks\.json|AGENTS\.md|CLAUDE\.md)$/;
|
|
783
|
+
const KIT_MARKER = 'solid2-agent-kit';
|
|
784
|
+
const SHELL_WRITE = /\b(?:rm|mv|cp|sed|tee|truncate|unlink|chmod|perl|python[\d.]*)\b|>>?/;
|
|
785
|
+
const INSTALL_CMD = /\b(?:npm|pnpm|yarn|bun)\s+(?:-\S+\s+)*(?:install|add|i)\b/;
|
|
786
|
+
|
|
787
|
+
const TAMPER_MESSAGE =
|
|
788
|
+
'solid2-agent-kit guardrail: this file is kit-managed Solid 2.0 guidance/enforcement. Do not edit, weaken, or delete it — fix the flagged code instead. Kit files update via `solid2-kit sync`. If you believe the guardrail itself is wrong, or the user explicitly wants it removed, stop and tell the user.';
|
|
789
|
+
|
|
790
|
+
function bannedInstall(command) {
|
|
791
|
+
if (!INSTALL_CMD.test(command)) return null;
|
|
792
|
+
for (const raw of command.split(/\s+/)) {
|
|
793
|
+
const token = raw.replace(/^['"]|['"]$/g, '');
|
|
794
|
+
const name = token.replace(/(?!^)@.*$/, '');
|
|
795
|
+
if (name in BANNED_DEPS) return name;
|
|
796
|
+
}
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function preToolGate(agent, payload) {
|
|
801
|
+
const deny = (agentMessage) => {
|
|
802
|
+
if (agent === 'claude') {
|
|
803
|
+
console.error(agentMessage);
|
|
804
|
+
process.exit(2);
|
|
805
|
+
}
|
|
806
|
+
console.log(
|
|
807
|
+
JSON.stringify({
|
|
808
|
+
permission: 'deny',
|
|
809
|
+
agent_message: agentMessage,
|
|
810
|
+
user_message: 'solid2-agent-kit guardrail blocked this action.',
|
|
811
|
+
}),
|
|
812
|
+
);
|
|
813
|
+
process.exit(0);
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
let toolInput = payload.tool_input;
|
|
817
|
+
if (typeof toolInput === 'string') {
|
|
818
|
+
try {
|
|
819
|
+
toolInput = JSON.parse(toolInput);
|
|
820
|
+
} catch {
|
|
821
|
+
toolInput = undefined;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const toolName = typeof payload.tool_name === 'string' ? payload.tool_name : '';
|
|
825
|
+
|
|
826
|
+
// Shell commands: beforeShellExecution carries `command` at the top level,
|
|
827
|
+
// PreToolUse(Bash) inside tool_input.
|
|
828
|
+
const command =
|
|
829
|
+
typeof payload.command === 'string'
|
|
830
|
+
? payload.command
|
|
831
|
+
: SHELL_TOOL.test(toolName) && typeof toolInput?.command === 'string'
|
|
832
|
+
? toolInput.command
|
|
833
|
+
: null;
|
|
834
|
+
if (command !== null) {
|
|
835
|
+
const banned = bannedInstall(command);
|
|
836
|
+
if (banned) {
|
|
837
|
+
deny(
|
|
838
|
+
`solid2-agent-kit guardrail: do not install "${banned}". ${BANNED_DEPS[banned]} (\`solid2-kit doctor\` fails on it.)`,
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
if (SHELL_WRITE.test(command)) {
|
|
842
|
+
if (GUARDRAIL_PATH.test(command)) deny(TAMPER_MESSAGE);
|
|
843
|
+
if (/(?:\.claude\/settings\.json|\.cursor\/hooks\.json)/.test(command)) {
|
|
844
|
+
deny(
|
|
845
|
+
'solid2-agent-kit guardrail: rewriting hook configs through the shell cannot be verified. Use the Edit tool (keeping the solid2-agent-kit hook entries), or ask the user.',
|
|
401
846
|
);
|
|
402
847
|
}
|
|
403
848
|
}
|
|
849
|
+
process.exit(0);
|
|
404
850
|
}
|
|
405
851
|
|
|
406
|
-
if (
|
|
407
|
-
|
|
852
|
+
if (!WRITE_TOOL.test(toolName)) process.exit(0);
|
|
853
|
+
const filePath = typeof toolInput?.file_path === 'string' ? toolInput.file_path : toolInput?.path;
|
|
854
|
+
if (typeof filePath !== 'string') process.exit(0);
|
|
855
|
+
|
|
856
|
+
if (GUARDRAIL_PATH.test(filePath)) deny(TAMPER_MESSAGE);
|
|
857
|
+
|
|
858
|
+
if (MARKER_FILE.test(filePath)) {
|
|
859
|
+
// Editing these files is fine (users add their own hooks/sections through
|
|
860
|
+
// agents) as long as the kit's entries/managed blocks survive the edit.
|
|
861
|
+
const removalMessage = `solid2-agent-kit guardrail: this edit removes the ${KIT_MARKER} entries/managed block from ${filePath}. Keep them intact; run \`solid2-kit sync\` to update them. If the user explicitly wants the kit removed, stop and tell the user to do it.`;
|
|
862
|
+
if (typeof toolInput.content === 'string') {
|
|
863
|
+
let existing = '';
|
|
864
|
+
try {
|
|
865
|
+
existing = readFileSync(resolve(typeof payload.cwd === 'string' ? payload.cwd : process.cwd(), filePath), 'utf8');
|
|
866
|
+
} catch {
|
|
867
|
+
// New file — nothing to preserve.
|
|
868
|
+
}
|
|
869
|
+
if (existing.includes(KIT_MARKER) && !toolInput.content.includes(KIT_MARKER)) deny(removalMessage);
|
|
870
|
+
} else if (
|
|
871
|
+
typeof toolInput.old_string === 'string' &&
|
|
872
|
+
toolInput.old_string.includes(KIT_MARKER) &&
|
|
873
|
+
!String(toolInput.new_string ?? '').includes(KIT_MARKER)
|
|
874
|
+
) {
|
|
875
|
+
deny(removalMessage);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
process.exit(0);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Claude Code Stop hook: the per-edit hooks catch violations file by file,
|
|
883
|
+
// but the agent can still end a turn with violations introduced through
|
|
884
|
+
// uncovered paths (git operations, scripts writing files, moved code). The
|
|
885
|
+
// Stop gate re-runs the whole mechanical check plus the wiring doctor and
|
|
886
|
+
// refuses to let the turn end while findings remain. `stop_hook_active`
|
|
887
|
+
// marks a turn that is already continuing because of this gate — allow it
|
|
888
|
+
// through then, so an agent that cannot satisfy the gate never loops forever.
|
|
889
|
+
function stopGate(payload) {
|
|
890
|
+
if (payload.stop_hook_active) process.exit(0);
|
|
891
|
+
const target = process.cwd();
|
|
892
|
+
|
|
893
|
+
const findings = [];
|
|
894
|
+
for (const dir of ['src', 'app', 'lib']) {
|
|
895
|
+
const abs = join(target, dir);
|
|
896
|
+
if (!existsSync(abs)) continue;
|
|
897
|
+
for (const file of walk(abs)) findings.push(...fileFindings(file, target));
|
|
898
|
+
}
|
|
899
|
+
if (existsSync(join(target, 'package.json'))) {
|
|
900
|
+
findings.push(...doctorFindings(target).map(({ id, message }) => `[${id}] ${message}`));
|
|
901
|
+
}
|
|
902
|
+
if (findings.length === 0) process.exit(0);
|
|
903
|
+
|
|
904
|
+
console.error(
|
|
905
|
+
formatReport(
|
|
906
|
+
'solid2-kit — React/Solid 1.x patterns are still in the project; fix them before finishing:',
|
|
907
|
+
findings,
|
|
908
|
+
),
|
|
909
|
+
);
|
|
910
|
+
process.exit(2);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function hook(agent) {
|
|
914
|
+
if (agent !== 'claude' && agent !== 'cursor') {
|
|
915
|
+
console.error('solid2-kit hook — expected agent: claude | cursor');
|
|
916
|
+
process.exit(2);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
let payload;
|
|
920
|
+
try {
|
|
921
|
+
payload = JSON.parse(readFileSync(0, 'utf8'));
|
|
922
|
+
} catch {
|
|
923
|
+
process.exit(0);
|
|
924
|
+
}
|
|
925
|
+
if (!payload || typeof payload !== 'object') process.exit(0);
|
|
926
|
+
|
|
927
|
+
const event = typeof payload.hook_event_name === 'string' ? payload.hook_event_name : '';
|
|
928
|
+
if (agent === 'claude' && event === 'Stop') stopGate(payload);
|
|
929
|
+
// Pre-execution events (PreToolUse / preToolUse / beforeShellExecution)
|
|
930
|
+
// only run the guard — never the content check (the file has not been
|
|
931
|
+
// written yet, so checking would block edits based on pre-edit state).
|
|
932
|
+
if (/^(?:pre|before)/i.test(event)) preToolGate(agent, payload);
|
|
933
|
+
|
|
934
|
+
// afterFileEdit-style payloads carry file_path at the top level; tool-use
|
|
935
|
+
// payloads carry the edited path(s) inside tool_input. Only write-shaped
|
|
936
|
+
// and shell-shaped tools are gated, so reads/searches never are.
|
|
937
|
+
const files = new Set();
|
|
938
|
+
if (typeof payload.file_path === 'string') collectSourcePaths(payload.file_path, files);
|
|
939
|
+
if (payload.tool_input !== undefined) {
|
|
940
|
+
const toolName = typeof payload.tool_name === 'string' ? payload.tool_name : '';
|
|
941
|
+
let toolInput = payload.tool_input;
|
|
942
|
+
if (typeof toolInput === 'string') {
|
|
943
|
+
try {
|
|
944
|
+
toolInput = JSON.parse(toolInput);
|
|
945
|
+
} catch {
|
|
946
|
+
toolInput = undefined;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
if (toolName === '' || WRITE_TOOL.test(toolName)) {
|
|
950
|
+
collectSourcePaths(toolInput, files);
|
|
951
|
+
} else if (SHELL_TOOL.test(toolName)) {
|
|
952
|
+
const cwd = typeof payload.cwd === 'string' ? payload.cwd : process.cwd();
|
|
953
|
+
collectCommandPaths(toolInput?.command, cwd, files);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
if (files.size === 0) process.exit(0);
|
|
957
|
+
|
|
958
|
+
const findings = [];
|
|
959
|
+
for (const file of files) {
|
|
960
|
+
try {
|
|
961
|
+
findings.push(...fileFindings(file, process.cwd()));
|
|
962
|
+
} catch {
|
|
963
|
+
// File disappeared between the edit and the hook — nothing to check.
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
if (findings.length === 0) process.exit(0);
|
|
967
|
+
|
|
968
|
+
const report = formatReport(
|
|
969
|
+
'solid2-kit check — React/Solid 1.x patterns in this edit; fix them now:',
|
|
970
|
+
findings,
|
|
971
|
+
);
|
|
972
|
+
if (agent === 'claude') {
|
|
973
|
+
console.error(report);
|
|
974
|
+
process.exit(2);
|
|
975
|
+
}
|
|
976
|
+
console.log(JSON.stringify({ additional_context: report }));
|
|
977
|
+
process.exit(0);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// --- doctor -----------------------------------------------------------------
|
|
981
|
+
// Project-config gate: `check` covers TSX sources, `doctor` covers the wiring
|
|
982
|
+
// around them — dependencies, tsconfig, and root config files — where agents
|
|
983
|
+
// also reach for React / Solid 1.x tooling (vite-plugin-solid,
|
|
984
|
+
// eslint-plugin-solid, jsxImportSource: "solid-js", ...).
|
|
985
|
+
|
|
986
|
+
const BANNED_DEPS = {
|
|
987
|
+
react: 'React does not belong in a Solid 2.0 project.',
|
|
988
|
+
'react-dom': 'React does not belong in a Solid 2.0 project.',
|
|
989
|
+
next: 'Next.js does not belong in a Solid 2.0 project.',
|
|
990
|
+
'vite-plugin-solid': 'Solid 1.x Vite plugin. Use @solidjs/vite-plugin.',
|
|
991
|
+
'eslint-plugin-solid':
|
|
992
|
+
'Built for Solid 1.x; its analyzer misreads Solid 2 idioms (two-phase createEffect, writable derivations, draft setters). Remove it — solid2-kit check carries the still-valid intents.',
|
|
993
|
+
'@solidjs/start': 'SolidStart 1.x. Solid 2 start mode lives in @solidjs/vite-plugin (start: true).',
|
|
994
|
+
'solid-start': 'SolidStart 0.x. Solid 2 start mode lives in @solidjs/vite-plugin (start: true).',
|
|
995
|
+
'solid-app-router': 'Pre-1.0 router. Use @solidjs/router 2 (createRouter({ routes })).',
|
|
996
|
+
vinxi: 'SolidStart 1.x toolchain. Solid 2 uses @solidjs/vite-plugin directly.',
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
const CONFIG_SOURCE = /\.(?:m|c)?[jt]s$/;
|
|
1000
|
+
|
|
1001
|
+
// Files installed by this kit that carry a version marker. When the kit
|
|
1002
|
+
// dependency is updated but `sync` is not re-run, the installed guidance
|
|
1003
|
+
// keeps teaching agents the older content — silent drift doctor can catch.
|
|
1004
|
+
const GUIDANCE_FILES = ['.cursor/rules/solid-2.mdc', 'CLAUDE.md', 'AGENTS.md'];
|
|
1005
|
+
|
|
1006
|
+
function doctorFindings(target) {
|
|
1007
|
+
const findings = [];
|
|
1008
|
+
const report = (id, message) => findings.push({ id, message });
|
|
1009
|
+
|
|
1010
|
+
const pkg = JSON.parse(readFileSync(join(target, 'package.json'), 'utf8'));
|
|
1011
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
1012
|
+
for (const [name, message] of Object.entries(BANNED_DEPS)) {
|
|
1013
|
+
if (name in deps) report(`dep-${name.replace(/[@/]/g, '')}`, `package.json depends on "${name}". ${message}`);
|
|
1014
|
+
}
|
|
1015
|
+
const solidRange = deps['solid-js'];
|
|
1016
|
+
if (typeof solidRange === 'string' && /^[\s^~=v]*[01]\./.test(solidRange)) {
|
|
1017
|
+
report('solid-js-version', `package.json pins solid-js "${solidRange}" — this kit teaches Solid 2.x; upgrade to ^2.`);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
for (const entry of readdirSync(target, { withFileTypes: true })) {
|
|
1021
|
+
if (!entry.isFile()) continue;
|
|
1022
|
+
const path = join(target, entry.name);
|
|
1023
|
+
|
|
1024
|
+
if (/^tsconfig.*\.json$/.test(entry.name)) {
|
|
1025
|
+
// tsconfig is JSONC; extract the two relevant keys with regexes.
|
|
1026
|
+
const content = readFileSync(path, 'utf8');
|
|
1027
|
+
const jsx = content.match(/"jsx"\s*:\s*"([^"]+)"/)?.[1];
|
|
1028
|
+
if (jsx && jsx !== 'preserve') {
|
|
1029
|
+
report('tsconfig-jsx', `${entry.name} sets "jsx": "${jsx}". Solid 2 compiles its own JSX: use "preserve".`);
|
|
1030
|
+
}
|
|
1031
|
+
const importSource = content.match(/"jsxImportSource"\s*:\s*"([^"]+)"/)?.[1];
|
|
1032
|
+
if (importSource && importSource !== '@solidjs/web') {
|
|
1033
|
+
report(
|
|
1034
|
+
'tsconfig-jsx-import-source',
|
|
1035
|
+
`${entry.name} sets "jsxImportSource": "${importSource}". Solid 2 JSX types live in "@solidjs/web".`,
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if (CONFIG_SOURCE.test(entry.name)) {
|
|
1042
|
+
const content = readFileSync(path, 'utf8');
|
|
1043
|
+
if (/['"]vite-plugin-solid['"]/.test(content)) {
|
|
1044
|
+
report('config-vite-plugin-solid', `${entry.name} references vite-plugin-solid (Solid 1.x). Import solid from "@solidjs/vite-plugin".`);
|
|
1045
|
+
}
|
|
1046
|
+
if (/jsxImportSource['"]?\s*[:=]\s*['"](?:solid-js|react)['"]/.test(content)) {
|
|
1047
|
+
report('config-jsx-import-source', `${entry.name} sets jsxImportSource to solid-js/react. Solid 2 uses "@solidjs/web".`);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (/^(?:\.eslintrc|eslint\.config\.)/.test(entry.name)) {
|
|
1052
|
+
const content = readFileSync(path, 'utf8');
|
|
1053
|
+
if (content.includes('eslint-plugin-solid')) {
|
|
1054
|
+
report('eslint-plugin-solid', `${entry.name} wires eslint-plugin-solid (Solid 1.x analyzer; false-positives on Solid 2 idioms). Remove it.`);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
for (const guidance of GUIDANCE_FILES) {
|
|
1060
|
+
const path = join(target, guidance);
|
|
1061
|
+
if (!existsSync(path)) continue;
|
|
1062
|
+
const installed = readFileSync(path, 'utf8').match(/solid2-agent-kit v(\d[\w.-]*)/)?.[1];
|
|
1063
|
+
if (installed && installed !== VERSION) {
|
|
1064
|
+
report(
|
|
1065
|
+
'stale-guidance',
|
|
1066
|
+
`${guidance} was installed by solid2-agent-kit v${installed} but v${VERSION} is running — run \`solid2-kit sync\` so agents read current guidance.`,
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
return findings;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function doctor() {
|
|
1075
|
+
const target = resolve(flagValue('--target', '.'));
|
|
1076
|
+
if (!existsSync(join(target, 'package.json'))) {
|
|
1077
|
+
console.error(`solid2-kit doctor — no package.json at ${target} (use --target)`);
|
|
1078
|
+
process.exit(2);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const findings = doctorFindings(target);
|
|
1082
|
+
for (const { id, message } of findings) console.error(`[${id}] ${message}`);
|
|
1083
|
+
|
|
1084
|
+
if (findings.length > 0) {
|
|
1085
|
+
console.error(`\nsolid2-kit doctor — ${findings.length} finding(s). Fix the project wiring above.`);
|
|
408
1086
|
process.exit(1);
|
|
409
1087
|
}
|
|
410
|
-
console.log(
|
|
1088
|
+
console.log('solid2-kit doctor — OK (dependencies, tsconfig, root configs, and installed guidance look like Solid 2).');
|
|
411
1089
|
}
|
|
412
1090
|
|
|
413
1091
|
// --- entry ------------------------------------------------------------------
|
|
@@ -420,15 +1098,23 @@ switch (command) {
|
|
|
420
1098
|
case 'check':
|
|
421
1099
|
check();
|
|
422
1100
|
break;
|
|
1101
|
+
case 'doctor':
|
|
1102
|
+
doctor();
|
|
1103
|
+
break;
|
|
1104
|
+
case 'hook':
|
|
1105
|
+
hook(args[1]);
|
|
1106
|
+
break;
|
|
423
1107
|
default:
|
|
424
1108
|
console.log(
|
|
425
1109
|
[
|
|
426
1110
|
`solid2-agent-kit v${VERSION}`,
|
|
427
1111
|
'',
|
|
428
1112
|
'Usage:',
|
|
429
|
-
' solid2-kit init [--cursor] [--claude] [--target <dir>] install/update guidance (default: both tools)',
|
|
430
|
-
' solid2-kit sync [--cursor] [--claude] [--target <dir>] alias of init (idempotent)',
|
|
431
|
-
' solid2-kit check [--dir <srcdir>] [--target <dir>]
|
|
1113
|
+
' solid2-kit init [--cursor] [--claude] [--no-hooks] [--target <dir>] install/update guidance + edit hooks (default: both tools)',
|
|
1114
|
+
' solid2-kit sync [--cursor] [--claude] [--no-hooks] [--target <dir>] alias of init (idempotent)',
|
|
1115
|
+
' solid2-kit check [--dir <srcdir>] [--target <dir>] [files...] mechanical React/Solid 1.x pattern gate (default dir: src)',
|
|
1116
|
+
' solid2-kit doctor [--target <dir>] project-wiring gate: deps, tsconfig, root configs',
|
|
1117
|
+
' solid2-kit hook (claude|cursor) edit-time gate for agent hooks (stdin: hook JSON payload)',
|
|
432
1118
|
].join('\n'),
|
|
433
1119
|
);
|
|
434
1120
|
process.exit(command ? 2 : 0);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Review the current change set for Solid 2.0 correctness. This project bans React patterns
|
|
2
|
+
and Solid 1.x APIs; the mechanical gates catch tokens, but several failure classes are
|
|
3
|
+
semantic and need your judgment.
|
|
4
|
+
|
|
5
|
+
Steps:
|
|
6
|
+
|
|
7
|
+
1. Run the gates and fix anything they report first:
|
|
8
|
+
- `node node_modules/solid2-agent-kit/bin/solid2-kit.mjs check`
|
|
9
|
+
- `node node_modules/solid2-agent-kit/bin/solid2-kit.mjs doctor`
|
|
10
|
+
2. Read the `solid-2` skill (`.cursor/skills/solid-2/SKILL.md` or
|
|
11
|
+
`.claude/skills/solid-2/SKILL.md`) and its final review checklist.
|
|
12
|
+
3. Collect the diff under review: staged/uncommitted changes if any, otherwise the branch
|
|
13
|
+
diff against the default branch.
|
|
14
|
+
4. Review every changed `.tsx`/`.jsx`/`.ts` file against the checklist, prioritizing what
|
|
15
|
+
regexes cannot see:
|
|
16
|
+
- reactive reads at component-body top level, or extracted into plain `const`s;
|
|
17
|
+
- async computations reading reactive inputs **after** the first `await`;
|
|
18
|
+
- effects that copy state instead of deriving it, or reading stores in the apply phase;
|
|
19
|
+
- `<For>` over server/refetched rows without a stable-id key function;
|
|
20
|
+
- `<Loading>`/`<Errored>` wrapping page chrome instead of the data slot, or async memos
|
|
21
|
+
passed as values (`user={user()}`) instead of accessors;
|
|
22
|
+
- store updates that rebuild objects/arrays instead of mutating the draft or reconciling;
|
|
23
|
+
- context values passed as snapshots instead of accessors/setters/stores;
|
|
24
|
+
- components with conditional/early returns on reactive values.
|
|
25
|
+
5. Verify any API you are not certain about against the official docs mirror
|
|
26
|
+
(`https://v2-rebuild--solid-docs-v2.netlify.app/llms.txt`); never trust Solid 1.x or
|
|
27
|
+
React memory.
|
|
28
|
+
|
|
29
|
+
Report each finding as `file:line — problem — Solid 2 replacement`, then fix them. Finish
|
|
30
|
+
by re-running the gates and the project's typecheck.
|
|
@@ -2,5 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
- All TSX in this project is Solid 2.0. Components run **once**; reactivity flows through signals/stores to JSX. React patterns (props destructuring, `className`, per-keystroke `onChange`, state-synced-by-effect, `.map()` lists) and Solid 1.x APIs (`createResource`, `onMount`, `solid-js/store`, `Suspense`, path setters) are bugs here.
|
|
4
4
|
- Before writing TSX, read the `solid-2` skill (`.cursor/skills/solid-2/SKILL.md` for Cursor, `.claude/skills/solid-2/SKILL.md` for Claude Code): patterns, decision tables, official doc URLs. Hard rules auto-attach from `.cursor/rules/solid-2.mdc` in Cursor and live in a managed block in `CLAUDE.md` for Claude Code.
|
|
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`. Do not install `eslint-plugin-solid` (built for Solid 1.x; misreads Solid 2 idioms).
|
|
6
|
-
-
|
|
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
|
+
- 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
|
+
- 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 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).
|
|
@@ -25,7 +25,13 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
|
|
|
25
25
|
(the function itself) into JSX and call it there.
|
|
26
26
|
3. Signals are read by **calling** them: `{count()}` in JSX, not `{count}`.
|
|
27
27
|
4. **Derived state is a function, not state + effect.** `const full = () => a() + b()`.
|
|
28
|
-
Never write "a signal plus an effect that syncs it".
|
|
28
|
+
Never write "a signal plus an effect that syncs it". That includes **reset**: to clear
|
|
29
|
+
a signal when another reactive value changes, use a writable derivation —
|
|
30
|
+
`createSignal(() => { source(); return initial; })` (re-runs when `source()` changes;
|
|
31
|
+
the setter still works) — never a `createEffect` whose apply calls the setter. The
|
|
32
|
+
derivation also moves the `source()` read to the read site, under its boundaries.
|
|
33
|
+
Mechanically enforced by `solid2-kit check` (effects whose apply only writes a local
|
|
34
|
+
signal). Use `createMemo` only for expensive
|
|
29
35
|
computations, multiple consumers, or an equality boundary. When several writes can land
|
|
30
36
|
before a flush, use the setter updater (`setCount((c) => c + 1)`); `setCount(count() + 1)`
|
|
31
37
|
reads the last *committed* value and drops the other staged writes. To store a *function*
|
|
@@ -165,7 +171,9 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
|
|
|
165
171
|
cross an async gap default to `action` + `createOptimistic`/`createOptimisticStore` —
|
|
166
172
|
except reactive clients (e.g. Convex) whose subscriptions already push authoritative
|
|
167
173
|
state after mutations. Compiler: `"jsxImportSource": "@solidjs/web"` (not `"solid-js"`);
|
|
168
|
-
Vite plugin is `@solidjs/vite-plugin` (not `vite-plugin-solid`)
|
|
174
|
+
Vite plugin is `@solidjs/vite-plugin` (not `vite-plugin-solid`) — run `solid2-kit doctor`
|
|
175
|
+
after touching `package.json` / tsconfig / root configs; it fails on React and Solid 1.x
|
|
176
|
+
wiring. Server vs browser:
|
|
169
177
|
`isServer` / `isDev` from `@solidjs/web`, or `clientOnly(() => import("./Widget"))` for
|
|
170
178
|
browser-only components — never `typeof window` as the SSR boundary. Default
|
|
171
179
|
`clientOnly` starts loading at declaration; `{ lazy: true }` defers until first
|
|
@@ -173,7 +181,12 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
|
|
|
173
181
|
visible content — `clientOnly` is for components that must never run on the server.
|
|
174
182
|
Pass a **function** to `render` / `hydrate` / `renderToString` / `renderToStream`
|
|
175
183
|
(`render(() => <App />, root)`), never `render(<App />, root)`. `hydrate` when the
|
|
176
|
-
container already holds SSR HTML; `render` for an empty mount.
|
|
184
|
+
container already holds SSR HTML; `render` for an empty mount. Client mode (the
|
|
185
|
+
`@solidjs/vite-plugin` default) mounts with `render()` into an empty body — there is
|
|
186
|
+
**no server HTML and no hydration mismatch**, so React's mismatch defenses
|
|
187
|
+
("server-safe" initial values adopted after mount, two-pass rendering) are unnecessary
|
|
188
|
+
and can clobber persisted state; read `localStorage` / `matchMedia` directly when
|
|
189
|
+
creating the signal. `renderToString`
|
|
177
190
|
emits `<Loading>` fallbacks; async or lazy-route trees need
|
|
178
191
|
`await renderToStream(() => <App />)` (one consumer: `pipe` / `pipeTo` / `readable`).
|
|
179
192
|
When the app owns the document, emit `<HydrationScript />` once before app markup;
|
|
@@ -254,6 +267,7 @@ directory (default `src/`).
|
|
|
254
267
|
| Never write (Solid 1.x) | Write instead (Solid 2) |
|
|
255
268
|
|---|---|
|
|
256
269
|
| `import ... from "solid-js/store"` or `"solid-js/web"` | stores/`merge`/`omit` from `"solid-js"`; `render`/`hydrate`/`Portal`/`Dynamic` from `"@solidjs/web"` |
|
|
270
|
+
| `import type { JSX } from "solid-js"` / `JSX.Element` as the children type | `solid-js` exports no `JSX` namespace: children/returns are `Element` from `"solid-js"`; DOM-specific `JSX` types (`JSX.IntrinsicElements`, `JSX.CSSProperties`) from `"@solidjs/web"` |
|
|
257
271
|
| `createResource` | async `createMemo` + `<Loading>`/`<Errored>`; `refresh()`, `latest()`, `isPending()` |
|
|
258
272
|
| `createEffect(fn)` (one arg), `on(...)` | `createEffect(compute, apply)`; deps belong in `compute` |
|
|
259
273
|
| `onMount` | `onSettled` (return cleanup from its callback) |
|
|
@@ -55,6 +55,7 @@ Most React `useEffect` code should NOT become `createEffect`:
|
|
|
55
55
|
| Fetch data in an effect, copy into state | `createMemo(async () => ...)` + `<Loading>` |
|
|
56
56
|
| React to a user interaction | do the work in the event handler or an `action` |
|
|
57
57
|
| Sync one state into another | delete the second state; derive it |
|
|
58
|
+
| Reset/clear state when another reactive value changes | writable derivation: `createSignal(() => { source(); return initial; })` — not an effect calling the setter |
|
|
58
59
|
| Push a settled reactive value into a non-Solid system (DOM API, third-party widget, analytics, subscription) | `createEffect(compute, apply)` — this is the only real use |
|
|
59
60
|
| One-time setup after mount (`onMount`) | `onSettled(() => { ...; return cleanup })` |
|
|
60
61
|
| Measure DOM / observe size after paint | ref **directive factory** + `onSettled` (writes a *new* input; not an effect that copies state) |
|
|
@@ -222,6 +223,28 @@ Reads inside `apply` do not track. Extract every needed reactive value in `compu
|
|
|
222
223
|
and reading `user.name` there *runs once* and never retriggers. Compute-phase errors can be
|
|
223
224
|
intercepted with the bundle form `createEffect(compute, { effect, error })`.
|
|
224
225
|
|
|
226
|
+
A two-phase effect whose apply only calls a local signal setter is "state + effect" in
|
|
227
|
+
disguise — formally legal Solid 2, still rule 4 (`solid2-kit check` flags it). The reset
|
|
228
|
+
form of a writable derivation replaces it, and moves the source read to the read site
|
|
229
|
+
(under that site's boundaries) instead of an effect compute:
|
|
230
|
+
|
|
231
|
+
```tsx
|
|
232
|
+
// WRONG — signal synced by an effect; organizer() errors bypass the read site's <Errored>
|
|
233
|
+
const [signInError, setSignInError] = createSignal<string | null>(null);
|
|
234
|
+
createEffect(
|
|
235
|
+
() => organizer(),
|
|
236
|
+
(id) => {
|
|
237
|
+
if (id !== null) setSignInError(null);
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
// CORRECT — writable derivation: resets when organizer() changes; the setter still works
|
|
242
|
+
const [signInError, setSignInError] = createSignal<string | null>(() => {
|
|
243
|
+
organizer();
|
|
244
|
+
return null;
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
225
248
|
### Async data
|
|
226
249
|
|
|
227
250
|
```tsx
|
|
@@ -529,6 +552,24 @@ test('increments on click', () => {
|
|
|
529
552
|
`jsxImportSource` for tests and app code is `"@solidjs/web"`, not `"solid-js"`.
|
|
530
553
|
Vite plugin is `@solidjs/vite-plugin`, not `vite-plugin-solid`.
|
|
531
554
|
|
|
555
|
+
To turn those dev diagnostics into a hard gate, patch `console.warn` in the test setup
|
|
556
|
+
file to rethrow unexpected warnings — then any top-level reactive read or unowned write
|
|
557
|
+
an agent sneaks in fails the suite instead of scrolling by.
|
|
558
|
+
|
|
559
|
+
## Client mode: no server HTML, no hydration reflexes
|
|
560
|
+
|
|
561
|
+
The `@solidjs/vite-plugin` default (client mode, the `bare` tier) serves an empty-body
|
|
562
|
+
shell and mounts with `render()`. Nothing is server-rendered and nothing hydrates, so
|
|
563
|
+
**React's hydration-mismatch playbook does not transfer**: do not start signals with a
|
|
564
|
+
"server-safe" initial value and adopt `localStorage` / `matchMedia` after mount, do not
|
|
565
|
+
two-pass render, and there is no `suppressHydrationWarning`. Read client sources directly
|
|
566
|
+
when creating state — `createSignal(storedPreference())` is correct in client mode. A
|
|
567
|
+
deferred "safe" initial value is worse than unnecessary: if anything persists the value
|
|
568
|
+
(an effect writing `localStorage`), the placeholder **overwrites the user's stored
|
|
569
|
+
preference** before the real value is adopted. Mismatch only becomes a concept in
|
|
570
|
+
start/SSR mode, where `hydrate()` mounts onto server HTML — and the Solid answer there is
|
|
571
|
+
`isServer` / `clientOnly`, still not React-style deferred adoption.
|
|
572
|
+
|
|
532
573
|
## App stack (only if the project has these packages)
|
|
533
574
|
|
|
534
575
|
This kit's always-applied rules cover `solid-js` + `@solidjs/web` TSX. Official
|
|
@@ -543,8 +584,12 @@ Solid Router 0.x/1.x stand-ins.** Patterns below; fetch the matching pages from
|
|
|
543
584
|
{ "compilerOptions": { "jsx": "preserve", "jsxImportSource": "@solidjs/web" } }
|
|
544
585
|
```
|
|
545
586
|
|
|
546
|
-
|
|
547
|
-
`
|
|
587
|
+
`solid-js` does **not** export a `JSX` namespace — `import type { JSX } from "solid-js"`
|
|
588
|
+
is a type error (TS2305), and `JSX.Element` as the children type is a React reflex.
|
|
589
|
+
Children/return types are the renderer-neutral `Element` from `solid-js`
|
|
590
|
+
(`children?: Element`), alongside `Component` / `ParentProps` / `Accessor` / `Setter`.
|
|
591
|
+
DOM-specific `JSX` types (`JSX.IntrinsicElements`, `JSX.CSSProperties`) and
|
|
592
|
+
`ComponentProps` come from `@solidjs/web`. Vite: `import solid from "@solidjs/vite-plugin"`
|
|
548
593
|
with `start: true` / `ssr: true` / `serverFunctions: true` only when the
|
|
549
594
|
project already uses start mode. Tiers are `bare` → `basic` (router) →
|
|
550
595
|
`fullstack` (SSR + server functions); do not jump a tier without cause.
|
|
@@ -790,6 +835,8 @@ how to reuse. Prefer the form on the right.
|
|
|
790
835
|
| `createMemo(() => { void save(); return x(); })` / an effect that calls an action | invoke actions from handlers, not from memos or effects |
|
|
791
836
|
| `createSignal(props.value)` / `createStore(props.items)` | `createSignal(() => props.value)` / `createStore(() => props.items, fallback)` — a bare `props.x` at setup is a snapshot |
|
|
792
837
|
| `createRenderEffect` / `createTrackedEffect` as the default effect | two-phase `createEffect(compute, apply)` |
|
|
838
|
+
| `createEffect(() => source(), () => setError(null))` to reset a signal | `createSignal(() => { source(); return null; })` — the writable derivation resets on change; a sole-setter apply is rule 4 |
|
|
839
|
+
| a "server-safe" initial value adopted after mount (React hydration-mismatch fix) | client mode mounts with `render()` into an empty body — no hydration, no mismatch; read `localStorage` / `matchMedia` directly at signal creation |
|
|
793
840
|
|
|
794
841
|
```tsx
|
|
795
842
|
// WRONG — post-await read never subscribes; production can hang pending
|
|
@@ -909,7 +956,9 @@ always-applied rules installed alongside this skill.
|
|
|
909
956
|
- [ ] No props destructuring; no `props.` at component-body top level.
|
|
910
957
|
- [ ] Every reactive read (`signal()`, `props.x`, `store.x`) sits in JSX, a memo, an effect
|
|
911
958
|
compute, or a boundary — not the component body.
|
|
912
|
-
- [ ] No signal-synced-by-effect; derived values are functions or memos.
|
|
959
|
+
- [ ] No signal-synced-by-effect; derived values are functions or memos. Reset-on-change
|
|
960
|
+
is a writable derivation (`createSignal(() => { source(); return initial; })`), not
|
|
961
|
+
an effect calling the setter.
|
|
913
962
|
- [ ] Event handlers wrap setters (`onClick={() => setX(...)}`), use `currentTarget`,
|
|
914
963
|
and `onInput` for keystrokes. `innerHTML` is sanitized and not mixed with children.
|
|
915
964
|
- [ ] Async memos are passed as accessors (`user={user}`), not `user={user()}`, so
|
|
@@ -929,6 +978,8 @@ always-applied rules installed alongside this skill.
|
|
|
929
978
|
- [ ] Rest props via `omit`, not `{ ...props }`. Conditional `class` via objects, not string concat.
|
|
930
979
|
- [ ] Input filters write back to the DOM on reject (inputs do not rewind).
|
|
931
980
|
- [ ] No Solid 1.x imports or APIs (`solid-js/store`, `createResource`, `onMount`, `Suspense`, ...).
|
|
981
|
+
No `JSX` imported from `solid-js` — children/return types are `Element` from
|
|
982
|
+
`solid-js`; DOM `JSX` types come from `@solidjs/web`.
|
|
932
983
|
- [ ] Inspect children with `children()`; code-split with `lazy` + `<Loading>`; reactive
|
|
933
984
|
component choice with `dynamic()`. No `React.lazy`, no effects inside ref callbacks.
|
|
934
985
|
- [ ] Browser-only code uses `isServer` / `clientOnly`, not `typeof window`.
|
|
@@ -944,4 +995,5 @@ always-applied rules installed alongside this skill.
|
|
|
944
995
|
- [ ] `jsxImportSource` is `@solidjs/web`; Vite plugin is `@solidjs/vite-plugin`.
|
|
945
996
|
No `@solidjs/start`, `vinxi`, `"use client"`, or Next.js imports.
|
|
946
997
|
- [ ] Single return per component; no early returns on reactive conditions.
|
|
947
|
-
- [ ] `solid2-kit check` and the project's typecheck pass
|
|
998
|
+
- [ ] `solid2-kit check` and the project's typecheck pass; `solid2-kit doctor` too if
|
|
999
|
+
`package.json`, tsconfig, or root config files changed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solid2-agent-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Teach AI coding agents (Cursor, Claude Code) to write correct Solid 2.0 instead of React or Solid 1.x — always-applied rules, an agent skill, and a mechanical pattern gate.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
],
|
|
17
17
|
"scripts": {
|
|
18
18
|
"check:drift": "node scripts/check-docs-drift.mjs",
|
|
19
|
-
"test": "node tests/check.test.mjs"
|
|
19
|
+
"test": "node tests/check.test.mjs && node tests/hook.test.mjs && node tests/doctor.test.mjs && node tests/init.test.mjs"
|
|
20
20
|
},
|
|
21
21
|
"engines": {
|
|
22
22
|
"node": ">=18"
|