solid2-agent-kit 0.4.4 → 0.7.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 +634 -28
- package/files/commands/solid2-review.md +30 -0
- package/files/shared/agents-section.md +4 -2
- package/files/shared/rules-body.md +36 -12
- package/files/skills/solid-2/SKILL.md +70 -14
- package/files/skills/solid-2/references/official-docs.md +6 -0
- 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), and `<Dynamic>` (use `dynamic()`) |
|
|
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,8 +252,24 @@ 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 ------------------------------------------------------------------
|
|
@@ -351,10 +485,75 @@ const CHECKS = [
|
|
|
351
485
|
message:
|
|
352
486
|
'Readiness branch on a zero-arg call. Read async values under <Loading> (first load) / isPending() (refetch) and let errors reach <Errored>. For non-reactive utils, assign to a variable and test that.',
|
|
353
487
|
},
|
|
488
|
+
{
|
|
489
|
+
// `isPending(user())` / `latest(data())` evaluate the read before the
|
|
490
|
+
// helper runs. `isPending(user)` and `isPending(() => user())` do not match.
|
|
491
|
+
id: 'pending-accessor-call',
|
|
492
|
+
pattern: /(?<![.\w])(?:isPending|latest)\(\s*[A-Za-z_$][\w$]*\(\s*\)/g,
|
|
493
|
+
message:
|
|
494
|
+
'Pass the accessor to isPending/latest, not the call: isPending(user), not isPending(user()). Calling it first evaluates the read before the helper runs.',
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
id: 'dynamic-jsx',
|
|
498
|
+
pattern: /<Dynamic[\s/>]/g,
|
|
499
|
+
message:
|
|
500
|
+
'`<Dynamic>` is a JSX convenience wrapper. Application code should use dynamic(() => ...) from "@solidjs/web" so the component identity stays stable.',
|
|
501
|
+
},
|
|
354
502
|
];
|
|
355
503
|
|
|
356
|
-
// .ts / .tsx sources, excluding .d.ts declaration files.
|
|
357
|
-
const SOURCE_FILE = /(?<!\.d)\.tsx
|
|
504
|
+
// .ts / .tsx / .jsx sources, excluding .d.ts declaration files.
|
|
505
|
+
const SOURCE_FILE = /(?<!\.d)\.(?:tsx?|jsx)$/;
|
|
506
|
+
|
|
507
|
+
// Blank comment interiors (preserving newlines and offsets) so banned tokens
|
|
508
|
+
// mentioned in comments ("migrated off createResource") do not trip the gate.
|
|
509
|
+
// String and template contents are preserved — some checks match string
|
|
510
|
+
// literals ("use client"). This is a heuristic scanner, not a JS lexer: a
|
|
511
|
+
// regex literal containing escaped slashes or `//` in JSX text can blank the
|
|
512
|
+
// remainder of that one line (false negatives only, never false positives).
|
|
513
|
+
function stripComments(content) {
|
|
514
|
+
let out = '';
|
|
515
|
+
let state = 'code';
|
|
516
|
+
for (let i = 0; i < content.length; i += 1) {
|
|
517
|
+
const ch = content[i];
|
|
518
|
+
const next = content[i + 1];
|
|
519
|
+
if (state === 'code') {
|
|
520
|
+
if (ch === '/' && (next === '/' || next === '*')) {
|
|
521
|
+
state = next === '/' ? 'line' : 'block';
|
|
522
|
+
out += ' ';
|
|
523
|
+
i += 1;
|
|
524
|
+
} else {
|
|
525
|
+
if (ch === "'") state = 'single';
|
|
526
|
+
else if (ch === '"') state = 'double';
|
|
527
|
+
else if (ch === '`') state = 'template';
|
|
528
|
+
out += ch;
|
|
529
|
+
}
|
|
530
|
+
} else if (state === 'line') {
|
|
531
|
+
if (ch === '\n') state = 'code';
|
|
532
|
+
out += ch === '\n' ? ch : ' ';
|
|
533
|
+
} else if (state === 'block') {
|
|
534
|
+
if (ch === '*' && next === '/') {
|
|
535
|
+
state = 'code';
|
|
536
|
+
out += ' ';
|
|
537
|
+
i += 1;
|
|
538
|
+
} else {
|
|
539
|
+
out += ch === '\n' ? ch : ' ';
|
|
540
|
+
}
|
|
541
|
+
} else {
|
|
542
|
+
out += ch;
|
|
543
|
+
if (ch === '\\') {
|
|
544
|
+
out += next ?? '';
|
|
545
|
+
i += 1;
|
|
546
|
+
} else if (
|
|
547
|
+
(state === 'single' && ch === "'") ||
|
|
548
|
+
(state === 'double' && ch === '"') ||
|
|
549
|
+
(state === 'template' && ch === '`')
|
|
550
|
+
) {
|
|
551
|
+
state = 'code';
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return out;
|
|
556
|
+
}
|
|
358
557
|
|
|
359
558
|
function* walk(dir) {
|
|
360
559
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -364,36 +563,435 @@ function* walk(dir) {
|
|
|
364
563
|
}
|
|
365
564
|
}
|
|
366
565
|
|
|
566
|
+
// One formatted finding per pattern match: "path:line [id] message\n > source".
|
|
567
|
+
// Patterns run against comment-stripped content (same offsets); the quoted
|
|
568
|
+
// source line comes from the original file.
|
|
569
|
+
function fileFindings(file, relativeTo) {
|
|
570
|
+
const raw = readFileSync(file, 'utf8');
|
|
571
|
+
const content = stripComments(raw);
|
|
572
|
+
const lines = raw.split('\n');
|
|
573
|
+
const findings = [];
|
|
574
|
+
for (const rule of CHECKS) {
|
|
575
|
+
for (const match of content.matchAll(rule.pattern)) {
|
|
576
|
+
const lineNumber = content.slice(0, match.index).split('\n').length;
|
|
577
|
+
findings.push(
|
|
578
|
+
`${relative(relativeTo, file) || file}:${lineNumber} [${rule.id}] ${rule.message}\n > ${lines[lineNumber - 1].trim()}`,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return findings;
|
|
583
|
+
}
|
|
584
|
+
|
|
367
585
|
function check() {
|
|
368
586
|
const target = resolve(flagValue('--target', '.'));
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
587
|
+
const fileArgs = positionalArgs();
|
|
588
|
+
|
|
589
|
+
let files;
|
|
590
|
+
if (fileArgs.length > 0) {
|
|
591
|
+
// Explicit file mode (used by agent hooks): check only the named sources.
|
|
592
|
+
files = fileArgs.map((file) => resolve(target, file)).filter((file) => SOURCE_FILE.test(file));
|
|
593
|
+
} else {
|
|
594
|
+
const srcDir = resolve(target, flagValue('--dir', 'src'));
|
|
595
|
+
if (!existsSync(srcDir)) {
|
|
596
|
+
console.error(`solid2-kit check — source directory not found: ${srcDir} (use --dir)`);
|
|
597
|
+
process.exit(2);
|
|
598
|
+
}
|
|
599
|
+
files = [...walk(srcDir)];
|
|
373
600
|
}
|
|
374
601
|
|
|
375
602
|
let findings = 0;
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
603
|
+
for (const file of files) {
|
|
604
|
+
for (const finding of fileFindings(file, target)) {
|
|
605
|
+
findings += 1;
|
|
606
|
+
console.error(finding);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (findings > 0) {
|
|
611
|
+
console.error(`\nsolid2-kit check — ${findings} finding(s) in ${files.length} file(s).`);
|
|
612
|
+
process.exit(1);
|
|
613
|
+
}
|
|
614
|
+
console.log(`solid2-kit check — OK (${files.length} files scanned).`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// --- hook -------------------------------------------------------------------
|
|
618
|
+
// Edit-time gate wired by `init` into the agents' hook systems. Reads the
|
|
619
|
+
// hook JSON payload from stdin, runs the mechanical checks on the edited
|
|
620
|
+
// source files, and feeds findings back through each agent's channel:
|
|
621
|
+
// claude — Claude Code PostToolUse: findings on stderr + exit 2 (Claude
|
|
622
|
+
// sees the stderr and fixes; other exit codes are ignored). Also
|
|
623
|
+
// handles the Stop event as a whole-project final gate: exit 2
|
|
624
|
+
// blocks the agent from ending its turn with violations in place.
|
|
625
|
+
// cursor — Cursor postToolUse: findings as {"additional_context"} JSON on
|
|
626
|
+
// stdout (injected into the conversation after the tool result).
|
|
627
|
+
// Both agents also edit files through shell commands (sed, heredocs, mv,
|
|
628
|
+
// codemods), which never hit the Edit/Write tools — so shell-shaped tool
|
|
629
|
+
// payloads are scanned for source paths mentioned in the command and those
|
|
630
|
+
// files are checked after the command ran.
|
|
631
|
+
// Hooks must never break the agent loop: malformed payloads exit 0 silently.
|
|
632
|
+
|
|
633
|
+
const WRITE_TOOL = /write|edit|replace|apply|patch/i;
|
|
634
|
+
const SHELL_TOOL = /shell|bash|terminal|command|exec/i;
|
|
635
|
+
const MAX_REPORTED = 40;
|
|
636
|
+
|
|
637
|
+
function formatReport(header, findings) {
|
|
638
|
+
const shown = findings.slice(0, MAX_REPORTED);
|
|
639
|
+
const more = findings.length - shown.length;
|
|
640
|
+
return `${header}\n\n${shown.join('\n')}${more > 0 ? `\n…and ${more} more finding(s).` : ''}`;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function isCheckableSource(path) {
|
|
644
|
+
return SOURCE_FILE.test(path) && !path.includes('\n') && !path.includes('node_modules');
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function collectSourcePaths(value, found = new Set()) {
|
|
648
|
+
if (typeof value === 'string') {
|
|
649
|
+
if (isCheckableSource(value) && existsSync(value)) found.add(value);
|
|
650
|
+
} else if (Array.isArray(value)) {
|
|
651
|
+
for (const item of value) collectSourcePaths(item, found);
|
|
652
|
+
} else if (value && typeof value === 'object') {
|
|
653
|
+
for (const item of Object.values(value)) collectSourcePaths(item, found);
|
|
654
|
+
}
|
|
655
|
+
return found;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Path-shaped tokens ending in a source extension, resolved against the
|
|
659
|
+
// command's cwd. Existence filtering keeps false positives at zero.
|
|
660
|
+
function collectCommandPaths(command, cwd, found = new Set()) {
|
|
661
|
+
if (typeof command !== 'string') return found;
|
|
662
|
+
for (const match of command.matchAll(/[^\s'"`;|&()<>=]+\.(?:tsx?|jsx)\b/g)) {
|
|
663
|
+
const token = match[0];
|
|
664
|
+
if (!isCheckableSource(token)) continue;
|
|
665
|
+
const path = resolve(cwd, token);
|
|
666
|
+
if (existsSync(path)) found.add(path);
|
|
667
|
+
}
|
|
668
|
+
return found;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// --- pre-execution guard (self-defense + proactive denial) -------------------
|
|
672
|
+
// Agents that keep getting blocked by a gate tend to conclude the gate is
|
|
673
|
+
// wrong and edit or delete it. Pre-execution hooks (Claude PreToolUse,
|
|
674
|
+
// Cursor preToolUse / beforeShellExecution) can deny a tool call before it
|
|
675
|
+
// runs, so this guard refuses:
|
|
676
|
+
// - edits/deletes of kit-owned guardrail files (rules, skill, commands,
|
|
677
|
+
// the kit itself under node_modules),
|
|
678
|
+
// - edits that would remove the kit's markers from hook configs or the
|
|
679
|
+
// AGENTS.md / CLAUDE.md managed blocks,
|
|
680
|
+
// - shell rewrites of those files (sed/rm/redirects — uninspectable),
|
|
681
|
+
// - installing dependencies doctor would flag anyway (react,
|
|
682
|
+
// vite-plugin-solid, eslint-plugin-solid, SolidStart, ...).
|
|
683
|
+
// Everything else is allowed; this gate never inspects file *content* being
|
|
684
|
+
// written (that is the post-edit hook's job).
|
|
685
|
+
|
|
686
|
+
const GUARDRAIL_PATH =
|
|
687
|
+
/\.cursor\/rules\/solid-2\.mdc|(?:\.cursor|\.claude)\/skills\/solid-2|(?:\.cursor|\.claude)\/commands\/solid2-review\.md|node_modules\/solid2-agent-kit\//;
|
|
688
|
+
const MARKER_FILE = /(?:\.claude\/settings\.json|\.cursor\/hooks\.json|AGENTS\.md|CLAUDE\.md)$/;
|
|
689
|
+
const KIT_MARKER = 'solid2-agent-kit';
|
|
690
|
+
const SHELL_WRITE = /\b(?:rm|mv|cp|sed|tee|truncate|unlink|chmod|perl|python[\d.]*)\b|>>?/;
|
|
691
|
+
const INSTALL_CMD = /\b(?:npm|pnpm|yarn|bun)\s+(?:-\S+\s+)*(?:install|add|i)\b/;
|
|
692
|
+
|
|
693
|
+
const TAMPER_MESSAGE =
|
|
694
|
+
'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.';
|
|
695
|
+
|
|
696
|
+
function bannedInstall(command) {
|
|
697
|
+
if (!INSTALL_CMD.test(command)) return null;
|
|
698
|
+
for (const raw of command.split(/\s+/)) {
|
|
699
|
+
const token = raw.replace(/^['"]|['"]$/g, '');
|
|
700
|
+
const name = token.replace(/(?!^)@.*$/, '');
|
|
701
|
+
if (name in BANNED_DEPS) return name;
|
|
702
|
+
}
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function preToolGate(agent, payload) {
|
|
707
|
+
const deny = (agentMessage) => {
|
|
708
|
+
if (agent === 'claude') {
|
|
709
|
+
console.error(agentMessage);
|
|
710
|
+
process.exit(2);
|
|
711
|
+
}
|
|
712
|
+
console.log(
|
|
713
|
+
JSON.stringify({
|
|
714
|
+
permission: 'deny',
|
|
715
|
+
agent_message: agentMessage,
|
|
716
|
+
user_message: 'solid2-agent-kit guardrail blocked this action.',
|
|
717
|
+
}),
|
|
718
|
+
);
|
|
719
|
+
process.exit(0);
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
let toolInput = payload.tool_input;
|
|
723
|
+
if (typeof toolInput === 'string') {
|
|
724
|
+
try {
|
|
725
|
+
toolInput = JSON.parse(toolInput);
|
|
726
|
+
} catch {
|
|
727
|
+
toolInput = undefined;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const toolName = typeof payload.tool_name === 'string' ? payload.tool_name : '';
|
|
731
|
+
|
|
732
|
+
// Shell commands: beforeShellExecution carries `command` at the top level,
|
|
733
|
+
// PreToolUse(Bash) inside tool_input.
|
|
734
|
+
const command =
|
|
735
|
+
typeof payload.command === 'string'
|
|
736
|
+
? payload.command
|
|
737
|
+
: SHELL_TOOL.test(toolName) && typeof toolInput?.command === 'string'
|
|
738
|
+
? toolInput.command
|
|
739
|
+
: null;
|
|
740
|
+
if (command !== null) {
|
|
741
|
+
const banned = bannedInstall(command);
|
|
742
|
+
if (banned) {
|
|
743
|
+
deny(
|
|
744
|
+
`solid2-agent-kit guardrail: do not install "${banned}". ${BANNED_DEPS[banned]} (\`solid2-kit doctor\` fails on it.)`,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
if (SHELL_WRITE.test(command)) {
|
|
748
|
+
if (GUARDRAIL_PATH.test(command)) deny(TAMPER_MESSAGE);
|
|
749
|
+
if (/(?:\.claude\/settings\.json|\.cursor\/hooks\.json)/.test(command)) {
|
|
750
|
+
deny(
|
|
751
|
+
'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.',
|
|
387
752
|
);
|
|
388
753
|
}
|
|
389
754
|
}
|
|
755
|
+
process.exit(0);
|
|
390
756
|
}
|
|
391
757
|
|
|
392
|
-
if (
|
|
393
|
-
|
|
758
|
+
if (!WRITE_TOOL.test(toolName)) process.exit(0);
|
|
759
|
+
const filePath = typeof toolInput?.file_path === 'string' ? toolInput.file_path : toolInput?.path;
|
|
760
|
+
if (typeof filePath !== 'string') process.exit(0);
|
|
761
|
+
|
|
762
|
+
if (GUARDRAIL_PATH.test(filePath)) deny(TAMPER_MESSAGE);
|
|
763
|
+
|
|
764
|
+
if (MARKER_FILE.test(filePath)) {
|
|
765
|
+
// Editing these files is fine (users add their own hooks/sections through
|
|
766
|
+
// agents) as long as the kit's entries/managed blocks survive the edit.
|
|
767
|
+
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.`;
|
|
768
|
+
if (typeof toolInput.content === 'string') {
|
|
769
|
+
let existing = '';
|
|
770
|
+
try {
|
|
771
|
+
existing = readFileSync(resolve(typeof payload.cwd === 'string' ? payload.cwd : process.cwd(), filePath), 'utf8');
|
|
772
|
+
} catch {
|
|
773
|
+
// New file — nothing to preserve.
|
|
774
|
+
}
|
|
775
|
+
if (existing.includes(KIT_MARKER) && !toolInput.content.includes(KIT_MARKER)) deny(removalMessage);
|
|
776
|
+
} else if (
|
|
777
|
+
typeof toolInput.old_string === 'string' &&
|
|
778
|
+
toolInput.old_string.includes(KIT_MARKER) &&
|
|
779
|
+
!String(toolInput.new_string ?? '').includes(KIT_MARKER)
|
|
780
|
+
) {
|
|
781
|
+
deny(removalMessage);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
process.exit(0);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Claude Code Stop hook: the per-edit hooks catch violations file by file,
|
|
789
|
+
// but the agent can still end a turn with violations introduced through
|
|
790
|
+
// uncovered paths (git operations, scripts writing files, moved code). The
|
|
791
|
+
// Stop gate re-runs the whole mechanical check plus the wiring doctor and
|
|
792
|
+
// refuses to let the turn end while findings remain. `stop_hook_active`
|
|
793
|
+
// marks a turn that is already continuing because of this gate — allow it
|
|
794
|
+
// through then, so an agent that cannot satisfy the gate never loops forever.
|
|
795
|
+
function stopGate(payload) {
|
|
796
|
+
if (payload.stop_hook_active) process.exit(0);
|
|
797
|
+
const target = process.cwd();
|
|
798
|
+
|
|
799
|
+
const findings = [];
|
|
800
|
+
for (const dir of ['src', 'app', 'lib']) {
|
|
801
|
+
const abs = join(target, dir);
|
|
802
|
+
if (!existsSync(abs)) continue;
|
|
803
|
+
for (const file of walk(abs)) findings.push(...fileFindings(file, target));
|
|
804
|
+
}
|
|
805
|
+
if (existsSync(join(target, 'package.json'))) {
|
|
806
|
+
findings.push(...doctorFindings(target).map(({ id, message }) => `[${id}] ${message}`));
|
|
807
|
+
}
|
|
808
|
+
if (findings.length === 0) process.exit(0);
|
|
809
|
+
|
|
810
|
+
console.error(
|
|
811
|
+
formatReport(
|
|
812
|
+
'solid2-kit — React/Solid 1.x patterns are still in the project; fix them before finishing:',
|
|
813
|
+
findings,
|
|
814
|
+
),
|
|
815
|
+
);
|
|
816
|
+
process.exit(2);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function hook(agent) {
|
|
820
|
+
if (agent !== 'claude' && agent !== 'cursor') {
|
|
821
|
+
console.error('solid2-kit hook — expected agent: claude | cursor');
|
|
822
|
+
process.exit(2);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
let payload;
|
|
826
|
+
try {
|
|
827
|
+
payload = JSON.parse(readFileSync(0, 'utf8'));
|
|
828
|
+
} catch {
|
|
829
|
+
process.exit(0);
|
|
830
|
+
}
|
|
831
|
+
if (!payload || typeof payload !== 'object') process.exit(0);
|
|
832
|
+
|
|
833
|
+
const event = typeof payload.hook_event_name === 'string' ? payload.hook_event_name : '';
|
|
834
|
+
if (agent === 'claude' && event === 'Stop') stopGate(payload);
|
|
835
|
+
// Pre-execution events (PreToolUse / preToolUse / beforeShellExecution)
|
|
836
|
+
// only run the guard — never the content check (the file has not been
|
|
837
|
+
// written yet, so checking would block edits based on pre-edit state).
|
|
838
|
+
if (/^(?:pre|before)/i.test(event)) preToolGate(agent, payload);
|
|
839
|
+
|
|
840
|
+
// afterFileEdit-style payloads carry file_path at the top level; tool-use
|
|
841
|
+
// payloads carry the edited path(s) inside tool_input. Only write-shaped
|
|
842
|
+
// and shell-shaped tools are gated, so reads/searches never are.
|
|
843
|
+
const files = new Set();
|
|
844
|
+
if (typeof payload.file_path === 'string') collectSourcePaths(payload.file_path, files);
|
|
845
|
+
if (payload.tool_input !== undefined) {
|
|
846
|
+
const toolName = typeof payload.tool_name === 'string' ? payload.tool_name : '';
|
|
847
|
+
let toolInput = payload.tool_input;
|
|
848
|
+
if (typeof toolInput === 'string') {
|
|
849
|
+
try {
|
|
850
|
+
toolInput = JSON.parse(toolInput);
|
|
851
|
+
} catch {
|
|
852
|
+
toolInput = undefined;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (toolName === '' || WRITE_TOOL.test(toolName)) {
|
|
856
|
+
collectSourcePaths(toolInput, files);
|
|
857
|
+
} else if (SHELL_TOOL.test(toolName)) {
|
|
858
|
+
const cwd = typeof payload.cwd === 'string' ? payload.cwd : process.cwd();
|
|
859
|
+
collectCommandPaths(toolInput?.command, cwd, files);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (files.size === 0) process.exit(0);
|
|
863
|
+
|
|
864
|
+
const findings = [];
|
|
865
|
+
for (const file of files) {
|
|
866
|
+
try {
|
|
867
|
+
findings.push(...fileFindings(file, process.cwd()));
|
|
868
|
+
} catch {
|
|
869
|
+
// File disappeared between the edit and the hook — nothing to check.
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (findings.length === 0) process.exit(0);
|
|
873
|
+
|
|
874
|
+
const report = formatReport(
|
|
875
|
+
'solid2-kit check — React/Solid 1.x patterns in this edit; fix them now:',
|
|
876
|
+
findings,
|
|
877
|
+
);
|
|
878
|
+
if (agent === 'claude') {
|
|
879
|
+
console.error(report);
|
|
880
|
+
process.exit(2);
|
|
881
|
+
}
|
|
882
|
+
console.log(JSON.stringify({ additional_context: report }));
|
|
883
|
+
process.exit(0);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// --- doctor -----------------------------------------------------------------
|
|
887
|
+
// Project-config gate: `check` covers TSX sources, `doctor` covers the wiring
|
|
888
|
+
// around them — dependencies, tsconfig, and root config files — where agents
|
|
889
|
+
// also reach for React / Solid 1.x tooling (vite-plugin-solid,
|
|
890
|
+
// eslint-plugin-solid, jsxImportSource: "solid-js", ...).
|
|
891
|
+
|
|
892
|
+
const BANNED_DEPS = {
|
|
893
|
+
react: 'React does not belong in a Solid 2.0 project.',
|
|
894
|
+
'react-dom': 'React does not belong in a Solid 2.0 project.',
|
|
895
|
+
next: 'Next.js does not belong in a Solid 2.0 project.',
|
|
896
|
+
'vite-plugin-solid': 'Solid 1.x Vite plugin. Use @solidjs/vite-plugin.',
|
|
897
|
+
'eslint-plugin-solid':
|
|
898
|
+
'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.',
|
|
899
|
+
'@solidjs/start': 'SolidStart 1.x. Solid 2 start mode lives in @solidjs/vite-plugin (start: true).',
|
|
900
|
+
'solid-start': 'SolidStart 0.x. Solid 2 start mode lives in @solidjs/vite-plugin (start: true).',
|
|
901
|
+
'solid-app-router': 'Pre-1.0 router. Use @solidjs/router 2 (createRouter({ routes })).',
|
|
902
|
+
vinxi: 'SolidStart 1.x toolchain. Solid 2 uses @solidjs/vite-plugin directly.',
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
const CONFIG_SOURCE = /\.(?:m|c)?[jt]s$/;
|
|
906
|
+
|
|
907
|
+
// Files installed by this kit that carry a version marker. When the kit
|
|
908
|
+
// dependency is updated but `sync` is not re-run, the installed guidance
|
|
909
|
+
// keeps teaching agents the older content — silent drift doctor can catch.
|
|
910
|
+
const GUIDANCE_FILES = ['.cursor/rules/solid-2.mdc', 'CLAUDE.md', 'AGENTS.md'];
|
|
911
|
+
|
|
912
|
+
function doctorFindings(target) {
|
|
913
|
+
const findings = [];
|
|
914
|
+
const report = (id, message) => findings.push({ id, message });
|
|
915
|
+
|
|
916
|
+
const pkg = JSON.parse(readFileSync(join(target, 'package.json'), 'utf8'));
|
|
917
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
918
|
+
for (const [name, message] of Object.entries(BANNED_DEPS)) {
|
|
919
|
+
if (name in deps) report(`dep-${name.replace(/[@/]/g, '')}`, `package.json depends on "${name}". ${message}`);
|
|
920
|
+
}
|
|
921
|
+
const solidRange = deps['solid-js'];
|
|
922
|
+
if (typeof solidRange === 'string' && /^[\s^~=v]*[01]\./.test(solidRange)) {
|
|
923
|
+
report('solid-js-version', `package.json pins solid-js "${solidRange}" — this kit teaches Solid 2.x; upgrade to ^2.`);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
for (const entry of readdirSync(target, { withFileTypes: true })) {
|
|
927
|
+
if (!entry.isFile()) continue;
|
|
928
|
+
const path = join(target, entry.name);
|
|
929
|
+
|
|
930
|
+
if (/^tsconfig.*\.json$/.test(entry.name)) {
|
|
931
|
+
// tsconfig is JSONC; extract the two relevant keys with regexes.
|
|
932
|
+
const content = readFileSync(path, 'utf8');
|
|
933
|
+
const jsx = content.match(/"jsx"\s*:\s*"([^"]+)"/)?.[1];
|
|
934
|
+
if (jsx && jsx !== 'preserve') {
|
|
935
|
+
report('tsconfig-jsx', `${entry.name} sets "jsx": "${jsx}". Solid 2 compiles its own JSX: use "preserve".`);
|
|
936
|
+
}
|
|
937
|
+
const importSource = content.match(/"jsxImportSource"\s*:\s*"([^"]+)"/)?.[1];
|
|
938
|
+
if (importSource && importSource !== '@solidjs/web') {
|
|
939
|
+
report(
|
|
940
|
+
'tsconfig-jsx-import-source',
|
|
941
|
+
`${entry.name} sets "jsxImportSource": "${importSource}". Solid 2 JSX types live in "@solidjs/web".`,
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
if (CONFIG_SOURCE.test(entry.name)) {
|
|
948
|
+
const content = readFileSync(path, 'utf8');
|
|
949
|
+
if (/['"]vite-plugin-solid['"]/.test(content)) {
|
|
950
|
+
report('config-vite-plugin-solid', `${entry.name} references vite-plugin-solid (Solid 1.x). Import solid from "@solidjs/vite-plugin".`);
|
|
951
|
+
}
|
|
952
|
+
if (/jsxImportSource['"]?\s*[:=]\s*['"](?:solid-js|react)['"]/.test(content)) {
|
|
953
|
+
report('config-jsx-import-source', `${entry.name} sets jsxImportSource to solid-js/react. Solid 2 uses "@solidjs/web".`);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if (/^(?:\.eslintrc|eslint\.config\.)/.test(entry.name)) {
|
|
958
|
+
const content = readFileSync(path, 'utf8');
|
|
959
|
+
if (content.includes('eslint-plugin-solid')) {
|
|
960
|
+
report('eslint-plugin-solid', `${entry.name} wires eslint-plugin-solid (Solid 1.x analyzer; false-positives on Solid 2 idioms). Remove it.`);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
for (const guidance of GUIDANCE_FILES) {
|
|
966
|
+
const path = join(target, guidance);
|
|
967
|
+
if (!existsSync(path)) continue;
|
|
968
|
+
const installed = readFileSync(path, 'utf8').match(/solid2-agent-kit v(\d[\w.-]*)/)?.[1];
|
|
969
|
+
if (installed && installed !== VERSION) {
|
|
970
|
+
report(
|
|
971
|
+
'stale-guidance',
|
|
972
|
+
`${guidance} was installed by solid2-agent-kit v${installed} but v${VERSION} is running — run \`solid2-kit sync\` so agents read current guidance.`,
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
return findings;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function doctor() {
|
|
981
|
+
const target = resolve(flagValue('--target', '.'));
|
|
982
|
+
if (!existsSync(join(target, 'package.json'))) {
|
|
983
|
+
console.error(`solid2-kit doctor — no package.json at ${target} (use --target)`);
|
|
984
|
+
process.exit(2);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const findings = doctorFindings(target);
|
|
988
|
+
for (const { id, message } of findings) console.error(`[${id}] ${message}`);
|
|
989
|
+
|
|
990
|
+
if (findings.length > 0) {
|
|
991
|
+
console.error(`\nsolid2-kit doctor — ${findings.length} finding(s). Fix the project wiring above.`);
|
|
394
992
|
process.exit(1);
|
|
395
993
|
}
|
|
396
|
-
console.log(
|
|
994
|
+
console.log('solid2-kit doctor — OK (dependencies, tsconfig, root configs, and installed guidance look like Solid 2).');
|
|
397
995
|
}
|
|
398
996
|
|
|
399
997
|
// --- entry ------------------------------------------------------------------
|
|
@@ -406,15 +1004,23 @@ switch (command) {
|
|
|
406
1004
|
case 'check':
|
|
407
1005
|
check();
|
|
408
1006
|
break;
|
|
1007
|
+
case 'doctor':
|
|
1008
|
+
doctor();
|
|
1009
|
+
break;
|
|
1010
|
+
case 'hook':
|
|
1011
|
+
hook(args[1]);
|
|
1012
|
+
break;
|
|
409
1013
|
default:
|
|
410
1014
|
console.log(
|
|
411
1015
|
[
|
|
412
1016
|
`solid2-agent-kit v${VERSION}`,
|
|
413
1017
|
'',
|
|
414
1018
|
'Usage:',
|
|
415
|
-
' solid2-kit init [--cursor] [--claude] [--target <dir>] install/update guidance (default: both tools)',
|
|
416
|
-
' solid2-kit sync [--cursor] [--claude] [--target <dir>] alias of init (idempotent)',
|
|
417
|
-
' solid2-kit check [--dir <srcdir>] [--target <dir>]
|
|
1019
|
+
' solid2-kit init [--cursor] [--claude] [--no-hooks] [--target <dir>] install/update guidance + edit hooks (default: both tools)',
|
|
1020
|
+
' solid2-kit sync [--cursor] [--claude] [--no-hooks] [--target <dir>] alias of init (idempotent)',
|
|
1021
|
+
' solid2-kit check [--dir <srcdir>] [--target <dir>] [files...] mechanical React/Solid 1.x pattern gate (default dir: src)',
|
|
1022
|
+
' solid2-kit doctor [--target <dir>] project-wiring gate: deps, tsconfig, root configs',
|
|
1023
|
+
' solid2-kit hook (claude|cursor) edit-time gate for agent hooks (stdin: hook JSON payload)',
|
|
418
1024
|
].join('\n'),
|
|
419
1025
|
);
|
|
420
1026
|
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`.
|
|
@@ -78,10 +78,14 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
|
|
|
78
78
|
accessor) only when that identity change should show the fallback again. Pass an async
|
|
79
79
|
memo to a child as the **accessor** (`user={user}`), and read `props.user()` under that
|
|
80
80
|
child's `<Loading>` — `user={user()}` throws not-ready at the parent, so the child's
|
|
81
|
-
boundary never sees it. Do not
|
|
81
|
+
boundary never sees it. Do not start `fetch` (or any request) at component-body top
|
|
82
|
+
level — that runs once at mount and is not a reactive source. Do not `try/catch` `NotReadyError` around a read, and do not
|
|
82
83
|
use `loadingValue` / `seedLoadingValue` as the default first-flight UI — those skip
|
|
83
|
-
`<Loading>`. `{latest(() => x())}` is a preview, not the visible answer
|
|
84
|
-
is the indicator
|
|
84
|
+
`<Loading>`. `{latest(() => x())}` is a preview, not the visible answer. `isPending`
|
|
85
|
+
is the *refetch* indicator after a settled answer exists — do not use it as the
|
|
86
|
+
first-load spinner (`<Loading>` owns that). Pass the accessor: `isPending(user)`,
|
|
87
|
+
not `isPending(user())` (that evaluates the read before `isPending` runs).
|
|
88
|
+
"Prop with local edits" = writable derivation:
|
|
85
89
|
`createSignal(() => props.value)` or `createStore(() => props.value, fallback)`.
|
|
86
90
|
`createOptimistic` is for an in-flight mutation, not a local editing session.
|
|
87
91
|
11. Context: the context object is the provider — `<MyContext value={{ theme, setTheme }}>`
|
|
@@ -139,7 +143,11 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
|
|
|
139
143
|
counter/version signal read inside the computation. Input-driven refetch is automatic
|
|
140
144
|
(tracked inputs re-run the computation) and subscriptions push. Legit uses: after a
|
|
141
145
|
mutation inside an `action`, and explicit reload buttons. Pair with `affects(source)`
|
|
142
|
-
when the reload should present as pending.
|
|
146
|
+
when the reload should present as pending — a bare `refresh()` re-asks quietly.
|
|
147
|
+
These are three different APIs: core `refresh(source)` reruns a reactive source;
|
|
148
|
+
router `revalidate(getUser.key)` invalidates the query cache; server-function
|
|
149
|
+
`return reload({ revalidate: "todos" })` asks the integration to refresh cached
|
|
150
|
+
data. Do not mix them.
|
|
143
151
|
20. **External collections flow into stores through reconciliation.** First choice:
|
|
144
152
|
function-form `createStore(() => source, fallback)` / `createProjection` (reconcile
|
|
145
153
|
automatically, keyed by `"id"`). Manual merge: `setStore(reconcile(fresh, "id"))` or
|
|
@@ -157,7 +165,9 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
|
|
|
157
165
|
cross an async gap default to `action` + `createOptimistic`/`createOptimisticStore` —
|
|
158
166
|
except reactive clients (e.g. Convex) whose subscriptions already push authoritative
|
|
159
167
|
state after mutations. Compiler: `"jsxImportSource": "@solidjs/web"` (not `"solid-js"`);
|
|
160
|
-
Vite plugin is `@solidjs/vite-plugin` (not `vite-plugin-solid`)
|
|
168
|
+
Vite plugin is `@solidjs/vite-plugin` (not `vite-plugin-solid`) — run `solid2-kit doctor`
|
|
169
|
+
after touching `package.json` / tsconfig / root configs; it fails on React and Solid 1.x
|
|
170
|
+
wiring. Server vs browser:
|
|
161
171
|
`isServer` / `isDev` from `@solidjs/web`, or `clientOnly(() => import("./Widget"))` for
|
|
162
172
|
browser-only components — never `typeof window` as the SSR boundary. Default
|
|
163
173
|
`clientOnly` starts loading at declaration; `{ lazy: true }` defers until first
|
|
@@ -175,9 +185,13 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
|
|
|
175
185
|
fallback body), not event-time mutations. `JSON.stringify(store)`
|
|
176
186
|
/ `structuredClone(store)` without `snapshot(store)` can throw or leak proxies. Async SSR
|
|
177
187
|
without `<Loading>` *works* but blocks the HTML shell until every read settles — wrap
|
|
178
|
-
reads, or stream with `renderToStream`.
|
|
179
|
-
|
|
188
|
+
reads, or stream with `renderToStream`. `pipe` / `pipeTo` / `readable` each consume
|
|
189
|
+
a stream render — use exactly one. `createRoot` is for tests, libraries, and
|
|
190
|
+
non-render entry points; inside a component let `render` / the component owner
|
|
191
|
+
own the scope. Async reads inside `<Portal>` start on the client — hoist the
|
|
192
|
+
read above the portal. Navigation-shaped updates (a setter, then async computeds holding previous values)
|
|
180
193
|
do not need core `action` — reach for it only when writes happen *after* async work.
|
|
194
|
+
Invoke actions from handlers, not from memos or effects.
|
|
181
195
|
23. **Composition.** Pass `props.children` through when you only render them. Inspect or
|
|
182
196
|
iterate children with `children(() => props.children)` (then `.toArray()`), never a
|
|
183
197
|
setup-time snapshot. Code-split with `lazy(() => import("./X"))` under `<Loading>` —
|
|
@@ -199,8 +213,17 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
|
|
|
199
213
|
`window.location` / `history.pushState`. Trusted identity is
|
|
200
214
|
`getRequestEvent()`, never a caller-supplied user id. Unscripted forms use
|
|
201
215
|
the function `.url` (or a router `action`), not a hand-built `/_server/`
|
|
202
|
-
path.
|
|
203
|
-
`<
|
|
216
|
+
path. Client history adapters do not select the SSR URL — pass
|
|
217
|
+
`<Router url={request.url}>` (or rely on the request event). Secrets belong in
|
|
218
|
+
`virtual:env/server`, never the `client` env map / `import.meta.env`. Document head is
|
|
219
|
+
`<Title>` / `<Meta>` from `@solidjs/meta` with **no** `MetaProvider`. A static
|
|
220
|
+
`<title>` in the document shell is the fallback; do not hardcode other tags
|
|
221
|
+
(`<meta name="description">`) that Solid Meta should manage.
|
|
222
|
+
Return `respond(value, { status })` from a `"use server"` function, not
|
|
223
|
+
`Response.json` — a raw `Response` is HTTP-handler control flow; scripted
|
|
224
|
+
callers should receive the value. JSON-encodable server-function arguments
|
|
225
|
+
only, unless `enableRichArguments()` was called once in the client entry
|
|
226
|
+
(`Date` / `Map` / `Set` throw without it).
|
|
204
227
|
|
|
205
228
|
## Lint heritage: eslint-plugin-solid (Solid 1.x) intents carried into this file
|
|
206
229
|
|
|
@@ -264,6 +287,7 @@ When unsure about any API, verify against the official Solid 2.0 docs — fetcha
|
|
|
264
287
|
listed in `references/official-docs.md` next to the `solid-2` skill. Do not guess from
|
|
265
288
|
Solid 1.x or React memory.
|
|
266
289
|
|
|
267
|
-
`createTrackedEffect` and `storePath` exist in Solid 2 but
|
|
268
|
-
`createEffect` unless a single tracked callback is
|
|
269
|
-
`storePath` except while converting 1.x
|
|
290
|
+
`createTrackedEffect`, `createRenderEffect`, and `storePath` exist in Solid 2 but
|
|
291
|
+
are not defaults: use two-phase `createEffect` unless a single tracked callback is
|
|
292
|
+
required, and draft setters instead of `storePath` except while converting 1.x
|
|
293
|
+
path setters.
|
|
@@ -67,7 +67,8 @@ Most React `useEffect` code should NOT become `createEffect`:
|
|
|
67
67
|
| Inspect, count, or iterate children | `children(() => props.children)` then `.toArray()` |
|
|
68
68
|
| Code-split a component | `lazy(() => import("./X"))` read under `<Loading>` |
|
|
69
69
|
| Named export from a lazy module | `lazy(() => import("./pages"), { export: "About" })` |
|
|
70
|
-
| Pick a component/tag from reactive state | `dynamic(() => ...)` from `@solidjs/web` (stable identity) |
|
|
70
|
+
| Pick a component/tag from reactive state | `dynamic(() => ...)` from `@solidjs/web` (stable identity; prefer over `<Dynamic>`) |
|
|
71
|
+
| Overlay / modal | `<Portal>` — hoist async reads *above* the portal (reads inside start on the client) |
|
|
71
72
|
| Browser-only widget (charts, maps, `window`) | `clientOnly(() => import("./Chart"))` from `@solidjs/web` (`{ lazy: true }` defers the import until first render) |
|
|
72
73
|
| Server vs browser branch | `isServer` / `isDev` from `@solidjs/web` (build-time constants), not `typeof window` |
|
|
73
74
|
| SSR-stable `id` / `for` / `aria-*` pairing | `createUniqueId()` |
|
|
@@ -199,7 +200,8 @@ const Result = dynamic(() => (detailed() ? Detailed : Compact));
|
|
|
199
200
|
`dynamic()` returns a **stable** component whose source can be a component, an
|
|
200
201
|
intrinsic tag name, a promise, or empty. Prefer it over swapping a component
|
|
201
202
|
variable in JSX (`const View = tab() ? A : B` freezes the choice at setup).
|
|
202
|
-
`<Dynamic>` is the JSX spelling of the same primitive
|
|
203
|
+
`<Dynamic>` is the JSX spelling of the same primitive; application code should
|
|
204
|
+
use `dynamic()` so the component identity stays stable.
|
|
203
205
|
|
|
204
206
|
### Two-phase effect (imperative boundary only)
|
|
205
207
|
|
|
@@ -245,15 +247,18 @@ return (
|
|
|
245
247
|
where the memo was created, and not of page chrome (header/nav) that should stay mounted.
|
|
246
248
|
Boundary placement is purely a UX decision — it does not change when fetches start
|
|
247
249
|
(no waterfalls: nested components set up and fetch in parallel). After first paint the
|
|
248
|
-
boundary keeps settled content during refetch; `isPending(
|
|
250
|
+
boundary keeps settled content during refetch; `isPending(results)` (pass the accessor,
|
|
251
|
+
not `isPending(results())`) is the
|
|
249
252
|
indicator. Use `on={query()}` (the **value**, not the accessor `on={query}`) only when
|
|
250
253
|
that identity should put the fallback back on screen.
|
|
254
|
+
Do not use `isPending` as the first-load spinner.
|
|
251
255
|
- Read every reactive input **before the first `await`** in an async computation. A read
|
|
252
256
|
after `await` does not subscribe; production can sit pending with no retry.
|
|
253
257
|
- `<Errored>` function fallbacks receive an error **accessor** and a `reset` callback:
|
|
254
258
|
`fallback={(error, reset) => ...}`.
|
|
255
|
-
- Refetch: `refresh(results)`. In-flight indicator: `isPending(
|
|
256
|
-
Freshest in-flight value for
|
|
259
|
+
- Refetch: `refresh(results)`. In-flight indicator: `isPending(results)` (or
|
|
260
|
+
`isPending(() => results())`). Freshest in-flight value for a preview:
|
|
261
|
+
`latest(results)`. Do not start `fetch` at component-body top level.
|
|
257
262
|
- Coordinate reveal order of sibling `<Loading>` boundaries with `<Reveal order="sequential" | "together" | "natural">`.
|
|
258
263
|
|
|
259
264
|
**Never hand-roll these states.** No `[loading, setLoading]` signal, no
|
|
@@ -524,6 +529,10 @@ test('increments on click', () => {
|
|
|
524
529
|
`jsxImportSource` for tests and app code is `"@solidjs/web"`, not `"solid-js"`.
|
|
525
530
|
Vite plugin is `@solidjs/vite-plugin`, not `vite-plugin-solid`.
|
|
526
531
|
|
|
532
|
+
To turn those dev diagnostics into a hard gate, patch `console.warn` in the test setup
|
|
533
|
+
file to rethrow unexpected warnings — then any top-level reactive read or unowned write
|
|
534
|
+
an agent sneaks in fails the suite instead of scrolling by.
|
|
535
|
+
|
|
527
536
|
## App stack (only if the project has these packages)
|
|
528
537
|
|
|
529
538
|
This kit's always-applied rules cover `solid-js` + `@solidjs/web` TSX. Official
|
|
@@ -634,7 +643,10 @@ shared cache, no preload reuse, and no `revalidate`. Do not wrap mutations in
|
|
|
634
643
|
`query` — wrapping an undeclared server function makes it GET. `query.get(key)`
|
|
635
644
|
throws if there is no entry; `query.set` does not accept a promise. Cache is
|
|
636
645
|
request-scoped on the server and application-scoped in the browser. After a
|
|
637
|
-
router mutation, `revalidate(...)` — not core `refresh()
|
|
646
|
+
router mutation, `revalidate(...)` — not core `refresh()`, and not
|
|
647
|
+
`reload()` (that is a *server-function return* that asks the integration to
|
|
648
|
+
refresh cached data: `return reload({ revalidate: "todos" })`). `live()` sources
|
|
649
|
+
update through the open stream; do not `revalidate` them. Replace SolidStart /
|
|
638
650
|
Router 0.x leftovers: no `createAsync`, `useSubmission` (singular), `cache()`,
|
|
639
651
|
router `json()`, or `<FileRoutes />` (`fileRoutes(pageRoutes)` instead).
|
|
640
652
|
|
|
@@ -656,7 +668,11 @@ revalidate: `redirect` / `reload` from `@solidjs/web` (see the server-function
|
|
|
656
668
|
guides). For 404 during SSR: `httpStatus(404)` from `@solidjs/web`.
|
|
657
669
|
`throw new Error("…")` is stripped to `"Internal Server Error"` in production —
|
|
658
670
|
use `markSafeError` or `respond(..., { status })` for intentional client-facing
|
|
659
|
-
failures. `
|
|
671
|
+
failures. Do not `return Response.json(...)` from `"use server"` — that is
|
|
672
|
+
HTTP-handler control flow; `respond(value, { status })` is what a scripted
|
|
673
|
+
caller unwraps. JSON-encodable arguments only unless `enableRichArguments()`
|
|
674
|
+
was called once in the client entry (`Date` / `Map` / `Set` throw without it).
|
|
675
|
+
`GET()` is only for idempotent reads (URLs leak into logs/history);
|
|
660
676
|
import it from `@solidjs/web/server-functions`, never `@solidjs/start`.
|
|
661
677
|
`live()` yields **current state** (each yield replaces the last); do not treat
|
|
662
678
|
it as an append-only event log, and hoist the memo so consumers share one
|
|
@@ -676,8 +692,12 @@ import { Title, Link, Meta } from '@solidjs/meta';
|
|
|
676
692
|
```
|
|
677
693
|
|
|
678
694
|
Solid Meta 1.x has **no provider**. Render tags anywhere; later wins, unmount
|
|
679
|
-
restores. `useHead` from `@solidjs/web` is the lower-level registry
|
|
680
|
-
|
|
695
|
+
restores. `useHead` from `@solidjs/web` is the lower-level registry — prefer
|
|
696
|
+
`<Title>` / `<Meta>` / `<Script>` (JSON-LD) for application metadata. Several
|
|
697
|
+
tags that should swap as one unit belong in `<Head>`. A static `<title>`
|
|
698
|
+
in the document shell is the fallback when no `<Title>` is mounted; do not
|
|
699
|
+
hardcode a second `<meta name="description">` (the registry leaves foreign tags
|
|
700
|
+
alone, so both would coexist). Do not hardcode a second `<title>` in the document shell.
|
|
681
701
|
|
|
682
702
|
## These still run — write the other form
|
|
683
703
|
|
|
@@ -742,7 +762,7 @@ how to reuse. Prefer the form on the right.
|
|
|
742
762
|
| `<For each={resolved()}>` after `children()` | `{resolved.toArray()}` — resolved children are not a reactive data list |
|
|
743
763
|
| `<input onChange={...}>` for keystrokes | `onInput` — native `onChange` is blur/commit |
|
|
744
764
|
| `createOptimistic` for a local edit draft | writable derivation / plain store. Optimistic is for an in-flight mutation |
|
|
745
|
-
| `createMemo(async fn, { loadingValue })` as the default first-flight UI | `<Loading>` for first flight;
|
|
765
|
+
| `createMemo(async fn, { loadingValue })` / `{ seedLoadingValue: true }` as the default first-flight UI | `<Loading>` for first flight; those options are escape hatches (store projections use `seedLoadingValue`) |
|
|
746
766
|
| `setSubmitted(true)` + an effect that watches it | do the work in the handler or an `action` |
|
|
747
767
|
| `findUser(userId)` with the id from the client as identity | `getRequestEvent()!.locals.userId` |
|
|
748
768
|
| `action={"/_server/" + id}` in app JSX | `action={createTodo.url}` or a router `action` |
|
|
@@ -753,6 +773,27 @@ how to reuse. Prefer the form on the right.
|
|
|
753
773
|
| `{latest(() => results())}` as the visible list | settled read + `isPending()` for the indicator; `latest` is for previews |
|
|
754
774
|
| `typeof window !== "undefined"` | `isServer` / `isDev` from `@solidjs/web` |
|
|
755
775
|
| `Object.assign({}, props)` / expecting `merge` to skip `undefined` like 1.x `mergeProps` | `omit` / `merge` — explicit `undefined` **overrides** (Object.assign) |
|
|
776
|
+
| `isPending(user())` / `latest(user())` | `isPending(user)` / `latest(user)` — pass the accessor (or `() => user()`). Calling it first evaluates the read before the helper runs |
|
|
777
|
+
| `isPending(...)` as the first-load spinner | `<Loading>` owns first flight; `isPending` is the *refetch* indicator after a settled answer exists |
|
|
778
|
+
| `refresh(getUser.key)` / `revalidate(user)` / `return refresh()` from `"use server"` | three APIs: core `refresh(source)` reruns a reactive source; router `revalidate(getUser.key)` invalidates the query cache; `return reload({ revalidate: "todos" })` asks the integration to refresh cached data |
|
|
779
|
+
| `revalidate(liveSource)` | `live()` updates through the open stream; do not revalidate it |
|
|
780
|
+
| `refresh(user)` without `affects` when the reload should look pending | pair `affects(user)` with `refresh(user)` — a bare `refresh` re-asks quietly |
|
|
781
|
+
| `<Dynamic component={tab() ? A : B} />` | `const View = dynamic(() => (tab() ? A : B))` — `<Dynamic>` is a JSX convenience; app code should use `dynamic()` for stable identity |
|
|
782
|
+
| `<meta name="description" content="...">` hardcoded in the document shell | `<Meta>` from `@solidjs/meta`. A static `<title>` is the fallback; other hardcoded tags coexist with the registry |
|
|
783
|
+
| `useHead({ tag: "meta", ... })` for ordinary page tags | `<Title>` / `<Meta>` / `<Script>` (JSON-LD). `useHead` is the lower-level registry |
|
|
784
|
+
| two `<Meta property="og:image">` meant as one replacement set | wrap them in `<Head>` — a later group replaces the earlier set as one unit |
|
|
785
|
+
| `createRoot(() => ...)` inside a component | let `render` / the component owner own the scope. `createRoot` is for tests, libraries, and non-render entry points |
|
|
786
|
+
| `JSON.stringify(store)` / `structuredClone(store)` | `snapshot(store)` — proxies throw or leak reactivity |
|
|
787
|
+
| `import { env } from "virtual:env/client"` (or `import.meta.env`) for a signing secret | `import { env } from "virtual:env/server"` — client map values are public |
|
|
788
|
+
| `renderToStream(...).pipe(res)` and also `.readable` / `await` on the same result | exactly one consumer: `pipe` / `pipeTo` / `readable` / await |
|
|
789
|
+
| `return Response.json(user, { status: 201 })` from `"use server"` | `return respond(user, { status: 201 })` — scripted callers receive `user`, not a `Response` |
|
|
790
|
+
| `<Portal>{user().name}</Portal>` for an async read | hoist the read above the portal (children start on the client); async UI inside one wants its own `<Loading>` |
|
|
791
|
+
| a client history adapter to pick the SSR location | `<Router url={request.url}>` (or the request event). Client adapters do not select the server URL |
|
|
792
|
+
| `fetch('/api')` at component-body top level | `createMemo(async () => { ... fetch ... })` under `<Loading>` — a top-level fetch runs once at mount and is not a reactive source |
|
|
793
|
+
| `<input type="checkbox" value={on()} />` | `checked={on()}` for the toggle; `value="string"` only when grouping radios or listing submitted values |
|
|
794
|
+
| `createMemo(() => { void save(); return x(); })` / an effect that calls an action | invoke actions from handlers, not from memos or effects |
|
|
795
|
+
| `createSignal(props.value)` / `createStore(props.items)` | `createSignal(() => props.value)` / `createStore(() => props.items, fallback)` — a bare `props.x` at setup is a snapshot |
|
|
796
|
+
| `createRenderEffect` / `createTrackedEffect` as the default effect | two-phase `createEffect(compute, apply)` |
|
|
756
797
|
|
|
757
798
|
```tsx
|
|
758
799
|
// WRONG — post-await read never subscribes; production can hang pending
|
|
@@ -832,6 +873,16 @@ render(<App />, document.getElementById('app')!);
|
|
|
832
873
|
render(() => <App />, document.getElementById('app')!);
|
|
833
874
|
```
|
|
834
875
|
|
|
876
|
+
```tsx
|
|
877
|
+
// WRONG — user() runs before isPending, so the helper never sees the accessor
|
|
878
|
+
<article aria-busy={isPending(user())}>{user().name}</article>
|
|
879
|
+
|
|
880
|
+
// CORRECT — pass the accessor (or () => user()); <Loading> still owns first flight
|
|
881
|
+
<Loading fallback={<p>Loading…</p>}>
|
|
882
|
+
<article aria-busy={isPending(user) ? 'true' : 'false'}>{user().name}</article>
|
|
883
|
+
</Loading>
|
|
884
|
+
```
|
|
885
|
+
|
|
835
886
|
```tsx
|
|
836
887
|
// WRONG — tab() is read once at setup; View never changes
|
|
837
888
|
const View = tab() ? Detailed : Compact;
|
|
@@ -872,8 +923,10 @@ always-applied rules installed alongside this skill.
|
|
|
872
923
|
- [ ] Effects are two-phase and only at imperative boundaries; apply does not read stores.
|
|
873
924
|
- [ ] Async reads sit under `<Loading>` (the data slot, not chrome); errors under `<Errored>`.
|
|
874
925
|
Reactive inputs of an async memo are read before the first `await`. No hand-rolled
|
|
875
|
-
`loading`/`error` signals or `=== undefined` readiness branches
|
|
876
|
-
`
|
|
926
|
+
`loading`/`error` signals or `=== undefined` readiness branches. First load is
|
|
927
|
+
`<Loading>`; refetch indicator is `isPending(user)` (the accessor, not `user()`).
|
|
928
|
+
Core `refresh(source)`, router `revalidate(key)`, and `return reload(...)` are
|
|
929
|
+
different APIs — do not mix them.
|
|
877
930
|
- [ ] External collections reconcile into stores (function-form `createStore` /
|
|
878
931
|
`reconcile`), never wholesale draft assignment or `setTodos((t) => t.filter(...))`
|
|
879
932
|
as the identity-preserving update.
|
|
@@ -889,8 +942,11 @@ always-applied rules installed alongside this skill.
|
|
|
889
942
|
`window.location`. Router `action`/`query` come from `@solidjs/router` (POST forms + cache), not
|
|
890
943
|
core `action`/`refresh`. Forms: `<form action={save} method="post">`.
|
|
891
944
|
- [ ] `render(() => <App />, root)` — a function, not `render(<App />)`. `hydrate` when
|
|
892
|
-
HTML already exists. Stream async/lazy trees (`await renderToStream(...)`)
|
|
945
|
+
HTML already exists. Stream async/lazy trees (`await renderToStream(...)`) with
|
|
946
|
+
exactly one consumer (`pipe` / `pipeTo` / `readable`). Snapshot stores with
|
|
947
|
+
`snapshot(store)`, not `JSON.stringify`. Secrets live in `virtual:env/server`.
|
|
893
948
|
- [ ] `jsxImportSource` is `@solidjs/web`; Vite plugin is `@solidjs/vite-plugin`.
|
|
894
949
|
No `@solidjs/start`, `vinxi`, `"use client"`, or Next.js imports.
|
|
895
950
|
- [ ] Single return per component; no early returns on reactive conditions.
|
|
896
|
-
- [ ] `solid2-kit check` and the project's typecheck pass
|
|
951
|
+
- [ ] `solid2-kit check` and the project's typecheck pass; `solid2-kit doctor` too if
|
|
952
|
+
`package.json`, tsconfig, or root config files changed.
|
|
@@ -99,6 +99,7 @@ read these before writing route JSX.
|
|
|
99
99
|
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-js/lifecycle-actions/on-settled.md
|
|
100
100
|
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-js/lifecycle-actions/refresh.md
|
|
101
101
|
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-js/advanced/specialized-reactivity/on-cleanup.md
|
|
102
|
+
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-js/advanced/store-advanced/snapshot.md
|
|
102
103
|
|
|
103
104
|
## Reference: components and JSX (solid-js)
|
|
104
105
|
|
|
@@ -138,9 +139,12 @@ read these before writing route JSX.
|
|
|
138
139
|
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-web/jsx-properties/inner-html.md
|
|
139
140
|
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-web/server-functions.md
|
|
140
141
|
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-web/server-functions/get.md
|
|
142
|
+
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-web/server-functions/rich-arguments.md
|
|
141
143
|
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-web/request-response.md
|
|
142
144
|
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-web/request-response/get-request-event.md
|
|
143
145
|
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-web/request-response/redirect.md
|
|
146
|
+
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-web/request-response/reload.md
|
|
147
|
+
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-web/request-response/respond.md
|
|
144
148
|
|
|
145
149
|
## Reference: @solidjs/router, @solidjs/meta, @solidjs/vite-plugin
|
|
146
150
|
|
|
@@ -148,10 +152,12 @@ read these before writing route JSX.
|
|
|
148
152
|
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-router/router-factory.md
|
|
149
153
|
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-router/navigation.md
|
|
150
154
|
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-router/data.md
|
|
155
|
+
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-router/history.md
|
|
151
156
|
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-router/filesystem.md
|
|
152
157
|
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-meta/title.md
|
|
153
158
|
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-meta/meta.md
|
|
154
159
|
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-meta/link.md
|
|
160
|
+
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-meta/script.md
|
|
155
161
|
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-meta/head.md
|
|
156
162
|
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/vite-plugin-solid.md
|
|
157
163
|
- https://v2-rebuild--solid-docs-v2.netlify.app/reference/vite-plugin-solid/options.md
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solid2-agent-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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"
|