react-detective 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +137 -2
- package/dist/chunk-5TDNB3DH.js +2686 -0
- package/dist/chunk-6EJCTAJS.js +25 -0
- package/dist/chunk-E23QXCCY.js +43 -0
- package/dist/chunk-Z2Y65YOY.cjs +7 -0
- package/dist/cli.js +35 -0
- package/dist/client.d.ts +9 -0
- package/dist/client.js +2028 -0
- package/dist/init-JUCWNJPS.js +630 -0
- package/dist/loader.cjs +105 -0
- package/dist/loader.js +101 -0
- package/dist/next.cjs +58 -0
- package/dist/next.d.cts +24 -0
- package/dist/next.d.ts +22 -0
- package/dist/next.js +54 -0
- package/dist/react.d.ts +11 -0
- package/dist/react.js +26 -0
- package/dist/server.d.ts +292 -0
- package/dist/server.js +15 -0
- package/dist/vite.d.ts +17 -0
- package/dist/vite.js +132 -0
- package/package.json +84 -4
|
@@ -0,0 +1,2686 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MISSING_KEY_MESSAGE,
|
|
3
|
+
findApiKey
|
|
4
|
+
} from "./chunk-E23QXCCY.js";
|
|
5
|
+
|
|
6
|
+
// src/server/explain.ts
|
|
7
|
+
import path from "path";
|
|
8
|
+
import { createSdkMcpServer, query, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
9
|
+
import { z as z2 } from "zod";
|
|
10
|
+
|
|
11
|
+
// src/server/card.ts
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
var short = (words, what) => z.string().describe(`${what} At most ~${words} words.`);
|
|
14
|
+
var OUTLINE_KINDS = [
|
|
15
|
+
"state",
|
|
16
|
+
"ref",
|
|
17
|
+
"context",
|
|
18
|
+
"store",
|
|
19
|
+
"hook",
|
|
20
|
+
"derived",
|
|
21
|
+
"memo",
|
|
22
|
+
"callback",
|
|
23
|
+
"function",
|
|
24
|
+
"effect",
|
|
25
|
+
"render",
|
|
26
|
+
"constant"
|
|
27
|
+
];
|
|
28
|
+
var outlineFields = {
|
|
29
|
+
kind: z.enum(OUTLINE_KINDS).describe(
|
|
30
|
+
"state = useState/useReducer, or another built-in holding a value that re-renders (useTransition, useOptimistic, useActionState, useFormStatus); ref = useRef or useImperativeHandle; context = useContext or use(SomeContext); store = useSyncExternalStore (a value read from outside React that re-renders on change); hook = custom or library hook call (also use(promise)); derived = value computed during render, including useDeferredValue and useId; memo = useMemo; callback = useCallback; function = handler or helper; effect = useEffect/useLayoutEffect; render = a component element in the returned JSX, or a conditional / list branch of it, named as written, e.g. '<UserRow>'; constant = module-level value."
|
|
31
|
+
),
|
|
32
|
+
name: z.string().describe("The identifier as written, e.g. 'selectedId', 'handleClick', 'initials'. For effects, a short label, e.g. 'sync to localStorage'. For render items, the element as written, e.g. '<UserRow>', '<p>'."),
|
|
33
|
+
line: z.number().int().describe("1-based line in the component's file where it is declared."),
|
|
34
|
+
type: z.string().describe(
|
|
35
|
+
[
|
|
36
|
+
"TypeScript type, written as code, using the project's own type names where they exist (e.g. 'User[]' not 'object[]').",
|
|
37
|
+
"state / ref / derived / memo / constant: the value's type, e.g. 'number | null', 'HTMLInputElement | null', 'string'.",
|
|
38
|
+
"context / store / hook: the type of what it returns, e.g. '{ users: User[]; loading: boolean }'.",
|
|
39
|
+
"callback / function: its signature, e.g. '(id: number) => void'.",
|
|
40
|
+
"effect / render: empty string."
|
|
41
|
+
].join(" ")
|
|
42
|
+
),
|
|
43
|
+
initial: z.string().describe(`state / ref only: the initial value as code, e.g. '""', 'null', '[]'. Empty string otherwise.`),
|
|
44
|
+
returns: z.string().describe(
|
|
45
|
+
"function / callback: what calling it gives back, in plain words, e.g. 'nothing; tells the parent which user was clicked' or 'the formatted price'. effect: what its cleanup does, e.g. 'clears the pending timer', or 'no cleanup'. Empty string for other kinds."
|
|
46
|
+
),
|
|
47
|
+
when: z.string().describe(
|
|
48
|
+
"effect: when it runs: its dependency list like '[query]', 'on mount' or 'every render'. render: when it is shown: 'always', 'if <condition>' (e.g. 'if loading') or 'each <item>' (e.g. 'each user'). Empty string for other kinds."
|
|
49
|
+
),
|
|
50
|
+
explanation: short(60, "Shown when the developer clicks the item: what it does and why it exists, in plain language. Mention related items by name. No file paths.")
|
|
51
|
+
};
|
|
52
|
+
var CardSchema = z.object({
|
|
53
|
+
summary: short(40, "One short paragraph a new teammate understands: what this component is for in the product, and in plain terms how it does it."),
|
|
54
|
+
props: z.array(
|
|
55
|
+
z.object({
|
|
56
|
+
name: z.string(),
|
|
57
|
+
type: z.string().describe("TypeScript type as written, or a short guess if untyped, e.g. 'User', '() => void'."),
|
|
58
|
+
optional: z.boolean(),
|
|
59
|
+
description: short(30, "What this prop is and what the component does with it, in plain language. No file paths or line numbers.")
|
|
60
|
+
})
|
|
61
|
+
).describe("Every prop the component accepts, in declaration order. Empty if it takes none."),
|
|
62
|
+
outline: z.array(
|
|
63
|
+
z.object({
|
|
64
|
+
...outlineFields,
|
|
65
|
+
children: z.array(z.object({ ...outlineFields, children: z.array(z.object(outlineFields)) })).describe("Items inside this one: helpers declared inside a handler or effect, or for render items the components rendered inside it.")
|
|
66
|
+
})
|
|
67
|
+
).describe("The component's internals in source order, like an IDE outline: state, refs, context, hook calls, derived values, memos, callbacks, handlers, effects, then the notable parts of what it renders. Do not include the component itself as an item."),
|
|
68
|
+
usedIn: z.array(z.object({ ref: z.string().describe("Call site as project-relative path:line.") })).describe("Where this component is rendered."),
|
|
69
|
+
history: z.array(
|
|
70
|
+
z.object({
|
|
71
|
+
commit: z.string(),
|
|
72
|
+
date: z.string().optional(),
|
|
73
|
+
author: z.string().optional(),
|
|
74
|
+
why: short(25, "What changed and why, from the commit message.")
|
|
75
|
+
})
|
|
76
|
+
).describe("Only commits whose message explains intent. Empty if there is no useful history."),
|
|
77
|
+
fileNotes: z.array(
|
|
78
|
+
z.object({
|
|
79
|
+
file: z.string(),
|
|
80
|
+
summary: short(45, "Factual summary: what it exports, its responsibility, key behaviour."),
|
|
81
|
+
exports: z.array(
|
|
82
|
+
z.object({
|
|
83
|
+
name: z.string().describe("The exported name as importers write it; 'default' for the default export."),
|
|
84
|
+
description: short(
|
|
85
|
+
40,
|
|
86
|
+
"What an importer needs to use it without opening the file: what it does, what its arguments mean, what it returns, side effects (fetching, storage, timers, context it requires) and gotchas. Don't repeat the signature."
|
|
87
|
+
)
|
|
88
|
+
})
|
|
89
|
+
).describe("Every export of the file (functions, components, hooks, constants, types), in source order. Empty if it exports nothing.")
|
|
90
|
+
})
|
|
91
|
+
).describe("One entry for EVERY file you opened with Read in this session. Stored and given to future sessions (including ones about other components that import from these files) so they can skip re-reading.")
|
|
92
|
+
});
|
|
93
|
+
var AnswerSchema = z.object({
|
|
94
|
+
answer: z.string().describe("Markdown answer. Lead with the direct answer in one sentence, then at most a few short bullets. Under ~150 words."),
|
|
95
|
+
fileNotes: CardSchema.shape.fileNotes
|
|
96
|
+
});
|
|
97
|
+
var CARD_VERSION = 4;
|
|
98
|
+
function isCurrentCard(card) {
|
|
99
|
+
return !!card && typeof card === "object" && card.v === CARD_VERSION;
|
|
100
|
+
}
|
|
101
|
+
var toSchema = (s) => {
|
|
102
|
+
const { $schema: _, ...rest } = z.toJSONSchema(s);
|
|
103
|
+
return rest;
|
|
104
|
+
};
|
|
105
|
+
var cardJsonSchema = toSchema(CardSchema);
|
|
106
|
+
var answerJsonSchema = toSchema(AnswerSchema);
|
|
107
|
+
function cardToMarkdown(card) {
|
|
108
|
+
const out = ["## Overview", card.summary];
|
|
109
|
+
if (card.props.length) {
|
|
110
|
+
out.push("", "## Props");
|
|
111
|
+
for (const p of card.props) out.push(`- \`${p.name}${p.optional ? "?" : ""}: ${p.type}\`: ${p.description}`);
|
|
112
|
+
}
|
|
113
|
+
if (card.outline.length) {
|
|
114
|
+
out.push("", "## Inside");
|
|
115
|
+
const list = (items, indent) => {
|
|
116
|
+
for (const item of items) {
|
|
117
|
+
out.push(`${indent}- ${item.kind} \`${item.name}\` (line ${item.line}${facts(item)}): ${item.explanation}`);
|
|
118
|
+
list(item.children ?? [], `${indent} `);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
list(card.outline, "");
|
|
122
|
+
}
|
|
123
|
+
if (card.usedIn.length) {
|
|
124
|
+
out.push("", "## Where it's used");
|
|
125
|
+
for (const u of card.usedIn) out.push(`- \`${u.ref}\``);
|
|
126
|
+
}
|
|
127
|
+
if (card.history.length) {
|
|
128
|
+
out.push("", "## History");
|
|
129
|
+
for (const h of card.history) out.push(`- \`${h.commit}\`${h.date ? ` ${h.date}` : ""}${h.author ? ` ${h.author}` : ""}: ${h.why}`);
|
|
130
|
+
}
|
|
131
|
+
return out.join("\n");
|
|
132
|
+
}
|
|
133
|
+
function facts(item) {
|
|
134
|
+
const parts = [item.type && `type ${item.type}`, item.initial && `initially ${item.initial}`, item.when, item.returns && `returns ${item.returns}`].filter(Boolean);
|
|
135
|
+
return parts.length ? `, ${parts.join(", ")}` : "";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/server/explain.ts
|
|
139
|
+
var SYSTEM_PROMPT = `You are a senior engineer onboarding a developer onto a React codebase they did not write (often it was AI-generated). The developer pointed at a component in the running app and wants to understand it.
|
|
140
|
+
|
|
141
|
+
Explore the code with your read-only tools. Files whose source is included in the message are already read: do not open them again. Otherwise open the component's file, then follow imports, hooks, contexts, stores and API calls as far as needed, and grep for where the component is rendered. Prefer a few targeted reads over reading everything. Never guess about code you have not opened.
|
|
142
|
+
|
|
143
|
+
You may be given "verified file notes": summaries of files whose content has not changed since the summary was written. Trust them and do not re-read those files unless you need exact lines or details the note doesn't cover. This saves the developer time and money.
|
|
144
|
+
|
|
145
|
+
You may also be given "imported exports": what the component imports from project files, each with its signature (read from the current source) and a verified description. Trust them like file notes: don't open a file just to learn what an imported function, hook, component or constant does.
|
|
146
|
+
|
|
147
|
+
You may have read-only git tools (log, blame, show). Commit messages often explain intent, especially in AI-written code. Use them when the developer asks why, when or who, or when code looks odd and history would explain it; otherwise don't spend calls on them.
|
|
148
|
+
|
|
149
|
+
Your final answer is structured data that is rendered as a visual panel, not prose. The developer reads it top to bottom: overview, then props, then an IDE-style outline of the component's internals they can click to expand.
|
|
150
|
+
- Write in plain language for someone who has never seen this code. Short, concrete strings; no filler; don't repeat the same fact in two places.
|
|
151
|
+
- Whenever you mention a prop, state, ref, function, value or constant of this component by name, wrap it in backticks (\`focused\`, \`clear()\`, \`MIN_QUERY\`): the panel turns those into hoverable links to their definitions.
|
|
152
|
+
- Props and outline explanations describe what a thing is and does. Do not put file paths or line references in them; refer to other parts of the component by name instead.
|
|
153
|
+
- The outline mirrors the component's source in order, like VS Code's outline view. Include every state, ref, context read, custom hook call, derived value, memo, callback, handler and effect, plus what it renders: one render item per component element in the returned JSX (named as written, e.g. \`<UserRow>\`) and per conditional or list branch that is a plain element (e.g. \`<p>\` shown if \`error\`), nested like the JSX so the developer sees which component sits inside which. Use exact line numbers from the component's file. Do not add the component itself as an item; its internals are the top level. Otherwise nest only items declared inside another item (e.g. a helper inside an effect).
|
|
154
|
+
- If something is surprising or likely a bug, say so briefly in the relevant item's explanation.
|
|
155
|
+
- usedIn uses project-relative \`path:line\` references so they become clickable links.
|
|
156
|
+
- history holds only commits whose message explains intent; leave it empty otherwise.
|
|
157
|
+
- fileNotes must cover every file you opened with Read and every file whose source was included in the message, and describe every export of each of those files: other components that import them rely on these descriptions instead of reading the file.
|
|
158
|
+
|
|
159
|
+
When the developer asks a specific question, the answer field is short markdown: the direct answer first, then a few bullets at most.`;
|
|
160
|
+
async function explainComponent(req, opts) {
|
|
161
|
+
const abortController = new AbortController();
|
|
162
|
+
opts.signal?.addEventListener("abort", () => abortController.abort(), { once: true });
|
|
163
|
+
const stream = query({
|
|
164
|
+
prompt: buildPrompt(req, opts.context),
|
|
165
|
+
options: {
|
|
166
|
+
cwd: opts.root,
|
|
167
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
168
|
+
// Read-only: the agent may look at code but never change or execute anything.
|
|
169
|
+
tools: ["Read", "Grep", "Glob"],
|
|
170
|
+
allowedTools: ["Read", "Grep", "Glob", ...opts.git ? GIT_TOOL_NAMES : []],
|
|
171
|
+
mcpServers: opts.git ? { git: gitServer(opts.git, opts.root) } : void 0,
|
|
172
|
+
permissionMode: "dontAsk",
|
|
173
|
+
// Isolate from the developer's personal hooks/settings; we only want code exploration.
|
|
174
|
+
settingSources: [],
|
|
175
|
+
env: agentEnv(opts.apiKey),
|
|
176
|
+
maxTurns: opts.maxTurns ?? 30,
|
|
177
|
+
model: opts.model,
|
|
178
|
+
outputFormat: { type: "json_schema", schema: req.question ? answerJsonSchema : cardJsonSchema },
|
|
179
|
+
abortController
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
let raw = "";
|
|
183
|
+
let structured;
|
|
184
|
+
let costUsd;
|
|
185
|
+
let turns;
|
|
186
|
+
let modelUsed;
|
|
187
|
+
const filesRead = /* @__PURE__ */ new Set();
|
|
188
|
+
for await (const msg of stream) {
|
|
189
|
+
if (msg.type === "assistant" && !msg.parent_tool_use_id) {
|
|
190
|
+
for (const block of msg.message.content) {
|
|
191
|
+
if (block.type !== "tool_use") continue;
|
|
192
|
+
const input = block.input;
|
|
193
|
+
if (block.name === "Read" && typeof input.file_path === "string") {
|
|
194
|
+
const rel = toRelative(opts.root, input.file_path);
|
|
195
|
+
if (rel) filesRead.add(rel);
|
|
196
|
+
}
|
|
197
|
+
opts.onProgress?.(describeToolUse(block.name, input, opts.root));
|
|
198
|
+
}
|
|
199
|
+
} else if (msg.type === "result") {
|
|
200
|
+
costUsd = msg.total_cost_usd;
|
|
201
|
+
turns = msg.num_turns;
|
|
202
|
+
modelUsed = Object.keys(msg.modelUsage ?? {})[0];
|
|
203
|
+
if (process.env.DETECTIVE_DEBUG) console.log("[react-detective] usage", JSON.stringify({ turns, costUsd, usage: msg.usage, models: msg.modelUsage }));
|
|
204
|
+
if (msg.subtype === "success") {
|
|
205
|
+
raw = msg.result;
|
|
206
|
+
structured = msg.structured_output;
|
|
207
|
+
} else throw new Error(`Explanation failed (${msg.subtype})`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const base = { filesRead: [...filesRead], costUsd, turns, model: modelUsed };
|
|
211
|
+
if (req.question) {
|
|
212
|
+
const parsed2 = AnswerSchema.safeParse(structured);
|
|
213
|
+
if (parsed2.success) return { ...base, markdown: parsed2.data.answer, ...notesRecord(parsed2.data.fileNotes) };
|
|
214
|
+
} else {
|
|
215
|
+
const parsed2 = CardSchema.safeParse(structured);
|
|
216
|
+
if (parsed2.success) {
|
|
217
|
+
const { fileNotes, ...fields } = parsed2.data;
|
|
218
|
+
const card = { ...fields, v: CARD_VERSION };
|
|
219
|
+
return { ...base, card, markdown: cardToMarkdown(card), ...notesRecord(fileNotes) };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (!req.question || !raw.trim()) throw new Error("The agent returned no usable explanation.");
|
|
223
|
+
return { ...base, markdown: raw.trim(), notes: {}, exports: {} };
|
|
224
|
+
}
|
|
225
|
+
function notesRecord(list) {
|
|
226
|
+
const notes = {};
|
|
227
|
+
const exports = {};
|
|
228
|
+
for (const n of list) {
|
|
229
|
+
const file = n.file.replace(/\\/g, "/");
|
|
230
|
+
notes[file] = n.summary;
|
|
231
|
+
if (n.exports?.length) exports[file] = Object.fromEntries(n.exports.map((e) => [e.name, e.description]));
|
|
232
|
+
}
|
|
233
|
+
return { notes, exports };
|
|
234
|
+
}
|
|
235
|
+
function buildPrompt(req, ctx = {}) {
|
|
236
|
+
const lines = [];
|
|
237
|
+
const where = req.file ? `defined in \`${req.file}${req.line ? `:${req.line}` : ""}\`` : "(file unknown, so find it with Grep first)";
|
|
238
|
+
lines.push(`The developer is looking at the React component \`<${req.component}>\` ${where}.`);
|
|
239
|
+
if (req.chain?.length) lines.push(`In the page it is rendered inside: ${req.chain.map((c) => `<${c}>`).join(" > ")}.`);
|
|
240
|
+
if (req.elementText) lines.push(`They pointed at an element showing the text: "${req.elementText.slice(0, 200)}".`);
|
|
241
|
+
if (req.props !== void 0) lines.push(`Live props right now (serialized, may be truncated):
|
|
242
|
+
\`\`\`json
|
|
243
|
+
${JSON.stringify(req.props, null, 2).slice(0, 4e3)}
|
|
244
|
+
\`\`\``);
|
|
245
|
+
if (req.state !== void 0) lines.push(`Live hook state values, in hook order:
|
|
246
|
+
\`\`\`json
|
|
247
|
+
${JSON.stringify(req.state, null, 2).slice(0, 2e3)}
|
|
248
|
+
\`\`\``);
|
|
249
|
+
const notes = Object.entries(ctx.notes ?? {});
|
|
250
|
+
if (notes.length) {
|
|
251
|
+
lines.push(`
|
|
252
|
+
Verified file notes (these files are unchanged since summarized):`);
|
|
253
|
+
for (const [file, summary] of notes) lines.push(`- \`${file}\`: ${summary}`);
|
|
254
|
+
}
|
|
255
|
+
if (ctx.imports?.length) {
|
|
256
|
+
lines.push(`
|
|
257
|
+
Imported exports (what \`${req.file}\` imports from project files; signatures are read from the current source, descriptions are verified unchanged):`);
|
|
258
|
+
for (const i of ctx.imports) {
|
|
259
|
+
const where2 = `\`${i.declaredIn ?? i.from}:${i.line}\`${i.declaredIn ? ` (via \`${i.from}\`)` : ""}`;
|
|
260
|
+
lines.push(`- ${i.kind} \`${i.name}\`${i.signature ? ` \`${i.signature}\`` : ""} from ${where2}: ${i.description}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (ctx.usages) {
|
|
264
|
+
lines.push(
|
|
265
|
+
ctx.usages.length ? `
|
|
266
|
+
Where \`<${req.component}>\` is rendered (text search for \`<${req.component}\` across the project; no need to grep for this again):
|
|
267
|
+
${ctx.usages.map((u) => `- \`${u}\``).join("\n")}` : `
|
|
268
|
+
A text search found no \`<${req.component}\` in the project source (it may be rendered under another name, e.g. via a variable or re-export).`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
for (const [file, text] of Object.entries(ctx.sources ?? {})) {
|
|
272
|
+
const numbered = text.split("\n").map((l, i) => `${String(i + 1).padStart(4)} ${l}`).join("\n");
|
|
273
|
+
lines.push(`
|
|
274
|
+
Source of \`${file}\` (line-numbered, current version; this counts as having read it, so do not open it with Read):
|
|
275
|
+
\`\`\`
|
|
276
|
+
${numbered}
|
|
277
|
+
\`\`\``);
|
|
278
|
+
}
|
|
279
|
+
if (ctx.history?.length) {
|
|
280
|
+
lines.push(`
|
|
281
|
+
Recent commits touching \`${req.file}\` (newest first):`);
|
|
282
|
+
for (const c of ctx.history) lines.push(`- \`${c.sha}\` ${c.date} ${c.author}: ${c.subject}`);
|
|
283
|
+
}
|
|
284
|
+
if (ctx.explanation) lines.push(`
|
|
285
|
+
Current explanation of this component (still accurate):
|
|
286
|
+
<explanation>
|
|
287
|
+
${ctx.explanation}
|
|
288
|
+
</explanation>`);
|
|
289
|
+
const own = ctx.pastQuestions?.filter((q) => !q.component) ?? [];
|
|
290
|
+
const others = ctx.pastQuestions?.filter((q) => q.component) ?? [];
|
|
291
|
+
if (own.length || others.length) {
|
|
292
|
+
lines.push(
|
|
293
|
+
req.question ? `
|
|
294
|
+
Questions developers already asked (answers verified still accurate: the code they relied on is unchanged). Build on them instead of re-investigating what they already established:` : `
|
|
295
|
+
Questions developers already asked (answers verified still accurate: the code they relied on is unchanged). Use what they established: put any findings that belong to this component (bugs, gotchas, why something works the way it does) into the relevant prop or outline explanation, briefly, and skip re-investigating them:`
|
|
296
|
+
);
|
|
297
|
+
for (const q of own) lines.push(`<qa about="${req.component}">
|
|
298
|
+
Q: ${q.question}
|
|
299
|
+
A: ${q.answer}
|
|
300
|
+
</qa>`);
|
|
301
|
+
for (const q of others) lines.push(`<qa about="${q.component}" related="true">
|
|
302
|
+
Q: ${q.question}
|
|
303
|
+
A: ${q.answer}
|
|
304
|
+
</qa>`);
|
|
305
|
+
}
|
|
306
|
+
if (req.question) {
|
|
307
|
+
lines.push(`
|
|
308
|
+
The developer's question: ${req.question}`);
|
|
309
|
+
} else if (ctx.previous) {
|
|
310
|
+
const { changes, markdown } = ctx.previous;
|
|
311
|
+
const changedFiles = changes.map((c) => c.file);
|
|
312
|
+
const unchanged = ctx.previous.unchanged.filter((f) => !changedFiles.includes(f));
|
|
313
|
+
lines.push(`
|
|
314
|
+
An earlier explanation of this component exists, but some files it relied on have changed since it was written:`);
|
|
315
|
+
for (const c of changes) {
|
|
316
|
+
if (c.deleted) lines.push(`- \`${c.file}\` was deleted.`);
|
|
317
|
+
else if (c.diff) lines.push(`- \`${c.file}\` changed. Diff from the explained version to now:
|
|
318
|
+
\`\`\`diff
|
|
319
|
+
${c.diff}
|
|
320
|
+
\`\`\``);
|
|
321
|
+
else lines.push(`- \`${c.file}\` changed (no diff available, so re-read it).`);
|
|
322
|
+
}
|
|
323
|
+
if (unchanged.length) {
|
|
324
|
+
lines.push(
|
|
325
|
+
`These files it relied on are unchanged, so do NOT open them again; the previous explanation and the notes above already cover them: ${unchanged.map((f) => `\`${f}\``).join(", ")}.`
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
lines.push(
|
|
329
|
+
`Where a diff is shown, use it instead of re-reading the file unless you need surrounding context. Then produce the updated explanation, keeping everything that is still correct.`,
|
|
330
|
+
`<previous_explanation>
|
|
331
|
+
${markdown}
|
|
332
|
+
</previous_explanation>`
|
|
333
|
+
);
|
|
334
|
+
} else {
|
|
335
|
+
lines.push(`
|
|
336
|
+
Explain this component.`);
|
|
337
|
+
}
|
|
338
|
+
return lines.join("\n");
|
|
339
|
+
}
|
|
340
|
+
function toRelative(root, file) {
|
|
341
|
+
const rel = path.relative(root, path.resolve(root, file));
|
|
342
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
343
|
+
return rel.split(path.sep).join("/");
|
|
344
|
+
}
|
|
345
|
+
var GIT_TOOL_NAMES = ["mcp__git__log", "mcp__git__blame", "mcp__git__show"];
|
|
346
|
+
function gitServer(git, root) {
|
|
347
|
+
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
348
|
+
const outside = text("Path is outside the project.");
|
|
349
|
+
return createSdkMcpServer({
|
|
350
|
+
name: "git",
|
|
351
|
+
tools: [
|
|
352
|
+
tool(
|
|
353
|
+
"log",
|
|
354
|
+
"Commit history (hash, date, author, full message) for a project file, newest first. Use it to learn why code was written or changed.",
|
|
355
|
+
{ path: z2.string().describe("Project-relative file path"), limit: z2.number().int().min(1).max(30).optional() },
|
|
356
|
+
async ({ path: p, limit }) => {
|
|
357
|
+
const rel = toRelative(root, p);
|
|
358
|
+
return rel ? text(await git.logText(rel, limit ?? 10)) : outside;
|
|
359
|
+
},
|
|
360
|
+
{ annotations: { readOnlyHint: true } }
|
|
361
|
+
),
|
|
362
|
+
tool(
|
|
363
|
+
"blame",
|
|
364
|
+
"Which commit last changed each line in a range of a project file.",
|
|
365
|
+
{ path: z2.string().describe("Project-relative file path"), start_line: z2.number().int().min(1), end_line: z2.number().int().min(1) },
|
|
366
|
+
async ({ path: p, start_line, end_line }) => {
|
|
367
|
+
const rel = toRelative(root, p);
|
|
368
|
+
return rel ? text(await git.blameText(rel, start_line, end_line)) : outside;
|
|
369
|
+
},
|
|
370
|
+
{ annotations: { readOnlyHint: true } }
|
|
371
|
+
),
|
|
372
|
+
tool(
|
|
373
|
+
"show",
|
|
374
|
+
"Full message, changed files and patch of one commit.",
|
|
375
|
+
{ commit: z2.string().describe("Commit hash (short or full)") },
|
|
376
|
+
async ({ commit }) => text(await git.showText(commit)),
|
|
377
|
+
{ annotations: { readOnlyHint: true } }
|
|
378
|
+
)
|
|
379
|
+
]
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
function describeToolUse(name, input, root) {
|
|
383
|
+
const rel = (p) => p ? toRelative(root, p) ?? p : "";
|
|
384
|
+
switch (name) {
|
|
385
|
+
case "mcp__git__log":
|
|
386
|
+
return `Checking git history of ${rel(input.path)}`;
|
|
387
|
+
case "mcp__git__blame":
|
|
388
|
+
return `Running git blame on ${rel(input.path)}:${input.start_line}-${input.end_line}`;
|
|
389
|
+
case "mcp__git__show":
|
|
390
|
+
return `Reading commit ${input.commit}`;
|
|
391
|
+
case "Read":
|
|
392
|
+
return `Reading ${rel(input.file_path)}`;
|
|
393
|
+
case "Grep":
|
|
394
|
+
return `Searching for \u201C${input.pattern}\u201D${input.path ? ` in ${rel(input.path)}` : ""}`;
|
|
395
|
+
case "Glob":
|
|
396
|
+
return `Looking for files matching ${input.pattern}`;
|
|
397
|
+
default:
|
|
398
|
+
return `Using ${name}`;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
function agentEnv(apiKey) {
|
|
402
|
+
const env = {};
|
|
403
|
+
for (const [k, v] of Object.entries(process.env)) if (v !== void 0) env[k] = v;
|
|
404
|
+
delete env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
405
|
+
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
406
|
+
env.ANTHROPIC_API_KEY = apiKey;
|
|
407
|
+
return env;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// src/server/handler.ts
|
|
411
|
+
import path9 from "path";
|
|
412
|
+
import launchEditor from "launch-editor";
|
|
413
|
+
import { createTwoFilesPatch } from "diff";
|
|
414
|
+
|
|
415
|
+
// src/server/store.ts
|
|
416
|
+
import { createHash } from "crypto";
|
|
417
|
+
import fs from "fs";
|
|
418
|
+
import path2 from "path";
|
|
419
|
+
var README = `# .react-detective
|
|
420
|
+
|
|
421
|
+
Knowledge collected by [react-detective](https://github.com/) while explaining this codebase.
|
|
422
|
+
|
|
423
|
+
- \`components/\` holds one file per component: the explanation and every question asked about it.
|
|
424
|
+
Each answer records the files the AI read and their git blob ids, so it is only reused while
|
|
425
|
+
that code is unchanged, and the commit it was written at.
|
|
426
|
+
- \`files.json\` holds short AI-written summaries of source files and of each thing they export,
|
|
427
|
+
keyed by content hash. They let the AI skip re-reading files that haven't changed, including
|
|
428
|
+
when it explains another component that imports from them.
|
|
429
|
+
|
|
430
|
+
Commit this folder so your team shares explanations instead of paying for them again.
|
|
431
|
+
Delete any file to force it to be regenerated.
|
|
432
|
+
`;
|
|
433
|
+
var KnowledgeStore = class {
|
|
434
|
+
dir;
|
|
435
|
+
root;
|
|
436
|
+
/** Local-only copies of file versions we explained, so we can diff against them later. */
|
|
437
|
+
blobDir;
|
|
438
|
+
hashMemo = /* @__PURE__ */ new Map();
|
|
439
|
+
notes = null;
|
|
440
|
+
constructor(root, dirName = ".react-detective") {
|
|
441
|
+
this.root = root;
|
|
442
|
+
this.dir = path2.join(root, dirName);
|
|
443
|
+
this.blobDir = path2.join(root, "node_modules", ".cache", "react-detective", "blobs");
|
|
444
|
+
}
|
|
445
|
+
// ---------- hashing ----------
|
|
446
|
+
/** Normalized content of a project file, or null if it doesn't exist. */
|
|
447
|
+
readFile(rel) {
|
|
448
|
+
try {
|
|
449
|
+
return fs.readFileSync(path2.join(this.root, rel), "utf8").replace(/\r\n/g, "\n");
|
|
450
|
+
} catch {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Fingerprint of a project file (its git blob id), or null if it doesn't exist.
|
|
456
|
+
* Line endings are normalized so Windows and Unix checkouts agree.
|
|
457
|
+
*/
|
|
458
|
+
hashFile(rel) {
|
|
459
|
+
let stat;
|
|
460
|
+
try {
|
|
461
|
+
stat = fs.statSync(path2.join(this.root, rel));
|
|
462
|
+
} catch {
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
const memo = this.hashMemo.get(rel);
|
|
466
|
+
if (memo && memo.mtimeMs === stat.mtimeMs && memo.size === stat.size) return memo.hash;
|
|
467
|
+
const content = this.readFile(rel);
|
|
468
|
+
if (content === null) return null;
|
|
469
|
+
const hash = blobHash(content);
|
|
470
|
+
this.hashMemo.set(rel, { mtimeMs: stat.mtimeMs, size: stat.size, hash });
|
|
471
|
+
return hash;
|
|
472
|
+
}
|
|
473
|
+
/** Keeps a local copy of each dependency's current content, so later diffs work even for never-committed versions. */
|
|
474
|
+
saveBlobs(deps) {
|
|
475
|
+
for (const [rel, hash] of Object.entries(deps)) {
|
|
476
|
+
const file = path2.join(this.blobDir, hash);
|
|
477
|
+
if (fs.existsSync(file)) continue;
|
|
478
|
+
const content = this.readFile(rel);
|
|
479
|
+
if (content === null || blobHash(content) !== hash) continue;
|
|
480
|
+
try {
|
|
481
|
+
fs.mkdirSync(this.blobDir, { recursive: true });
|
|
482
|
+
fs.writeFileSync(file, content);
|
|
483
|
+
} catch {
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
readBlob(hash) {
|
|
488
|
+
if (!/^[0-9a-f]{40}$/.test(hash)) return null;
|
|
489
|
+
try {
|
|
490
|
+
return fs.readFileSync(path2.join(this.blobDir, hash), "utf8");
|
|
491
|
+
} catch {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
snapshot(files) {
|
|
496
|
+
const deps = {};
|
|
497
|
+
for (const f of [...new Set(files)].sort()) {
|
|
498
|
+
const h = this.hashFile(f);
|
|
499
|
+
if (h) deps[f] = h;
|
|
500
|
+
}
|
|
501
|
+
return deps;
|
|
502
|
+
}
|
|
503
|
+
check(deps) {
|
|
504
|
+
const changed = Object.entries(deps).filter(([f, h]) => this.hashFile(f) !== h).map(([f]) => f);
|
|
505
|
+
return { fresh: changed.length === 0, changed };
|
|
506
|
+
}
|
|
507
|
+
// ---------- components ----------
|
|
508
|
+
getComponent(component, file) {
|
|
509
|
+
try {
|
|
510
|
+
return JSON.parse(fs.readFileSync(this.componentPath(component, file), "utf8"));
|
|
511
|
+
} catch {
|
|
512
|
+
return { component, file: file ?? null, questions: [] };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
saveExplanation(component, file, entry) {
|
|
516
|
+
const rec = this.getComponent(component, file);
|
|
517
|
+
rec.explanation = entry;
|
|
518
|
+
this.writeComponent(rec, file);
|
|
519
|
+
}
|
|
520
|
+
saveQuestion(component, file, entry) {
|
|
521
|
+
const rec = this.getComponent(component, file);
|
|
522
|
+
const key = normalizeQuestion(entry.question);
|
|
523
|
+
rec.questions = rec.questions.filter((q) => normalizeQuestion(q.question) !== key);
|
|
524
|
+
rec.questions.push(entry);
|
|
525
|
+
this.writeComponent(rec, file);
|
|
526
|
+
}
|
|
527
|
+
findQuestion(rec, question) {
|
|
528
|
+
const key = normalizeQuestion(question);
|
|
529
|
+
return rec.questions.find((q) => normalizeQuestion(q.question) === key);
|
|
530
|
+
}
|
|
531
|
+
/** Every saved component record, for finding knowledge about related components. */
|
|
532
|
+
listComponents() {
|
|
533
|
+
const dir = path2.join(this.dir, "components");
|
|
534
|
+
let names;
|
|
535
|
+
try {
|
|
536
|
+
names = fs.readdirSync(dir).filter((n) => n.endsWith(".json"));
|
|
537
|
+
} catch {
|
|
538
|
+
return [];
|
|
539
|
+
}
|
|
540
|
+
const out = [];
|
|
541
|
+
for (const name of names) {
|
|
542
|
+
try {
|
|
543
|
+
out.push(JSON.parse(fs.readFileSync(path2.join(dir, name), "utf8")));
|
|
544
|
+
} catch {
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return out;
|
|
548
|
+
}
|
|
549
|
+
componentPath(component, file) {
|
|
550
|
+
const safeFile = (file ?? "_unknown").replace(/[\\/]/g, "--").replace(/[^\w.\-[\]()@]/g, "_");
|
|
551
|
+
return path2.join(this.dir, "components", `${safeFile}--${component.replace(/[^\w]/g, "_")}.json`);
|
|
552
|
+
}
|
|
553
|
+
writeComponent(rec, file) {
|
|
554
|
+
this.write(this.componentPath(rec.component, file), rec);
|
|
555
|
+
}
|
|
556
|
+
// ---------- file notes ----------
|
|
557
|
+
/** Notes whose file content is unchanged since they were written. */
|
|
558
|
+
validNotes(files) {
|
|
559
|
+
const out = {};
|
|
560
|
+
for (const f of files) {
|
|
561
|
+
const note = this.validNote(f);
|
|
562
|
+
if (note) out[f] = note.summary;
|
|
563
|
+
}
|
|
564
|
+
return out;
|
|
565
|
+
}
|
|
566
|
+
/** Saved descriptions of a file's exports, if the file is unchanged since they were written. */
|
|
567
|
+
validExports(file) {
|
|
568
|
+
return this.validNote(file)?.exports ?? {};
|
|
569
|
+
}
|
|
570
|
+
validNote(file) {
|
|
571
|
+
const note = this.loadNotes()[file];
|
|
572
|
+
return note && this.hashFile(file) === note.hash ? note : void 0;
|
|
573
|
+
}
|
|
574
|
+
noteFiles() {
|
|
575
|
+
return Object.keys(this.loadNotes());
|
|
576
|
+
}
|
|
577
|
+
saveNotes(summaries, deps, exports = {}) {
|
|
578
|
+
const all = this.loadNotes();
|
|
579
|
+
let changed = false;
|
|
580
|
+
for (const [file, summary] of Object.entries(summaries)) {
|
|
581
|
+
const hash = deps[file];
|
|
582
|
+
if (!hash || !summary?.trim()) continue;
|
|
583
|
+
const kept = all[file]?.hash === hash ? all[file].exports : void 0;
|
|
584
|
+
const described = Object.entries(exports[file] ?? {}).filter(([name, d]) => name.trim() && d?.trim());
|
|
585
|
+
const merged = { ...kept, ...Object.fromEntries(described.map(([name, d]) => [name.trim(), d.trim()])) };
|
|
586
|
+
all[file] = { hash, summary: summary.trim(), ...Object.keys(merged).length ? { exports: merged } : {}, updatedAt: Date.now() };
|
|
587
|
+
changed = true;
|
|
588
|
+
}
|
|
589
|
+
if (changed) this.write(path2.join(this.dir, "files.json"), sortKeys(all));
|
|
590
|
+
}
|
|
591
|
+
loadNotes() {
|
|
592
|
+
if (!this.notes) {
|
|
593
|
+
try {
|
|
594
|
+
this.notes = JSON.parse(fs.readFileSync(path2.join(this.dir, "files.json"), "utf8"));
|
|
595
|
+
} catch {
|
|
596
|
+
this.notes = {};
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return this.notes;
|
|
600
|
+
}
|
|
601
|
+
// ---------- io ----------
|
|
602
|
+
write(file, data) {
|
|
603
|
+
try {
|
|
604
|
+
fs.mkdirSync(path2.dirname(file), { recursive: true });
|
|
605
|
+
const readme = path2.join(this.dir, "README.md");
|
|
606
|
+
if (!fs.existsSync(readme)) fs.writeFileSync(readme, README);
|
|
607
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
|
|
608
|
+
} catch (err) {
|
|
609
|
+
console.warn(`[react-detective] could not write ${file}: ${err.message}`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
function blobHash(content) {
|
|
614
|
+
const buf = Buffer.from(content, "utf8");
|
|
615
|
+
return createHash("sha1").update(`blob ${buf.length}\0`).update(buf).digest("hex");
|
|
616
|
+
}
|
|
617
|
+
function normalizeQuestion(q) {
|
|
618
|
+
return q.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
619
|
+
}
|
|
620
|
+
function sortKeys(obj) {
|
|
621
|
+
return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
|
|
622
|
+
}
|
|
623
|
+
function localImports(root, rel) {
|
|
624
|
+
let code;
|
|
625
|
+
try {
|
|
626
|
+
code = fs.readFileSync(path2.join(root, rel), "utf8");
|
|
627
|
+
} catch {
|
|
628
|
+
return [];
|
|
629
|
+
}
|
|
630
|
+
const out = [];
|
|
631
|
+
const re = /(?:from\s+|import\s*\(\s*|import\s+)["']([^"']+)["']/g;
|
|
632
|
+
for (const m of code.matchAll(re)) {
|
|
633
|
+
const hit = resolveImport(root, rel, m[1]);
|
|
634
|
+
if (hit) out.push(hit);
|
|
635
|
+
}
|
|
636
|
+
return [...new Set(out)];
|
|
637
|
+
}
|
|
638
|
+
function resolveModule(root, fromRel, spec) {
|
|
639
|
+
return resolveImport(root, fromRel, spec) ?? resolvePackage(root, fromRel, spec);
|
|
640
|
+
}
|
|
641
|
+
function resolvePackage(root, fromRel, spec) {
|
|
642
|
+
const parts = spec.split("/");
|
|
643
|
+
const name = spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
|
|
644
|
+
const sub = spec.slice(name.length + 1);
|
|
645
|
+
if (!name || name.startsWith(".")) return null;
|
|
646
|
+
for (let dir = path2.dirname(path2.join(root, fromRel)); ; dir = path2.dirname(dir)) {
|
|
647
|
+
const pkgDir = path2.join(dir, "node_modules", name);
|
|
648
|
+
let pkg = null;
|
|
649
|
+
try {
|
|
650
|
+
pkg = JSON.parse(fs.readFileSync(path2.join(pkgDir, "package.json"), "utf8"));
|
|
651
|
+
} catch {
|
|
652
|
+
}
|
|
653
|
+
if (pkg) {
|
|
654
|
+
const entry = exportTarget(pkg.exports, sub ? `./${sub}` : ".") ?? (sub ? `./${sub}` : pkg.module ?? pkg.main ?? "index.js");
|
|
655
|
+
const hit = resolveFile(root, path2.relative(root, path2.join(pkgDir, entry)));
|
|
656
|
+
if (hit) return hit;
|
|
657
|
+
}
|
|
658
|
+
if (path2.dirname(dir) === dir) return null;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
function exportTarget(exports, subpath) {
|
|
662
|
+
const pick = (t) => typeof t === "string" ? t : t && typeof t === "object" ? pick(t.import) ?? pick(t.module) ?? pick(t.browser) ?? pick(t.default) ?? null : null;
|
|
663
|
+
if (typeof exports === "string") return subpath === "." ? exports : null;
|
|
664
|
+
if (!exports || typeof exports !== "object") return null;
|
|
665
|
+
if (!Object.keys(exports).some((k) => k.startsWith("."))) return subpath === "." ? pick(exports) : null;
|
|
666
|
+
return pick(exports[subpath]);
|
|
667
|
+
}
|
|
668
|
+
function resolveImport(root, fromRel, spec) {
|
|
669
|
+
const bases = [];
|
|
670
|
+
if (spec.startsWith(".")) bases.push(path2.join(path2.dirname(fromRel), spec));
|
|
671
|
+
else if (spec.startsWith("@/") || spec.startsWith("~/")) bases.push(spec.slice(2), path2.join("src", spec.slice(2)));
|
|
672
|
+
for (const base of bases) {
|
|
673
|
+
const hit = resolveFile(root, base);
|
|
674
|
+
if (hit) return hit;
|
|
675
|
+
}
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
var EXTS = ["", ".tsx", ".ts", ".jsx", ".js", ".mjs", "/index.tsx", "/index.ts", "/index.jsx", "/index.js"];
|
|
679
|
+
function resolveFile(root, base) {
|
|
680
|
+
for (const ext of EXTS) {
|
|
681
|
+
const candidate = base + ext;
|
|
682
|
+
try {
|
|
683
|
+
if (fs.statSync(path2.join(root, candidate)).isFile()) return candidate.split(path2.sep).join("/");
|
|
684
|
+
} catch {
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// src/server/exports.ts
|
|
691
|
+
import { parse } from "@babel/parser";
|
|
692
|
+
import fs2 from "fs";
|
|
693
|
+
import path3 from "path";
|
|
694
|
+
var MAX_SIGNATURE = 200;
|
|
695
|
+
var MAX_DEPTH = 4;
|
|
696
|
+
var cache = /* @__PURE__ */ new Map();
|
|
697
|
+
function exportsOf(root, rel, depth = 0) {
|
|
698
|
+
const info = fileInfo(root, rel);
|
|
699
|
+
const out = /* @__PURE__ */ new Map();
|
|
700
|
+
if (!info) return out;
|
|
701
|
+
for (const [name, e] of info.own) out.set(name, e);
|
|
702
|
+
if (depth >= MAX_DEPTH) return out;
|
|
703
|
+
for (const [name, [spec, there]] of info.reexports) {
|
|
704
|
+
const target = resolveImport(root, rel, spec);
|
|
705
|
+
const hit = target && exportsOf(root, target, depth + 1).get(there);
|
|
706
|
+
if (hit) out.set(name, { ...hit, name });
|
|
707
|
+
}
|
|
708
|
+
for (const spec of info.star) {
|
|
709
|
+
const target = resolveImport(root, rel, spec);
|
|
710
|
+
if (!target) continue;
|
|
711
|
+
for (const [name, e] of exportsOf(root, target, depth + 1)) if (name !== "default" && !out.has(name)) out.set(name, e);
|
|
712
|
+
}
|
|
713
|
+
return out;
|
|
714
|
+
}
|
|
715
|
+
function importsOf(root, rel) {
|
|
716
|
+
const info = fileInfo(root, rel);
|
|
717
|
+
if (!info) return [];
|
|
718
|
+
const out = /* @__PURE__ */ new Map();
|
|
719
|
+
for (const [spec, names] of info.imports) {
|
|
720
|
+
const file = resolveImport(root, rel, spec);
|
|
721
|
+
if (!file) continue;
|
|
722
|
+
const set = out.get(file) ?? /* @__PURE__ */ new Set();
|
|
723
|
+
names.forEach((n) => set.add(n));
|
|
724
|
+
out.set(file, set);
|
|
725
|
+
}
|
|
726
|
+
return [...out].map(([file, names]) => ({ file, names: [...names] }));
|
|
727
|
+
}
|
|
728
|
+
function fileInfo(root, rel) {
|
|
729
|
+
const abs = path3.join(root, rel);
|
|
730
|
+
let stat;
|
|
731
|
+
try {
|
|
732
|
+
stat = fs2.statSync(abs);
|
|
733
|
+
} catch {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
const hit = cache.get(abs);
|
|
737
|
+
if (hit && hit.mtimeMs === stat.mtimeMs) return hit.info;
|
|
738
|
+
let info = null;
|
|
739
|
+
try {
|
|
740
|
+
info = readInfo(rel, fs2.readFileSync(abs, "utf8").replace(/\r\n/g, "\n"));
|
|
741
|
+
} catch {
|
|
742
|
+
}
|
|
743
|
+
cache.set(abs, { mtimeMs: stat.mtimeMs, info });
|
|
744
|
+
return info;
|
|
745
|
+
}
|
|
746
|
+
function readInfo(rel, source) {
|
|
747
|
+
const ast = parse(source, { sourceType: "module", errorRecovery: true, plugins: ["typescript", "jsx", "decorators-legacy"] });
|
|
748
|
+
const src = (n) => n ? source.slice(n.start, n.end) : "";
|
|
749
|
+
const info = { own: /* @__PURE__ */ new Map(), reexports: /* @__PURE__ */ new Map(), star: [], imports: /* @__PURE__ */ new Map() };
|
|
750
|
+
const declared = /* @__PURE__ */ new Map();
|
|
751
|
+
const addOwn = (name, d) => info.own.set(name, { name, declaredAs: name, ...d });
|
|
752
|
+
const pendingLocal = [];
|
|
753
|
+
const declare = (node) => {
|
|
754
|
+
const line = node.loc?.start.line ?? 0;
|
|
755
|
+
const base = { file: rel, line };
|
|
756
|
+
switch (node.type) {
|
|
757
|
+
case "FunctionDeclaration":
|
|
758
|
+
return node.id ? [[node.id.name, { ...base, local: node.id.name, kind: fnKind(node.id.name), signature: signature(node, src) }]] : [];
|
|
759
|
+
case "ClassDeclaration":
|
|
760
|
+
return node.id ? [[node.id.name, { ...base, local: node.id.name, kind: "class", signature: "" }]] : [];
|
|
761
|
+
case "TSInterfaceDeclaration":
|
|
762
|
+
return [[node.id.name, { ...base, local: node.id.name, kind: "interface", signature: clip(src(node.body)) }]];
|
|
763
|
+
case "TSTypeAliasDeclaration":
|
|
764
|
+
return [[node.id.name, { ...base, local: node.id.name, kind: "type", signature: clip(src(node.typeAnnotation)) }]];
|
|
765
|
+
case "TSEnumDeclaration":
|
|
766
|
+
return [[node.id.name, { ...base, local: node.id.name, kind: "enum", signature: "" }]];
|
|
767
|
+
case "VariableDeclaration":
|
|
768
|
+
return node.declarations.filter((d) => d.id.type === "Identifier").map((d) => {
|
|
769
|
+
const fn = functionOf(d.init);
|
|
770
|
+
const name = d.id.name;
|
|
771
|
+
const annotated = src(d.id.typeAnnotation?.typeAnnotation);
|
|
772
|
+
const kind = fn ? fnKind(name) : node.kind === "const" ? "const" : "variable";
|
|
773
|
+
const sig = annotated || (fn ? signature(fn, src) : kind === "const" && d.init && !/^(Call|New)Expression$/.test(d.init.type) ? src(d.init) : "");
|
|
774
|
+
return [name, { file: rel, line: d.loc?.start.line ?? line, local: name, kind, signature: clip(sig) }];
|
|
775
|
+
});
|
|
776
|
+
default:
|
|
777
|
+
return [];
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
for (const node of ast.program.body) {
|
|
781
|
+
if (node.type === "ImportDeclaration") {
|
|
782
|
+
if (node.importKind === "type" && !node.specifiers.length) continue;
|
|
783
|
+
const names = info.imports.get(node.source.value) ?? /* @__PURE__ */ new Set();
|
|
784
|
+
for (const s of node.specifiers) {
|
|
785
|
+
if (s.type === "ImportDefaultSpecifier") names.add("default");
|
|
786
|
+
else if (s.type === "ImportNamespaceSpecifier") names.add("*");
|
|
787
|
+
else names.add(s.imported.name ?? s.imported.value);
|
|
788
|
+
}
|
|
789
|
+
info.imports.set(node.source.value, names);
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
if (node.type === "ExportAllDeclaration") {
|
|
793
|
+
info.star.push(node.source.value);
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
if (node.type === "ExportNamedDeclaration") {
|
|
797
|
+
if (node.declaration) {
|
|
798
|
+
for (const [name, d] of declare(node.declaration)) addOwn(name, d);
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
for (const s of node.specifiers) {
|
|
802
|
+
const exported = s.exported.name ?? s.exported.value;
|
|
803
|
+
const local = s.local?.name ?? s.local?.value;
|
|
804
|
+
if (node.source) info.reexports.set(exported, [node.source.value, s.type === "ExportNamespaceSpecifier" ? "*" : local]);
|
|
805
|
+
else pendingLocal.push([exported, local]);
|
|
806
|
+
}
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
if (node.type === "ExportDefaultDeclaration") {
|
|
810
|
+
const decl = node.declaration;
|
|
811
|
+
if (decl.type === "Identifier") pendingLocal.push(["default", decl.name]);
|
|
812
|
+
else {
|
|
813
|
+
const own = declare(decl)[0];
|
|
814
|
+
if (own) {
|
|
815
|
+
addOwn("default", own[1]);
|
|
816
|
+
declared.set(own[0], own[1]);
|
|
817
|
+
} else {
|
|
818
|
+
const fn = functionOf(decl);
|
|
819
|
+
const line = decl.loc?.start.line ?? 0;
|
|
820
|
+
addOwn("default", {
|
|
821
|
+
file: rel,
|
|
822
|
+
line,
|
|
823
|
+
local: "default",
|
|
824
|
+
kind: fn ? "function" : "const",
|
|
825
|
+
signature: clip(fn ? signature(fn, src) : src(decl))
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
for (const [name, d] of declare(node)) declared.set(name, d);
|
|
832
|
+
}
|
|
833
|
+
for (const [exported, local] of pendingLocal) {
|
|
834
|
+
const d = declared.get(local);
|
|
835
|
+
if (d) addOwn(exported, d);
|
|
836
|
+
}
|
|
837
|
+
return info;
|
|
838
|
+
}
|
|
839
|
+
function fnKind(name) {
|
|
840
|
+
return /^use[A-Z0-9]/.test(name) ? "hook" : /^[A-Z]/.test(name) ? "component" : "function";
|
|
841
|
+
}
|
|
842
|
+
function functionOf(node) {
|
|
843
|
+
while (node?.type === "CallExpression") node = node.arguments.find((a) => /Function/.test(a.type)) ?? node.arguments[0];
|
|
844
|
+
return node && /Function/.test(node.type) ? node : null;
|
|
845
|
+
}
|
|
846
|
+
function signature(fn, src) {
|
|
847
|
+
const ret = src(fn.returnType?.typeAnnotation);
|
|
848
|
+
return clip(`(${fn.params.map(src).join(", ")})${ret ? ` => ${ret}` : ""}`);
|
|
849
|
+
}
|
|
850
|
+
function clip(text) {
|
|
851
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
852
|
+
return flat.length > MAX_SIGNATURE ? flat.slice(0, MAX_SIGNATURE - 1) + "\u2026" : flat;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// src/server/git.ts
|
|
856
|
+
import { execFile } from "child_process";
|
|
857
|
+
var SEP = "";
|
|
858
|
+
var Git = class {
|
|
859
|
+
repo = null;
|
|
860
|
+
cwd;
|
|
861
|
+
constructor(cwd) {
|
|
862
|
+
this.cwd = cwd;
|
|
863
|
+
}
|
|
864
|
+
isRepo() {
|
|
865
|
+
this.repo ??= this.run(["rev-parse", "--is-inside-work-tree"]).then((out) => out?.trim() === "true");
|
|
866
|
+
return this.repo;
|
|
867
|
+
}
|
|
868
|
+
async head() {
|
|
869
|
+
if (!await this.isRepo()) return null;
|
|
870
|
+
const [commit, branch] = await Promise.all([
|
|
871
|
+
this.run(["rev-parse", "--short", "HEAD"]),
|
|
872
|
+
this.run(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
873
|
+
]);
|
|
874
|
+
return commit ? { commit: commit.trim(), branch: branch?.trim() ?? "" } : null;
|
|
875
|
+
}
|
|
876
|
+
/** True if any of the files have uncommitted changes (or are untracked). */
|
|
877
|
+
async isDirty(files) {
|
|
878
|
+
if (!files.length || !await this.isRepo()) return false;
|
|
879
|
+
const out = await this.run(["status", "--porcelain", "--", ...files]);
|
|
880
|
+
return !!out?.trim();
|
|
881
|
+
}
|
|
882
|
+
async catBlob(sha) {
|
|
883
|
+
if (!/^[0-9a-f]{40}$/.test(sha) || !await this.isRepo()) return null;
|
|
884
|
+
return this.run(["cat-file", "blob", sha]);
|
|
885
|
+
}
|
|
886
|
+
async recentCommits(file, limit = 5) {
|
|
887
|
+
if (!await this.isRepo()) return [];
|
|
888
|
+
const out = await this.run(["log", `-n${limit}`, "--follow", "--date=short", `--format=%h${SEP}%an${SEP}%ad${SEP}%s`, "--", file]);
|
|
889
|
+
return (out ?? "").split("\n").filter(Boolean).map((line) => {
|
|
890
|
+
const [sha, author, date, subject] = line.split(SEP);
|
|
891
|
+
return { sha, author, date, subject };
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
// ---------- agent tools (formatted text, size-capped) ----------
|
|
895
|
+
async logText(file, limit) {
|
|
896
|
+
const out = await this.run([
|
|
897
|
+
"log",
|
|
898
|
+
`-n${Math.min(Math.max(limit, 1), 30)}`,
|
|
899
|
+
"--follow",
|
|
900
|
+
"--date=short",
|
|
901
|
+
"--format=commit %h %ad %an%n%B%n---",
|
|
902
|
+
"--",
|
|
903
|
+
file
|
|
904
|
+
]);
|
|
905
|
+
return cap(out?.trim() || `No commits found for ${file}.`, 12e3);
|
|
906
|
+
}
|
|
907
|
+
async blameText(file, start, end) {
|
|
908
|
+
const s = Math.max(1, Math.floor(start));
|
|
909
|
+
const e = Math.min(Math.max(s, Math.floor(end)), s + 300);
|
|
910
|
+
const out = await this.run(["blame", "-L", `${s},${e}`, "--date=short", "--", file]);
|
|
911
|
+
return cap(out?.trim() || `Could not blame ${file}:${s}-${e} (uncommitted file or out of range).`, 12e3);
|
|
912
|
+
}
|
|
913
|
+
async showText(commit) {
|
|
914
|
+
if (!/^[0-9a-f]{4,40}$/i.test(commit)) return "Invalid commit hash.";
|
|
915
|
+
const out = await this.run(["show", "--stat", "--patch", "--date=short", "--format=commit %H%nAuthor: %an%nDate: %ad%n%n%B", commit]);
|
|
916
|
+
return cap(out?.trim() || `Commit ${commit} not found.`, 15e3);
|
|
917
|
+
}
|
|
918
|
+
run(args) {
|
|
919
|
+
return new Promise((resolve) => {
|
|
920
|
+
execFile(
|
|
921
|
+
"git",
|
|
922
|
+
["-c", "core.quotepath=off", ...args],
|
|
923
|
+
{ cwd: this.cwd, maxBuffer: 8 * 1024 * 1024, windowsHide: true },
|
|
924
|
+
(err, stdout) => resolve(err ? null : stdout)
|
|
925
|
+
);
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
function cap(text, max) {
|
|
930
|
+
return text.length > max ? `${text.slice(0, max)}
|
|
931
|
+
\u2026 (truncated, ${text.length - max} more characters)` : text;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// src/server/types.ts
|
|
935
|
+
import { parse as parse2 } from "@babel/parser";
|
|
936
|
+
import fs3 from "fs";
|
|
937
|
+
import path4 from "path";
|
|
938
|
+
var MAX_LINES = 30;
|
|
939
|
+
var MAX_FILES = 25;
|
|
940
|
+
function resolveTypes(root, file, text) {
|
|
941
|
+
if (!file) return [];
|
|
942
|
+
const files = candidateFiles(root, file);
|
|
943
|
+
const declared = /* @__PURE__ */ new Map();
|
|
944
|
+
for (const f of files) for (const def of declarationsIn(root, f)) if (!declared.has(def.name)) declared.set(def.name, def);
|
|
945
|
+
const found = /* @__PURE__ */ new Map();
|
|
946
|
+
let pending = mentionedNames(text);
|
|
947
|
+
for (let depth = 0; depth < 3 && pending.size; depth++) {
|
|
948
|
+
const next = /* @__PURE__ */ new Set();
|
|
949
|
+
for (const name of pending) {
|
|
950
|
+
const def = declared.get(name);
|
|
951
|
+
if (!def || found.has(name)) continue;
|
|
952
|
+
found.set(name, def);
|
|
953
|
+
for (const n of mentionedNames(def.code)) if (!found.has(n)) next.add(n);
|
|
954
|
+
}
|
|
955
|
+
pending = next;
|
|
956
|
+
}
|
|
957
|
+
return [...found.values()];
|
|
958
|
+
}
|
|
959
|
+
function mentionedNames(text) {
|
|
960
|
+
return new Set(text.match(/\b[A-Z][A-Za-z0-9_]*\b/g) ?? []);
|
|
961
|
+
}
|
|
962
|
+
function candidateFiles(root, file) {
|
|
963
|
+
const out = [file];
|
|
964
|
+
for (let i = 0; i < out.length && out.length < MAX_FILES && i < 12; i++) {
|
|
965
|
+
for (const imp of localImports(root, out[i])) if (!out.includes(imp)) out.push(imp);
|
|
966
|
+
}
|
|
967
|
+
return out.slice(0, MAX_FILES).filter((f) => /\.[cm]?tsx?$/.test(f));
|
|
968
|
+
}
|
|
969
|
+
var cache2 = /* @__PURE__ */ new Map();
|
|
970
|
+
function declarationsIn(root, rel) {
|
|
971
|
+
const abs = path4.join(root, rel);
|
|
972
|
+
let stat;
|
|
973
|
+
try {
|
|
974
|
+
stat = fs3.statSync(abs);
|
|
975
|
+
} catch {
|
|
976
|
+
return [];
|
|
977
|
+
}
|
|
978
|
+
const hit = cache2.get(abs);
|
|
979
|
+
if (hit && hit.mtimeMs === stat.mtimeMs) return hit.defs;
|
|
980
|
+
const source = fs3.readFileSync(abs, "utf8").replace(/\r\n/g, "\n");
|
|
981
|
+
let ast;
|
|
982
|
+
try {
|
|
983
|
+
ast = parse2(source, { sourceType: "module", errorRecovery: true, plugins: ["typescript", "jsx", "decorators-legacy"] });
|
|
984
|
+
} catch {
|
|
985
|
+
return [];
|
|
986
|
+
}
|
|
987
|
+
const defs = [];
|
|
988
|
+
for (let node of ast.program.body) {
|
|
989
|
+
if ((node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration") && node.declaration) node = node.declaration;
|
|
990
|
+
const kind = node.type === "TSInterfaceDeclaration" ? "interface" : node.type === "TSTypeAliasDeclaration" ? "type" : node.type === "TSEnumDeclaration" ? "enum" : null;
|
|
991
|
+
if (!kind || !node.id?.name) continue;
|
|
992
|
+
let code = source.slice(node.start, node.end);
|
|
993
|
+
const lines = code.split("\n");
|
|
994
|
+
if (lines.length > MAX_LINES) code = [...lines.slice(0, MAX_LINES), " // \u2026"].join("\n");
|
|
995
|
+
defs.push({ name: node.id.name, kind, code, file: rel, line: node.loc.start.line });
|
|
996
|
+
}
|
|
997
|
+
cache2.set(abs, { mtimeMs: stat.mtimeMs, defs });
|
|
998
|
+
return defs;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// src/server/hookslots.ts
|
|
1002
|
+
import { parse as parse3 } from "@babel/parser";
|
|
1003
|
+
import fs4 from "fs";
|
|
1004
|
+
import path5 from "path";
|
|
1005
|
+
var VALUE = [{ state: false, element: 0 }];
|
|
1006
|
+
var STATE = [{ state: true, element: 0 }];
|
|
1007
|
+
var INTERNAL = { state: false, element: null };
|
|
1008
|
+
var BUILTIN_ENTRIES = {
|
|
1009
|
+
useState: STATE,
|
|
1010
|
+
useReducer: STATE,
|
|
1011
|
+
useOptimistic: STATE,
|
|
1012
|
+
// isPending, then the start function.
|
|
1013
|
+
useTransition: [...STATE, INTERNAL],
|
|
1014
|
+
// The state, isPending (the third element), then the action queue.
|
|
1015
|
+
useActionState: [...STATE, { state: true, element: 2 }, INTERNAL],
|
|
1016
|
+
// The snapshot, then the effect that subscribes to the store.
|
|
1017
|
+
useSyncExternalStore: [...VALUE, INTERNAL],
|
|
1018
|
+
useRef: VALUE,
|
|
1019
|
+
useMemo: VALUE,
|
|
1020
|
+
useCallback: VALUE,
|
|
1021
|
+
useEffect: VALUE,
|
|
1022
|
+
useLayoutEffect: VALUE,
|
|
1023
|
+
useInsertionEffect: VALUE,
|
|
1024
|
+
useImperativeHandle: VALUE,
|
|
1025
|
+
useId: VALUE,
|
|
1026
|
+
useDeferredValue: VALUE
|
|
1027
|
+
};
|
|
1028
|
+
var STATE_HOOKS = new Set(Object.keys(BUILTIN_ENTRIES).filter((h) => BUILTIN_ENTRIES[h].some((e) => e.state)));
|
|
1029
|
+
var NO_ENTRY = /* @__PURE__ */ new Set(["useContext", "useDebugValue", "use"]);
|
|
1030
|
+
var DEPS_ARG = { useEffect: 1, useLayoutEffect: 1, useInsertionEffect: 1, useMemo: 1, useCallback: 1, useImperativeHandle: 2 };
|
|
1031
|
+
var RETURNED = { type: "Identifier", name: "@returned" };
|
|
1032
|
+
var MAX_DEPTH2 = 4;
|
|
1033
|
+
function hookSlots(root, file, component) {
|
|
1034
|
+
if (!file) return null;
|
|
1035
|
+
const found = findFunction(root, file, component, 0);
|
|
1036
|
+
if (!found) return null;
|
|
1037
|
+
const raws = collect(root, found.file, found.fn, 0);
|
|
1038
|
+
const unknownAt = raws.findIndex((r) => r.kind === "unknown");
|
|
1039
|
+
const hookRaws = (unknownAt < 0 ? raws : raws.slice(0, unknownAt)).filter((r) => r.kind === "hook");
|
|
1040
|
+
return {
|
|
1041
|
+
hooks: hookRaws.map((r) => ({ ...toSlot(r), hook: r.hook, ...r.deps ? { deps: r.deps } : {} })),
|
|
1042
|
+
complete: unknownAt < 0,
|
|
1043
|
+
states: raws.filter((r) => r.kind === "hook" && r.state).map(toSlot),
|
|
1044
|
+
contexts: raws.filter((r) => r.kind === "context").map(toSlot),
|
|
1045
|
+
constants: constantsIn(root, file)
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
function toSlot(r) {
|
|
1049
|
+
return r.owner ? { owner: r.owner, key: r.key, name: r.name } : { name: r.local ?? r.name };
|
|
1050
|
+
}
|
|
1051
|
+
function collect(root, file, fn, depth) {
|
|
1052
|
+
const out = [];
|
|
1053
|
+
const source = parseFile(root, file)?.source ?? "";
|
|
1054
|
+
const body = fn.body?.type === "BlockStatement" ? fn.body : { type: "VariableDeclarator", id: RETURNED, init: fn.body };
|
|
1055
|
+
walk(body, (call, declarator) => {
|
|
1056
|
+
const callee = calleeName(call);
|
|
1057
|
+
const readsContext = callee === "useContext" || callee === "use" && /Context$/.test(call.arguments[0]?.name ?? "");
|
|
1058
|
+
if (!callee || NO_ENTRY.has(callee) && !readsContext) return;
|
|
1059
|
+
if (callee in BUILTIN_ENTRIES) {
|
|
1060
|
+
const first = boundName(declarator?.id, 0);
|
|
1061
|
+
const name = first && first !== RETURNED.name ? first : callee;
|
|
1062
|
+
const depsNode = DEPS_ARG[callee] !== void 0 ? call.arguments[DEPS_ARG[callee]] : void 0;
|
|
1063
|
+
const deps = depsNode?.type === "ArrayExpression" ? depsNode.elements.map((e) => e ? source.slice(e.start, e.end) : "") : void 0;
|
|
1064
|
+
BUILTIN_ENTRIES[callee].forEach((entry, n) => {
|
|
1065
|
+
const local = entry.element === null ? null : boundName(declarator?.id, entry.element);
|
|
1066
|
+
const label = n === 0 ? name : entry.element === null || !local ? `${name} (internal)` : local;
|
|
1067
|
+
out.push({ kind: "hook", hook: callee, state: entry.state, local, name: label, ...n === 0 && deps ? { deps } : {} });
|
|
1068
|
+
});
|
|
1069
|
+
} else if (readsContext) {
|
|
1070
|
+
const ctx = call.arguments[0]?.type === "Identifier" ? call.arguments[0].name : "context";
|
|
1071
|
+
out.push({ kind: "context", local: declarator?.id?.type === "Identifier" ? declarator.id.name : null, name: ctx });
|
|
1072
|
+
} else if (/^use[A-Z]/.test(callee)) {
|
|
1073
|
+
const target = depth < MAX_DEPTH2 ? findFunction(root, file, callee, depth + 1) : null;
|
|
1074
|
+
if (!target) return void out.push({ kind: "unknown", local: null, name: callee });
|
|
1075
|
+
const inner = collect(root, target.file, target.fn, depth + 1);
|
|
1076
|
+
const shape = returnShape(target.fn);
|
|
1077
|
+
for (const s of inner) {
|
|
1078
|
+
const key = shape && s.local ? exposedAs(shape, s.local, declarator?.id) : void 0;
|
|
1079
|
+
out.push({
|
|
1080
|
+
...s,
|
|
1081
|
+
owner: callee,
|
|
1082
|
+
key,
|
|
1083
|
+
local: key !== void 0 ? callerLocal(declarator?.id, shape, key) : null
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
});
|
|
1088
|
+
return out;
|
|
1089
|
+
}
|
|
1090
|
+
function constantsIn(root, file) {
|
|
1091
|
+
const parsed2 = parseFile(root, file);
|
|
1092
|
+
return parsed2 ? Object.fromEntries(parsed2.constants) : {};
|
|
1093
|
+
}
|
|
1094
|
+
function returnShape(fn) {
|
|
1095
|
+
let expr = fn.body?.type !== "BlockStatement" ? fn.body : null;
|
|
1096
|
+
if (!expr) {
|
|
1097
|
+
const ret = [...fn.body?.body ?? []].reverse().find((s) => s.type === "ReturnStatement");
|
|
1098
|
+
expr = ret?.argument;
|
|
1099
|
+
}
|
|
1100
|
+
while (expr?.type === "TSAsExpression" || expr?.type === "TSSatisfiesExpression") expr = expr.expression;
|
|
1101
|
+
if (!expr) return null;
|
|
1102
|
+
if (expr.type === "Identifier") return { type: "single", name: expr.name };
|
|
1103
|
+
if (expr.type === "CallExpression") return { type: "single", name: RETURNED.name };
|
|
1104
|
+
if (expr.type === "ObjectExpression") {
|
|
1105
|
+
const map = /* @__PURE__ */ new Map();
|
|
1106
|
+
for (const p of expr.properties) {
|
|
1107
|
+
if (p.type !== "ObjectProperty" || p.value?.type !== "Identifier") continue;
|
|
1108
|
+
const key = p.key.type === "Identifier" ? p.key.name : p.key.type === "StringLiteral" ? p.key.value : null;
|
|
1109
|
+
if (key) map.set(p.value.name, key);
|
|
1110
|
+
}
|
|
1111
|
+
return { type: "object", map };
|
|
1112
|
+
}
|
|
1113
|
+
if (expr.type === "ArrayExpression") {
|
|
1114
|
+
const map = /* @__PURE__ */ new Map();
|
|
1115
|
+
expr.elements.forEach((e, i) => e?.type === "Identifier" && map.set(e.name, i));
|
|
1116
|
+
return { type: "array", map };
|
|
1117
|
+
}
|
|
1118
|
+
return null;
|
|
1119
|
+
}
|
|
1120
|
+
function exposedAs(shape, local, callSitePattern) {
|
|
1121
|
+
if (shape.type === "single") return shape.name === local ? "" : void 0;
|
|
1122
|
+
if (shape.type === "object") return shape.map.get(local);
|
|
1123
|
+
const index = shape.map.get(local);
|
|
1124
|
+
if (index === void 0) return void 0;
|
|
1125
|
+
return boundName(callSitePattern, index) ?? `[${index}]`;
|
|
1126
|
+
}
|
|
1127
|
+
function callerLocal(pattern, shape, key) {
|
|
1128
|
+
if (!pattern) return null;
|
|
1129
|
+
if (shape.type === "single") return pattern.type === "Identifier" ? pattern.name : null;
|
|
1130
|
+
if (shape.type === "array") return key.startsWith("[") ? null : key;
|
|
1131
|
+
if (pattern.type === "ObjectPattern") {
|
|
1132
|
+
for (const p of pattern.properties) {
|
|
1133
|
+
const k = p.key?.type === "Identifier" ? p.key.name : p.key?.value;
|
|
1134
|
+
if (k !== key) continue;
|
|
1135
|
+
const v = p.value?.type === "AssignmentPattern" ? p.value.left : p.value;
|
|
1136
|
+
return v?.type === "Identifier" ? v.name : null;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return null;
|
|
1140
|
+
}
|
|
1141
|
+
function boundName(pattern, index) {
|
|
1142
|
+
if (!pattern) return null;
|
|
1143
|
+
if (pattern.type === "Identifier") return index === 0 ? pattern.name : null;
|
|
1144
|
+
if (pattern.type === "ArrayPattern") {
|
|
1145
|
+
const el = pattern.elements[index];
|
|
1146
|
+
const id = el?.type === "AssignmentPattern" ? el.left : el;
|
|
1147
|
+
return id?.type === "Identifier" ? id.name : null;
|
|
1148
|
+
}
|
|
1149
|
+
return null;
|
|
1150
|
+
}
|
|
1151
|
+
function calleeName(call) {
|
|
1152
|
+
const c = call.callee;
|
|
1153
|
+
if (c.type === "Identifier") return c.name;
|
|
1154
|
+
if (c.type === "MemberExpression" && c.property.type === "Identifier" && c.object.type === "Identifier" && c.object.name === "React") return c.property.name;
|
|
1155
|
+
return null;
|
|
1156
|
+
}
|
|
1157
|
+
function walk(node, visit, declarator = null, top = true) {
|
|
1158
|
+
if (!node || typeof node !== "object") return;
|
|
1159
|
+
if (Array.isArray(node)) return node.forEach((n) => walk(n, visit, declarator, top));
|
|
1160
|
+
if (!top && /Function/.test(node.type)) return;
|
|
1161
|
+
if (node.type === "ReturnStatement" && node.argument?.type === "CallExpression") {
|
|
1162
|
+
walk(node.argument, visit, { id: RETURNED }, false);
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
if (node.type === "VariableDeclarator") {
|
|
1166
|
+
walk(node.init, visit, node, false);
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
if (node.type === "CallExpression") {
|
|
1170
|
+
walk(node.arguments, visit, null, false);
|
|
1171
|
+
visit(node, declarator);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
for (const key of Object.keys(node)) {
|
|
1175
|
+
if (key === "loc" || key === "start" || key === "end" || key === "leadingComments" || key === "trailingComments") continue;
|
|
1176
|
+
walk(node[key], visit, null, false);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
var parsedCache = /* @__PURE__ */ new Map();
|
|
1180
|
+
function parseFile(root, rel) {
|
|
1181
|
+
const abs = path5.join(root, rel);
|
|
1182
|
+
let stat;
|
|
1183
|
+
try {
|
|
1184
|
+
stat = fs4.statSync(abs);
|
|
1185
|
+
} catch {
|
|
1186
|
+
return null;
|
|
1187
|
+
}
|
|
1188
|
+
const hit = parsedCache.get(abs);
|
|
1189
|
+
if (hit && hit.mtimeMs === stat.mtimeMs) return hit.parsed;
|
|
1190
|
+
let parsed2 = null;
|
|
1191
|
+
try {
|
|
1192
|
+
const source = fs4.readFileSync(abs, "utf8").replace(/\r\n/g, "\n");
|
|
1193
|
+
const ast = parse3(source, { sourceType: "module", errorRecovery: true, plugins: ["typescript", "jsx", "decorators-legacy"] });
|
|
1194
|
+
parsed2 = { source, constants: /* @__PURE__ */ new Map(), constantNodes: /* @__PURE__ */ new Map(), contexts: /* @__PURE__ */ new Map(), typeDecls: /* @__PURE__ */ new Map(), functions: /* @__PURE__ */ new Map(), defaultExport: null, imports: /* @__PURE__ */ new Map(), reexports: [] };
|
|
1195
|
+
for (let node of ast.program.body) {
|
|
1196
|
+
if (node.type === "ImportDeclaration") {
|
|
1197
|
+
for (const s of node.specifiers) {
|
|
1198
|
+
const imported = s.type === "ImportDefaultSpecifier" ? "default" : s.type === "ImportSpecifier" ? s.imported.name ?? s.imported.value : null;
|
|
1199
|
+
if (imported) parsed2.imports.set(s.local.name, { spec: node.source.value, imported });
|
|
1200
|
+
}
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
if (node.type === "ExportNamedDeclaration" && node.source) {
|
|
1204
|
+
for (const s of node.specifiers) {
|
|
1205
|
+
const exported = s.exported?.name ?? s.exported?.value;
|
|
1206
|
+
if (exported && s.local) parsed2.imports.set(exported, { spec: node.source.value, imported: s.local.name ?? s.local.value });
|
|
1207
|
+
}
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
if (node.type === "ExportAllDeclaration" && !node.exported) {
|
|
1211
|
+
parsed2.reexports.push(node.source.value);
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
const isDefault = node.type === "ExportDefaultDeclaration";
|
|
1215
|
+
if ((node.type === "ExportNamedDeclaration" || isDefault) && node.declaration) node = node.declaration;
|
|
1216
|
+
if (isDefault) parsed2.defaultExport = functionOf2(node);
|
|
1217
|
+
if (node.type === "FunctionDeclaration" && node.id) parsed2.functions.set(node.id.name, node);
|
|
1218
|
+
if ((node.type === "TSInterfaceDeclaration" || node.type === "TSTypeAliasDeclaration") && node.id) parsed2.typeDecls.set(node.id.name, node);
|
|
1219
|
+
if (node.type === "VariableDeclaration") {
|
|
1220
|
+
for (const d of node.declarations) {
|
|
1221
|
+
const fn = functionOf2(d.init);
|
|
1222
|
+
if (d.id.type === "Identifier" && fn) parsed2.functions.set(d.id.name, fn);
|
|
1223
|
+
else if (d.id.type === "Identifier" && d.init?.type === "CallExpression" && /^(React\.)?createContext$/.test(source.slice(d.init.callee.start, d.init.callee.end))) {
|
|
1224
|
+
parsed2.contexts.set(d.id.name, d.init);
|
|
1225
|
+
} else if (d.id.type === "Identifier" && d.init && node.kind === "const" && !/^(Call|New)Expression$/.test(d.init.type)) {
|
|
1226
|
+
const value = source.slice(d.init.start, d.init.end);
|
|
1227
|
+
parsed2.constants.set(d.id.name, value.length > 300 ? value.slice(0, 299) + "\u2026" : value);
|
|
1228
|
+
parsed2.constantNodes.set(d.id.name, d);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
} catch {
|
|
1234
|
+
parsed2 = null;
|
|
1235
|
+
}
|
|
1236
|
+
parsedCache.set(abs, { mtimeMs: stat.mtimeMs, parsed: parsed2 });
|
|
1237
|
+
return parsed2;
|
|
1238
|
+
}
|
|
1239
|
+
function functionOf2(node) {
|
|
1240
|
+
while (node?.type === "CallExpression") node = node.arguments.find((a) => /Function/.test(a.type)) ?? node.arguments[0];
|
|
1241
|
+
return node && /Function/.test(node.type) ? node : null;
|
|
1242
|
+
}
|
|
1243
|
+
function findContext(root, file, name) {
|
|
1244
|
+
const parsed2 = parseFile(root, file);
|
|
1245
|
+
if (!parsed2) return null;
|
|
1246
|
+
const local = parsed2.contexts.get(name);
|
|
1247
|
+
if (local) return { parsed: parsed2, call: local };
|
|
1248
|
+
const imp = parsed2.imports.get(name);
|
|
1249
|
+
const target = imp && resolveModule(root, file, imp.spec);
|
|
1250
|
+
const targetParsed = target ? parseFile(root, target) : null;
|
|
1251
|
+
const call = targetParsed?.contexts.get(imp.imported);
|
|
1252
|
+
return call ? { parsed: targetParsed, call } : null;
|
|
1253
|
+
}
|
|
1254
|
+
function findFunction(root, file, name, depth) {
|
|
1255
|
+
const parsed2 = parseFile(root, file);
|
|
1256
|
+
if (!parsed2) return null;
|
|
1257
|
+
const local = parsed2.functions.get(name);
|
|
1258
|
+
if (local) return { file, fn: local };
|
|
1259
|
+
if (depth === 0 && parsed2.defaultExport) return { file, fn: parsed2.defaultExport };
|
|
1260
|
+
if (depth > MAX_DEPTH2) return null;
|
|
1261
|
+
return importedFunction(root, file, parsed2, name, 0);
|
|
1262
|
+
}
|
|
1263
|
+
function importedFunction(root, file, parsed2, name, hops) {
|
|
1264
|
+
if (hops > 6) return null;
|
|
1265
|
+
const imp = parsed2.imports.get(name);
|
|
1266
|
+
const sources = imp ? [{ spec: imp.spec, name: imp.imported }] : parsed2.reexports.map((spec) => ({ spec, name }));
|
|
1267
|
+
for (const { spec, name: imported } of sources) {
|
|
1268
|
+
const target = resolveModule(root, file, spec);
|
|
1269
|
+
const targetParsed = target ? parseFile(root, target) : null;
|
|
1270
|
+
if (!targetParsed) continue;
|
|
1271
|
+
const fn = imported === "default" ? targetParsed.defaultExport : targetParsed.functions.get(imported);
|
|
1272
|
+
if (fn) return { file: target, fn };
|
|
1273
|
+
const further = imported === "default" ? null : importedFunction(root, target, targetParsed, imported, hops + 1);
|
|
1274
|
+
if (further) return further;
|
|
1275
|
+
}
|
|
1276
|
+
return null;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// src/server/skeleton.ts
|
|
1280
|
+
import fs5 from "fs";
|
|
1281
|
+
import path6 from "path";
|
|
1282
|
+
var STATE_LIKE = {
|
|
1283
|
+
useTransition: () => ({ type: "boolean", initial: "false" }),
|
|
1284
|
+
useOptimistic: (args, src) => ({ type: literalType(args[0]), initial: src(args[0]) }),
|
|
1285
|
+
useActionState: (args, src) => ({ type: literalType(args[1]), initial: src(args[1]) }),
|
|
1286
|
+
useFormStatus: () => ({ type: "", initial: "" })
|
|
1287
|
+
};
|
|
1288
|
+
var VALUE_FIRST = /* @__PURE__ */ new Set(["useState", "useReducer", "useRef", "useMemo", "useCallback", "useContext", "useDeferredValue", "useId", "useSyncExternalStore", ...Object.keys(STATE_LIKE)]);
|
|
1289
|
+
function skeletonCard(root, file, component, usedIn = []) {
|
|
1290
|
+
if (!file) return null;
|
|
1291
|
+
const found = findFunction(root, file, component, 0);
|
|
1292
|
+
const parsed2 = found && parseFile(root, found.file);
|
|
1293
|
+
if (!found || !parsed2) return null;
|
|
1294
|
+
const src = (node) => node ? parsed2.source.slice(node.start, node.end) : "";
|
|
1295
|
+
const outline = [];
|
|
1296
|
+
const item = (kind, name, node, extra = {}) => ({
|
|
1297
|
+
kind,
|
|
1298
|
+
name,
|
|
1299
|
+
line: node.loc?.start.line ?? 0,
|
|
1300
|
+
type: "",
|
|
1301
|
+
initial: "",
|
|
1302
|
+
returns: "",
|
|
1303
|
+
when: "",
|
|
1304
|
+
explanation: "",
|
|
1305
|
+
children: [],
|
|
1306
|
+
...extra
|
|
1307
|
+
});
|
|
1308
|
+
const inner = (fn, renders = true) => nested(fn, item, src, renders);
|
|
1309
|
+
const libraryOf = (hook) => {
|
|
1310
|
+
const spec = parsed2.imports.get(hook)?.spec ?? "";
|
|
1311
|
+
return /^[.~]|^@\//.test(spec) || spec === "react" ? "" : spec;
|
|
1312
|
+
};
|
|
1313
|
+
const opaque = (hook) => hook === "use" || !!libraryOf(hook);
|
|
1314
|
+
const ownSignature = (hook) => opaque(hook) ? "" : hookSignature(root, found.file, hook);
|
|
1315
|
+
const depsText = (deps) => deps?.type === "ArrayExpression" ? `[${deps.elements.map(src).join(", ")}]` : deps ? "" : "every render";
|
|
1316
|
+
const actionSignature = (fn) => {
|
|
1317
|
+
const f = fn?.type === "Identifier" ? parsed2.functions.get(fn.name) : fn;
|
|
1318
|
+
const param = /Function/.test(f?.type ?? "") ? f.params[1] : null;
|
|
1319
|
+
return param ? `(${src(param)}) => void` : "";
|
|
1320
|
+
};
|
|
1321
|
+
const companions = (name, type, node) => name ? [item("function", name, node, { type, reads: "1" })] : [];
|
|
1322
|
+
const body = found.fn.body?.type === "BlockStatement" ? found.fn.body.body : [];
|
|
1323
|
+
for (const stmt of body) {
|
|
1324
|
+
if (stmt.type === "FunctionDeclaration" && stmt.id) {
|
|
1325
|
+
outline.push(item("function", stmt.id.name, stmt, { type: signature2(stmt, src), children: inner(stmt) }));
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
if (stmt.type === "ExpressionStatement" && stmt.expression.type === "CallExpression") {
|
|
1329
|
+
const call = stmt.expression;
|
|
1330
|
+
const hook = calleeName(call);
|
|
1331
|
+
if (hook && /^use(Layout|Insertion)?Effect$/.test(hook)) {
|
|
1332
|
+
const deps = call.arguments[1];
|
|
1333
|
+
outline.push(
|
|
1334
|
+
item("effect", hook, stmt, {
|
|
1335
|
+
hook,
|
|
1336
|
+
when: deps?.type === "ArrayExpression" ? `[${deps.elements.map(src).join(", ")}]` : deps ? "" : "every render",
|
|
1337
|
+
returns: hasCleanup(call.arguments[0]) ? "" : "no cleanup",
|
|
1338
|
+
children: inner(call.arguments[0], false)
|
|
1339
|
+
})
|
|
1340
|
+
);
|
|
1341
|
+
} else if (hook === "useImperativeHandle") {
|
|
1342
|
+
const [refArg, factory, deps] = call.arguments;
|
|
1343
|
+
const handleType = refArg?.type === "Identifier" ? refTarget(found.fn, refArg.name, parsed2) : null;
|
|
1344
|
+
const fromType = typeMembers(handleType, parsed2);
|
|
1345
|
+
const exposed = returnedObject(factory);
|
|
1346
|
+
const members = exposed ? exposed.properties.flatMap((p) => {
|
|
1347
|
+
const key = p.key?.name ?? p.key?.value;
|
|
1348
|
+
if (key == null || p.computed) return [];
|
|
1349
|
+
const declared = fromType.get(key);
|
|
1350
|
+
const fn = p.type === "ObjectMethod" || /Function/.test(p.value?.type ?? "");
|
|
1351
|
+
const type = declared?.type || (p.type === "ObjectMethod" ? `(${p.params.map(src).join(", ")})` : fn ? signature2(p.value, src) : literalType(p.value));
|
|
1352
|
+
return [item(declared?.fn || fn ? "function" : "derived", key, p, { type, reads: key })];
|
|
1353
|
+
}) : [...fromType].map(([key, m]) => item(m.fn ? "function" : "derived", String(key), stmt, { type: m.type, reads: String(key) }));
|
|
1354
|
+
outline.push(
|
|
1355
|
+
item("ref", `useImperativeHandle(${src(refArg)})`, stmt, {
|
|
1356
|
+
hook,
|
|
1357
|
+
type: src(handleType),
|
|
1358
|
+
when: deps?.type === "ArrayExpression" ? `[${deps.elements.map(src).join(", ")}]` : deps ? "" : "every render",
|
|
1359
|
+
children: members
|
|
1360
|
+
})
|
|
1361
|
+
);
|
|
1362
|
+
} else if (hook && hook !== "useDebugValue" && /^use[A-Z]/.test(hook)) {
|
|
1363
|
+
outline.push(item("hook", `${hook}(${call.arguments.map(src).join(", ")})`, stmt, { type: ownSignature(hook), from: libraryOf(hook) }));
|
|
1364
|
+
}
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1367
|
+
if (stmt.type !== "VariableDeclaration") continue;
|
|
1368
|
+
for (const d of stmt.declarations) {
|
|
1369
|
+
const init = d.init;
|
|
1370
|
+
const names = boundNames(d.id);
|
|
1371
|
+
const named = names.filter(Boolean);
|
|
1372
|
+
if (!init || !named.length) continue;
|
|
1373
|
+
const hook = init.type === "CallExpression" ? calleeName(init) : null;
|
|
1374
|
+
if (!names[0] && hook && VALUE_FIRST.has(hook)) continue;
|
|
1375
|
+
const typeArg = init.typeParameters?.params?.[0] ?? init.typeArguments?.params?.[0];
|
|
1376
|
+
const declared = d.id.typeAnnotation?.typeAnnotation;
|
|
1377
|
+
if (hook === "useState" || hook === "useReducer") {
|
|
1378
|
+
const first = init.arguments[hook === "useState" ? 0 : 1];
|
|
1379
|
+
const type = src(typeArg) || literalType(first) || (first?.type === "Identifier" ? paramType(found.fn, first.name, src) : "");
|
|
1380
|
+
const setter = hook === "useState" ? type ? `Dispatch<SetStateAction<${type}>>` : "" : actionSignature(init.arguments[0]);
|
|
1381
|
+
outline.push(item("state", names[0], d, { hook, type, initial: hook === "useState" ? src(first) : "", children: companions(names[1], setter, d) }));
|
|
1382
|
+
} else if (hook === "useRef") {
|
|
1383
|
+
const first = init.arguments[0];
|
|
1384
|
+
const t = src(typeArg);
|
|
1385
|
+
outline.push(item("ref", names[0], d, { type: t ? first?.type === "NullLiteral" ? `${t} | null` : t : literalType(first), initial: src(first) }));
|
|
1386
|
+
} else if (hook === "useMemo") {
|
|
1387
|
+
if (d.id.type === "Identifier") {
|
|
1388
|
+
outline.push(item("memo", names[0], d, { type: src(typeArg) || src(declared), when: depsText(init.arguments[1]), children: inner(init.arguments[0]) }));
|
|
1389
|
+
} else {
|
|
1390
|
+
const members = typeMembers(typeArg ?? declared, parsed2);
|
|
1391
|
+
if (!members.size) for (const [key, m] of literalMembers(init.arguments[0], src)) members.set(key, m);
|
|
1392
|
+
outline.push(item("memo", "useMemo()", d, { type: src(typeArg) || src(declared), when: depsText(init.arguments[1]), children: [...inner(init.arguments[0]), ...givenItems(item, d.id, d, members)] }));
|
|
1393
|
+
}
|
|
1394
|
+
} else if (hook === "useCallback") {
|
|
1395
|
+
outline.push(item("callback", names[0], d, { type: signature2(init.arguments[0], src), when: depsText(init.arguments[1]), children: inner(init.arguments[0]) }));
|
|
1396
|
+
} else if (hook === "useContext" || hook === "use" && /Context$/.test(src(init.arguments[0]))) {
|
|
1397
|
+
const context = src(init.arguments[0]);
|
|
1398
|
+
const type = src(typeArg) || src(declared) || contextType(root, found.file, context);
|
|
1399
|
+
if (d.id.type === "Identifier") outline.push(item("context", names[0], d, { hook, type, from: context }));
|
|
1400
|
+
else {
|
|
1401
|
+
const given = givenItems(item, d.id, d, contextMembers(root, found.file, context));
|
|
1402
|
+
outline.push(item("context", `${hook}(${context})`, d, { hook, type, from: context, children: given }));
|
|
1403
|
+
}
|
|
1404
|
+
} else if (hook === "useSyncExternalStore") {
|
|
1405
|
+
const snapshot = init.arguments[1];
|
|
1406
|
+
const body2 = /Function/.test(snapshot?.type ?? "") && snapshot.body.type !== "BlockStatement" ? snapshot.body : null;
|
|
1407
|
+
const from = body2 ? src(body2) : snapshot ? `${src(snapshot)}()` : "";
|
|
1408
|
+
const type = src(typeArg) || src(declared) || src(snapshot?.returnType?.typeAnnotation) || literalType(body2);
|
|
1409
|
+
if (d.id.type === "Identifier") outline.push(item("store", names[0], d, { hook, type, from }));
|
|
1410
|
+
else outline.push(item("store", `${hook}()`, d, { hook, type, from, children: givenItems(item, d.id, d, typeMembers(typeArg ?? declared, parsed2)) }));
|
|
1411
|
+
} else if (hook && hook in STATE_LIKE) {
|
|
1412
|
+
const { type, initial } = STATE_LIKE[hook](init.arguments, src);
|
|
1413
|
+
const companion = d.id.type === "ObjectPattern" ? [] : companions(
|
|
1414
|
+
names[1],
|
|
1415
|
+
hook === "useTransition" ? "(action: () => void | Promise<void>) => void" : hook === "useOptimistic" ? actionSignature(init.arguments[1]) : hook === "useActionState" ? actionSignature(init.arguments[0]) : "",
|
|
1416
|
+
d
|
|
1417
|
+
);
|
|
1418
|
+
for (const [i, name] of (d.id.type === "ObjectPattern" ? names : names.slice(0, 1)).entries()) {
|
|
1419
|
+
outline.push(item("state", name, d, { hook, type: src(typeArg) || type, initial, children: i === 0 ? companion : [] }));
|
|
1420
|
+
}
|
|
1421
|
+
if (hook === "useActionState" && names[2]) outline.push(item("state", names[2], d, { hook, type: "boolean", initial: "false" }));
|
|
1422
|
+
} else if (hook === "useDeferredValue" || hook === "useId") {
|
|
1423
|
+
outline.push(item("derived", names[0], d, { hook, type: src(typeArg) || src(declared) || (hook === "useId" ? "string" : "") }));
|
|
1424
|
+
} else if (hook === "use" || hook && /^use[A-Z]/.test(hook)) {
|
|
1425
|
+
const given = d.id.type === "Identifier" ? [item("derived", d.id.name, d, { type: src(declared) || (opaque(hook) ? "" : hookReturnType(root, found.file, hook)), reads: "" })] : givenItems(item, d.id, d, opaque(hook) ? /* @__PURE__ */ new Map() : hookGives(root, found.file, hook));
|
|
1426
|
+
outline.push(item("hook", `${hook}(${init.arguments.map(src).join(", ")})`, d, { type: ownSignature(hook), from: libraryOf(hook), children: given }));
|
|
1427
|
+
} else if (/Function/.test(init.type)) {
|
|
1428
|
+
outline.push(item("function", names[0], d, { type: signature2(init, src), children: inner(init) }));
|
|
1429
|
+
} else {
|
|
1430
|
+
for (const name of named) outline.push(item("derived", name, d, { type: src(declared) || (named.length === 1 ? literalType(init) : "") }));
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
const used = src(found.fn);
|
|
1435
|
+
for (const [name, node] of found.file === file ? parsed2.constantNodes : /* @__PURE__ */ new Map()) {
|
|
1436
|
+
if (!new RegExp(`\\b${name.replace(/\$/g, "\\$")}\\b`).test(used)) continue;
|
|
1437
|
+
outline.push(item("constant", name, node, { type: src(node.id.typeAnnotation?.typeAnnotation) || literalType(node.init) }));
|
|
1438
|
+
}
|
|
1439
|
+
outline.push(...renderTree(found.fn, item, src));
|
|
1440
|
+
outline.sort((a, b) => a.line - b.line);
|
|
1441
|
+
return { summary: "", props: propsOf(found.fn, parsed2, src), outline, usedIn: usedIn.map((ref) => ({ ref })), history: [], v: CARD_VERSION };
|
|
1442
|
+
}
|
|
1443
|
+
function nested(fn, make, src, renders = true) {
|
|
1444
|
+
if (!fn || !/Function/.test(fn.type)) return [];
|
|
1445
|
+
const out = [];
|
|
1446
|
+
for (const stmt of fn.body?.type === "BlockStatement" ? fn.body.body : []) {
|
|
1447
|
+
if (stmt.type === "FunctionDeclaration" && stmt.id) {
|
|
1448
|
+
out.push(make("function", stmt.id.name, stmt, { type: signature2(stmt, src), children: nested(stmt, make, src) }));
|
|
1449
|
+
} else if (stmt.type === "VariableDeclaration") {
|
|
1450
|
+
for (const d of stmt.declarations) {
|
|
1451
|
+
if (d.id.type === "Identifier" && /Function/.test(d.init?.type ?? "")) {
|
|
1452
|
+
out.push(make("function", d.id.name, d, { type: signature2(d.init, src), children: nested(d.init, make, src) }));
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
if (renders) out.push(...renderTree(fn, make, src));
|
|
1458
|
+
return out.sort((a, b) => a.line - b.line);
|
|
1459
|
+
}
|
|
1460
|
+
function renderTree(fn, make, src) {
|
|
1461
|
+
if (!fn || !/Function/.test(fn.type)) return [];
|
|
1462
|
+
const locals = /* @__PURE__ */ new Map();
|
|
1463
|
+
const hold = (name, node, when) => locals.set(name, [...locals.get(name) ?? [], { node, when }]);
|
|
1464
|
+
const values = (e) => e?.type === "Identifier" && locals.has(e.name) ? locals.get(e.name) : [{ node: e, when: "" }];
|
|
1465
|
+
const fnValue = (e) => values(e).map((v) => v.node).find((n) => /Function/.test(n?.type ?? ""));
|
|
1466
|
+
const following = /* @__PURE__ */ new Set();
|
|
1467
|
+
const follow = (name, run) => {
|
|
1468
|
+
if (!name) return run();
|
|
1469
|
+
if (following.has(name)) return [];
|
|
1470
|
+
following.add(name);
|
|
1471
|
+
try {
|
|
1472
|
+
return run();
|
|
1473
|
+
} finally {
|
|
1474
|
+
following.delete(name);
|
|
1475
|
+
}
|
|
1476
|
+
};
|
|
1477
|
+
const code = (node) => src(node).replace(/\s+/g, " ");
|
|
1478
|
+
const and = (...parts) => parts.filter(Boolean).join(", ");
|
|
1479
|
+
let depth = 0;
|
|
1480
|
+
let keepRoot = false;
|
|
1481
|
+
const element = (node, when) => {
|
|
1482
|
+
const name = jsxName(node.openingElement.name);
|
|
1483
|
+
const attrs = node.openingElement.attributes;
|
|
1484
|
+
depth++;
|
|
1485
|
+
const children = [
|
|
1486
|
+
// JSX passed as props (`fallback={<Spinner />}`, `renderItem={(u) => <Row />}`) counts as inside.
|
|
1487
|
+
...attrs.flatMap((a) => a.type === "JSXAttribute" ? jsx(a.value, "") : []),
|
|
1488
|
+
...node.children.flatMap((c) => jsx(c, ""))
|
|
1489
|
+
];
|
|
1490
|
+
depth--;
|
|
1491
|
+
const hidden = hiddenWhen(attrs, code);
|
|
1492
|
+
const plain = !/^[A-Z]/.test(name) && !name.includes(".") && !when && hidden === null;
|
|
1493
|
+
if (plain && !children.length && !(keepRoot && depth === 0)) return [];
|
|
1494
|
+
const shown = when || "always";
|
|
1495
|
+
return [make("render", `<${name}>`, node, { when: hidden === null ? shown : and(shown, `hidden${hidden ? ` ${hidden}` : ""}`), children })];
|
|
1496
|
+
};
|
|
1497
|
+
const jsx = (node, when) => {
|
|
1498
|
+
switch (node?.type) {
|
|
1499
|
+
case "JSXElement":
|
|
1500
|
+
return element(node, when);
|
|
1501
|
+
case "JSXFragment":
|
|
1502
|
+
return node.children.flatMap((c) => jsx(c, when));
|
|
1503
|
+
case "JSXExpressionContainer":
|
|
1504
|
+
return expr(node.expression, when);
|
|
1505
|
+
default:
|
|
1506
|
+
return [];
|
|
1507
|
+
}
|
|
1508
|
+
};
|
|
1509
|
+
const expr = (e, when) => {
|
|
1510
|
+
switch (e?.type) {
|
|
1511
|
+
case "JSXElement":
|
|
1512
|
+
case "JSXFragment":
|
|
1513
|
+
return jsx(e, when);
|
|
1514
|
+
case "LogicalExpression":
|
|
1515
|
+
return e.operator === "&&" ? expr(e.right, and(when, `if ${code(e.left)}`)) : [...expr(e.left, when), ...expr(e.right, and(when, `unless ${code(e.left)}`))];
|
|
1516
|
+
case "ConditionalExpression":
|
|
1517
|
+
return [...expr(e.consequent, and(when, `if ${code(e.test)}`)), ...expr(e.alternate, and(when, `unless ${code(e.test)}`))];
|
|
1518
|
+
case "ArrayExpression":
|
|
1519
|
+
return e.elements.flatMap((x) => expr(x, when));
|
|
1520
|
+
case "CallExpression": {
|
|
1521
|
+
const { callee } = e;
|
|
1522
|
+
const method = callee.type === "MemberExpression" && !callee.computed ? callee.property.name : null;
|
|
1523
|
+
if (method === "map" || method === "flatMap") {
|
|
1524
|
+
const cb = fnValue(e.arguments[0]);
|
|
1525
|
+
if (!cb) return [];
|
|
1526
|
+
const item = cb.params[0] ? code(cb.params[0]) : "item";
|
|
1527
|
+
const each = `each ${item}${filtered(callee.object, item)}`;
|
|
1528
|
+
return follow(e.arguments[0].type === "Identifier" ? e.arguments[0].name : null, () => body(cb, and(when, each)));
|
|
1529
|
+
}
|
|
1530
|
+
const helper = callee.type === "Identifier" ? fnValue(callee) : null;
|
|
1531
|
+
return helper ? follow(callee.name, () => body(helper, when)) : [];
|
|
1532
|
+
}
|
|
1533
|
+
case "Identifier":
|
|
1534
|
+
return follow(e.name, () => (locals.get(e.name) ?? []).flatMap((v) => expr(v.node, and(when, v.when))));
|
|
1535
|
+
case "MemberExpression": {
|
|
1536
|
+
const key = e.computed ? null : e.property.name;
|
|
1537
|
+
return values(e.object).flatMap(
|
|
1538
|
+
({ node: obj, when: held }) => obj?.type !== "ObjectExpression" ? [] : obj.properties.flatMap((p) => {
|
|
1539
|
+
const k = p.type === "ObjectProperty" ? p.key.name ?? p.key.value : void 0;
|
|
1540
|
+
if (k === void 0) return [];
|
|
1541
|
+
if (key !== null) return k === key ? expr(p.value, and(when, held)) : [];
|
|
1542
|
+
return expr(p.value, and(when, held, `if ${code(e.property)} === ${JSON.stringify(String(k))}`));
|
|
1543
|
+
})
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
case "ArrowFunctionExpression":
|
|
1547
|
+
case "FunctionExpression":
|
|
1548
|
+
return body(e, when);
|
|
1549
|
+
// a render prop: `{(user) => <Row user={user} />}`
|
|
1550
|
+
case "TSAsExpression":
|
|
1551
|
+
case "TSSatisfiesExpression":
|
|
1552
|
+
case "TSNonNullExpression":
|
|
1553
|
+
case "ParenthesizedExpression":
|
|
1554
|
+
return expr(e.expression, when);
|
|
1555
|
+
default:
|
|
1556
|
+
return [];
|
|
1557
|
+
}
|
|
1558
|
+
};
|
|
1559
|
+
const filtered = (list, item) => {
|
|
1560
|
+
const isFilter = list?.type === "CallExpression" && list.callee.type === "MemberExpression" && list.callee.property?.name === "filter";
|
|
1561
|
+
if (!isFilter) return "";
|
|
1562
|
+
const f = list.arguments[0];
|
|
1563
|
+
if (!/Function/.test(f?.type ?? "") || f.body.type === "BlockStatement") return " (filtered)";
|
|
1564
|
+
const param = f.params[0]?.type === "Identifier" ? f.params[0].name : null;
|
|
1565
|
+
const test = code(f.body);
|
|
1566
|
+
return ` where ${param && /^[\w$]+$/.test(item) ? test.replace(new RegExp(`\\b${param.replace(/\$/g, "\\$")}\\b`, "g"), item) : test}`;
|
|
1567
|
+
};
|
|
1568
|
+
const body = (f, when) => f.body?.type === "BlockStatement" ? statements(f.body.body, when) : expr(f.body, when);
|
|
1569
|
+
const block = (s) => s?.type === "BlockStatement" ? s.body : s ? [s] : [];
|
|
1570
|
+
const returns = (s) => block(s).some((x) => x.type === "ReturnStatement");
|
|
1571
|
+
const ifChain = (s, when, first = true) => {
|
|
1572
|
+
const test = code(s.test);
|
|
1573
|
+
const out = statements(block(s.consequent), and(when, `${first ? "if" : "else if"} ${test}`));
|
|
1574
|
+
if (s.alternate?.type === "IfStatement") out.push(...ifChain(s.alternate, when, false));
|
|
1575
|
+
else if (s.alternate) out.push(...statements(block(s.alternate), and(when, first ? `unless ${test}` : "otherwise")));
|
|
1576
|
+
return out;
|
|
1577
|
+
};
|
|
1578
|
+
const statements = (stmts, when) => {
|
|
1579
|
+
const out = [];
|
|
1580
|
+
const early = [];
|
|
1581
|
+
for (const s of stmts) {
|
|
1582
|
+
switch (s.type) {
|
|
1583
|
+
case "FunctionDeclaration":
|
|
1584
|
+
if (s.id) hold(s.id.name, s, "");
|
|
1585
|
+
break;
|
|
1586
|
+
case "VariableDeclaration":
|
|
1587
|
+
for (const d of s.declarations) if (d.id.type === "Identifier" && d.init) hold(d.id.name, d.init, "");
|
|
1588
|
+
break;
|
|
1589
|
+
case "ExpressionStatement": {
|
|
1590
|
+
const x = s.expression;
|
|
1591
|
+
if (x.type === "AssignmentExpression" && x.operator === "=" && x.left.type === "Identifier") hold(x.left.name, x.right, when);
|
|
1592
|
+
if (x.type === "CallExpression" && x.callee.type === "MemberExpression" && x.callee.property?.name === "push" && x.callee.object.type === "Identifier") {
|
|
1593
|
+
for (const arg of x.arguments) hold(x.callee.object.name, arg, when);
|
|
1594
|
+
}
|
|
1595
|
+
break;
|
|
1596
|
+
}
|
|
1597
|
+
case "IfStatement":
|
|
1598
|
+
out.push(...ifChain(s, when));
|
|
1599
|
+
if (returns(s.consequent)) early.push(code(s.test));
|
|
1600
|
+
break;
|
|
1601
|
+
case "SwitchStatement": {
|
|
1602
|
+
let tests = [];
|
|
1603
|
+
for (const c of s.cases) {
|
|
1604
|
+
if (c.test) tests.push(code(c.test));
|
|
1605
|
+
if (!c.consequent.length) continue;
|
|
1606
|
+
const test = c.test ? `${code(s.discriminant)} === ${tests.join(" or ")}` : "";
|
|
1607
|
+
out.push(...statements(c.consequent, and(when, test ? `if ${test}` : "otherwise")));
|
|
1608
|
+
if (c.consequent.some(returns)) early.push(test || "default");
|
|
1609
|
+
tests = [];
|
|
1610
|
+
}
|
|
1611
|
+
break;
|
|
1612
|
+
}
|
|
1613
|
+
case "ForOfStatement":
|
|
1614
|
+
case "ForInStatement":
|
|
1615
|
+
out.push(...statements(block(s.body), and(when, `each ${code(s.left.declarations?.[0]?.id ?? s.left)}`)));
|
|
1616
|
+
break;
|
|
1617
|
+
case "ForStatement":
|
|
1618
|
+
case "WhileStatement":
|
|
1619
|
+
out.push(...statements(block(s.body), and(when, "in a loop")));
|
|
1620
|
+
break;
|
|
1621
|
+
case "BlockStatement":
|
|
1622
|
+
out.push(...statements(s.body, when));
|
|
1623
|
+
break;
|
|
1624
|
+
case "ReturnStatement": {
|
|
1625
|
+
const rest = early.length === 1 ? `unless ${early[0]}` : early.length ? "otherwise" : "";
|
|
1626
|
+
out.push(...expr(s.argument, and(when, rest)));
|
|
1627
|
+
return out;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
return out;
|
|
1632
|
+
};
|
|
1633
|
+
const tree = body(fn, "");
|
|
1634
|
+
if (tree.length) return tree;
|
|
1635
|
+
locals.clear();
|
|
1636
|
+
keepRoot = true;
|
|
1637
|
+
return body(fn, "");
|
|
1638
|
+
}
|
|
1639
|
+
var hides = (n) => n?.type === "StringLiteral" && (n.value === "none" || n.value === "hidden");
|
|
1640
|
+
function hiddenWhen(attrs, code) {
|
|
1641
|
+
for (const a of attrs) {
|
|
1642
|
+
if (a.type !== "JSXAttribute") continue;
|
|
1643
|
+
const v = a.value?.type === "JSXExpressionContainer" ? a.value.expression : a.value;
|
|
1644
|
+
if (a.name.name === "hidden") {
|
|
1645
|
+
if (!v) return "";
|
|
1646
|
+
if (v.type === "BooleanLiteral") return v.value ? "" : null;
|
|
1647
|
+
return `if ${code(v)}`;
|
|
1648
|
+
}
|
|
1649
|
+
if (a.name.name === "style" && v?.type === "ObjectExpression") {
|
|
1650
|
+
for (const p of v.properties) {
|
|
1651
|
+
const key = p.key?.name ?? p.key?.value;
|
|
1652
|
+
if (key !== "display" && key !== "visibility") continue;
|
|
1653
|
+
if (hides(p.value)) return "";
|
|
1654
|
+
if (p.value?.type === "ConditionalExpression") {
|
|
1655
|
+
if (hides(p.value.consequent)) return `if ${code(p.value.test)}`;
|
|
1656
|
+
if (hides(p.value.alternate)) return `unless ${code(p.value.test)}`;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
return null;
|
|
1662
|
+
}
|
|
1663
|
+
function jsxName(n) {
|
|
1664
|
+
if (n.type === "JSXMemberExpression") return `${jsxName(n.object)}.${n.property.name}`;
|
|
1665
|
+
if (n.type === "JSXNamespacedName") return `${n.namespace.name}:${n.name.name}`;
|
|
1666
|
+
return n.name;
|
|
1667
|
+
}
|
|
1668
|
+
var FAMILY = {
|
|
1669
|
+
state: "state",
|
|
1670
|
+
ref: "ref",
|
|
1671
|
+
context: "context",
|
|
1672
|
+
store: "store",
|
|
1673
|
+
hook: "hook",
|
|
1674
|
+
derived: "value",
|
|
1675
|
+
memo: "value",
|
|
1676
|
+
function: "fn",
|
|
1677
|
+
callback: "fn",
|
|
1678
|
+
effect: "effect",
|
|
1679
|
+
render: "render",
|
|
1680
|
+
constant: "constant"
|
|
1681
|
+
};
|
|
1682
|
+
var FIELD_KINDS = {
|
|
1683
|
+
type: /* @__PURE__ */ new Set(["state", "ref", "context", "store", "hook", "derived", "memo", "function", "callback", "constant"]),
|
|
1684
|
+
initial: /* @__PURE__ */ new Set(["state", "ref"]),
|
|
1685
|
+
when: /* @__PURE__ */ new Set(["effect", "render"]),
|
|
1686
|
+
returns: /* @__PURE__ */ new Set(["function", "callback", "effect"])
|
|
1687
|
+
};
|
|
1688
|
+
var baseName = (name) => name.replace(/\(.*$/s, "").trim();
|
|
1689
|
+
function mergeType(s, modelType) {
|
|
1690
|
+
const fromSource = s.type ?? "";
|
|
1691
|
+
if (!fromSource) return modelType;
|
|
1692
|
+
const isSignature = s.kind === "function" || s.kind === "callback" || s.kind === "hook";
|
|
1693
|
+
if (!isSignature || fromSource.includes("=>") || !modelType) return fromSource;
|
|
1694
|
+
if (s.kind !== "hook" && modelType.includes("=>")) return modelType;
|
|
1695
|
+
return `${fromSource} => ${modelType}`;
|
|
1696
|
+
}
|
|
1697
|
+
function mergeWithSource(card, source) {
|
|
1698
|
+
const clean = (item) => {
|
|
1699
|
+
const out = { ...item, children: (item.children ?? []).map(clean) };
|
|
1700
|
+
for (const [field, kinds] of Object.entries(FIELD_KINDS)) if (!kinds.has(out.kind)) out[field] = "";
|
|
1701
|
+
return out;
|
|
1702
|
+
};
|
|
1703
|
+
const outline = card.outline.map(clean);
|
|
1704
|
+
if (!source) return { ...card, outline };
|
|
1705
|
+
const described = new Map(card.props.map((p) => [p.name, p]));
|
|
1706
|
+
const props = source.props.length ? [
|
|
1707
|
+
...source.props.map((p) => {
|
|
1708
|
+
const ai = described.get(p.name);
|
|
1709
|
+
described.delete(p.name);
|
|
1710
|
+
return { ...p, type: p.type || ai?.type || "", description: ai?.description ?? "" };
|
|
1711
|
+
}),
|
|
1712
|
+
...described.values()
|
|
1713
|
+
] : card.props;
|
|
1714
|
+
const refs = new Set(card.usedIn.map((u) => u.ref));
|
|
1715
|
+
const usedIn = [...card.usedIn, ...source.usedIn.filter((u) => !refs.has(u.ref))];
|
|
1716
|
+
return { ...card, props, outline: mergeItems(outline, source.outline), usedIn };
|
|
1717
|
+
}
|
|
1718
|
+
function mergeItems(outline, source) {
|
|
1719
|
+
const givenBy = /* @__PURE__ */ new Map();
|
|
1720
|
+
for (const s of source) for (const c of s.children) if (c.reads !== void 0) givenBy.set(c.name, s);
|
|
1721
|
+
if (givenBy.size) {
|
|
1722
|
+
const declared = new Set(source.map((s) => baseName(s.name)));
|
|
1723
|
+
outline = outline.filter((item) => {
|
|
1724
|
+
const parent = item.kind === "render" || declared.has(baseName(item.name)) ? void 0 : givenBy.get(baseName(item.name));
|
|
1725
|
+
const owner = parent && outline.find((o) => o !== item && FAMILY[o.kind] === FAMILY[parent.kind] && baseName(o.name) === baseName(parent.name));
|
|
1726
|
+
if (!owner) return true;
|
|
1727
|
+
owner.children = [...owner.children, item];
|
|
1728
|
+
return false;
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
const sourceRenders = source.filter((s) => s.kind === "render");
|
|
1732
|
+
const pool = sourceRenders.length ? outline.filter((i) => i.kind === "render").flatMap(flatRenders) : [];
|
|
1733
|
+
if (pool.length) outline = outline.filter((i) => i.kind !== "render");
|
|
1734
|
+
const unmatched = new Set(source.filter((s) => s.kind !== "render"));
|
|
1735
|
+
const take = (pred) => {
|
|
1736
|
+
const hit = [...unmatched].find(pred);
|
|
1737
|
+
if (hit) unmatched.delete(hit);
|
|
1738
|
+
return hit;
|
|
1739
|
+
};
|
|
1740
|
+
for (const item of outline) {
|
|
1741
|
+
if (item.kind === "render") continue;
|
|
1742
|
+
const family = FAMILY[item.kind];
|
|
1743
|
+
const s = item.kind === "effect" ? (
|
|
1744
|
+
// Effects have descriptive labels: pair them by line, else in order.
|
|
1745
|
+
take((s2) => s2.kind === "effect" && Math.abs(s2.line - item.line) <= 1) ?? take((s2) => s2.kind === "effect")
|
|
1746
|
+
) : take((s2) => FAMILY[s2.kind] === family && baseName(s2.name) === baseName(item.name)) ?? take((s2) => baseName(s2.name) === baseName(item.name));
|
|
1747
|
+
if (!s) continue;
|
|
1748
|
+
if (item.kind !== "effect") item.name = s.name;
|
|
1749
|
+
item.kind = s.kind;
|
|
1750
|
+
item.line = s.line;
|
|
1751
|
+
item.type = mergeType(s, item.type ?? "");
|
|
1752
|
+
if (s.initial) item.initial = s.initial;
|
|
1753
|
+
if (s.reads !== void 0) item.reads = s.reads;
|
|
1754
|
+
if (s.hook) item.hook = s.hook;
|
|
1755
|
+
if (s.from) item.from = s.from;
|
|
1756
|
+
if ((s.kind === "ref" || s.kind === "memo" || s.kind === "callback") && s.when) item.when = s.when;
|
|
1757
|
+
if (s.kind === "effect") {
|
|
1758
|
+
if (s.when) item.when = s.when;
|
|
1759
|
+
if (!item.returns) item.returns = s.returns;
|
|
1760
|
+
}
|
|
1761
|
+
item.children = mergeItems(item.children, s.children);
|
|
1762
|
+
}
|
|
1763
|
+
const merged = [...outline, ...unmatched, ...mergeRenders(sourceRenders, pool)];
|
|
1764
|
+
merged.push(...pool.filter((p) => !p.used && !PLAIN_ELEMENT.test(p.name)).map(({ used: _, ...p }) => p));
|
|
1765
|
+
return merged.sort((a, b) => a.line - b.line);
|
|
1766
|
+
}
|
|
1767
|
+
function flatRenders(item) {
|
|
1768
|
+
return [{ ...item, children: [] }, ...item.children.filter((c) => c.kind === "render").flatMap(flatRenders)];
|
|
1769
|
+
}
|
|
1770
|
+
var tagName = (name) => name.replace(/[<>/\s]/g, "").replace(/^([a-z][\w-]*)[.#].*$/, "$1").toLowerCase();
|
|
1771
|
+
var PLAIN_ELEMENT = /^<?[a-z][\w-]*(?:[.#][\w-]+)*\s*\/?>?$/;
|
|
1772
|
+
function mergeRenders(nodes, pool) {
|
|
1773
|
+
const nearest = (fits, line) => pool.filter((p) => !p.used && fits(p)).sort((a, b) => Math.abs(a.line - line) - Math.abs(b.line - line))[0];
|
|
1774
|
+
return nodes.map((s) => {
|
|
1775
|
+
const tag = tagName(s.name);
|
|
1776
|
+
const named = labelPattern(s.name);
|
|
1777
|
+
const m = nearest((p) => tagName(p.name) === tag, s.line) ?? nearest((p) => p.line === s.line, s.line) ?? nearest((p) => Math.abs(p.line - s.line) <= 3 && named.test(p.name), s.line);
|
|
1778
|
+
if (m) m.used = true;
|
|
1779
|
+
return { ...s, explanation: m?.explanation || s.explanation, children: mergeRenders(s.children, pool) };
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
function labelPattern(name) {
|
|
1783
|
+
const words = name.replace(/[<>/\s]/g, "").split(/[.:_-]+|(?<=[a-z0-9])(?=[A-Z])/).filter(Boolean).map((w) => w.replace(/[$]/g, "\\$&"));
|
|
1784
|
+
return new RegExp(`\\b${words.join("[\\s._-]*")}s?\\b`, "i");
|
|
1785
|
+
}
|
|
1786
|
+
var REF_TYPES = /^(React.)?(Ref|RefObject|ForwardedRef|MutableRefObject)$/;
|
|
1787
|
+
function refTarget(fn, name, parsed2) {
|
|
1788
|
+
const src = (n) => n ? parsed2.source.slice(n.start, n.end) : "";
|
|
1789
|
+
const unwrap = (t) => t?.type === "TSTypeReference" && REF_TYPES.test(src(t.typeName)) ? (t.typeParameters ?? t.typeArguments)?.params?.[0] : null;
|
|
1790
|
+
const second = fn.params?.[1];
|
|
1791
|
+
if (second?.type === "Identifier" && second.name === name) return unwrap(second.typeAnnotation?.typeAnnotation);
|
|
1792
|
+
const member = propMembers(fn, parsed2).find((m) => (m.key?.name ?? m.key?.value) === name);
|
|
1793
|
+
return unwrap(member?.typeAnnotation?.typeAnnotation);
|
|
1794
|
+
}
|
|
1795
|
+
function returnedExpression(fn) {
|
|
1796
|
+
if (!/Function/.test(fn?.type ?? "")) return null;
|
|
1797
|
+
const unwrap = (n) => n?.type === "ParenthesizedExpression" || /^TS(As|Satisfies)Expression$/.test(n?.type ?? "") ? unwrap(n.expression) : n;
|
|
1798
|
+
return unwrap(fn.body.type === "BlockStatement" ? [...fn.body.body].reverse().find((s) => s.type === "ReturnStatement")?.argument : fn.body);
|
|
1799
|
+
}
|
|
1800
|
+
function returnedObject(fn) {
|
|
1801
|
+
const obj = returnedExpression(fn);
|
|
1802
|
+
return obj?.type === "ObjectExpression" ? obj : null;
|
|
1803
|
+
}
|
|
1804
|
+
function literalMembers(fn, src) {
|
|
1805
|
+
const out = /* @__PURE__ */ new Map();
|
|
1806
|
+
const member = (v) => /Function/.test(v?.type ?? "") ? { fn: true, type: signature2(v, src) } : { fn: false, type: literalType(v) };
|
|
1807
|
+
const e = returnedExpression(fn);
|
|
1808
|
+
if (e?.type === "ArrayExpression") e.elements.forEach((el, i) => el && out.set(i, member(el)));
|
|
1809
|
+
for (const p of e?.type === "ObjectExpression" ? e.properties : []) {
|
|
1810
|
+
const key = p.key?.name ?? p.key?.value;
|
|
1811
|
+
if (key == null || p.computed) continue;
|
|
1812
|
+
out.set(key, p.type === "ObjectMethod" ? { fn: true, type: `(${p.params.map(src).join(", ")})` } : member(p.value));
|
|
1813
|
+
}
|
|
1814
|
+
return out;
|
|
1815
|
+
}
|
|
1816
|
+
function propMembers(fn, parsed2) {
|
|
1817
|
+
const param = fn.params?.[0];
|
|
1818
|
+
if (!param) return [];
|
|
1819
|
+
const pattern = param.type === "AssignmentPattern" ? param.left : param;
|
|
1820
|
+
let typeNode = pattern.typeAnnotation?.typeAnnotation;
|
|
1821
|
+
while (typeNode?.type === "TSTypeReference" && typeNode.typeName?.type === "Identifier") {
|
|
1822
|
+
const decl = parsed2.typeDecls.get(typeNode.typeName.name);
|
|
1823
|
+
if (decl) typeNode = decl.type === "TSInterfaceDeclaration" ? decl.body : decl.typeAnnotation;
|
|
1824
|
+
else typeNode = (typeNode.typeParameters ?? typeNode.typeArguments)?.params?.[0];
|
|
1825
|
+
}
|
|
1826
|
+
return typeNode?.type === "TSTypeLiteral" ? typeNode.members : typeNode?.type === "TSInterfaceBody" ? typeNode.body : [];
|
|
1827
|
+
}
|
|
1828
|
+
function propsOf(fn, parsed2, src) {
|
|
1829
|
+
const param = fn.params?.[0];
|
|
1830
|
+
if (!param) return [];
|
|
1831
|
+
const pattern = param.type === "AssignmentPattern" ? param.left : param;
|
|
1832
|
+
const members = propMembers(fn, parsed2);
|
|
1833
|
+
const out = [];
|
|
1834
|
+
for (const m of members) {
|
|
1835
|
+
const name = m.key?.type === "Identifier" ? m.key.name : m.key?.value;
|
|
1836
|
+
if (!name) continue;
|
|
1837
|
+
const type = m.type === "TSMethodSignature" ? `(${m.params?.map(src).join(", ") ?? ""}) => ${src(m.returnType?.typeAnnotation) || "void"}` : src(m.typeAnnotation?.typeAnnotation);
|
|
1838
|
+
out.push({ name, type, optional: !!m.optional, description: "" });
|
|
1839
|
+
}
|
|
1840
|
+
if (!out.length && pattern.type === "ObjectPattern") {
|
|
1841
|
+
for (const p of pattern.properties) {
|
|
1842
|
+
const name = p.key?.type === "Identifier" ? p.key.name : null;
|
|
1843
|
+
if (name) out.push({ name, type: "", optional: p.value?.type === "AssignmentPattern", description: "" });
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
return out;
|
|
1847
|
+
}
|
|
1848
|
+
function boundNames(id) {
|
|
1849
|
+
if (id.type === "Identifier") return [id.name];
|
|
1850
|
+
if (id.type === "ArrayPattern") {
|
|
1851
|
+
return id.elements.map((el) => {
|
|
1852
|
+
const target = el?.type === "AssignmentPattern" ? el.left : el?.type === "RestElement" ? el.argument : el;
|
|
1853
|
+
return target?.type === "Identifier" ? target.name : "";
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
if (id.type === "ObjectPattern") {
|
|
1857
|
+
return id.properties.map((p) => p.type === "RestElement" ? p.argument : p.value?.type === "AssignmentPattern" ? p.value.left : p.value).filter((v) => v?.type === "Identifier").map((v) => v.name);
|
|
1858
|
+
}
|
|
1859
|
+
return [];
|
|
1860
|
+
}
|
|
1861
|
+
function hookSignature(root, file, hook) {
|
|
1862
|
+
const found = findFunction(root, file, hook, 1);
|
|
1863
|
+
const parsed2 = found && parseFile(root, found.file);
|
|
1864
|
+
if (!found || !parsed2) return "";
|
|
1865
|
+
return signature2(found.fn, (n) => n ? parsed2.source.slice(n.start, n.end) : "");
|
|
1866
|
+
}
|
|
1867
|
+
function givenItems(make, id, node, members) {
|
|
1868
|
+
return givenNames(id).map(({ key, name }) => {
|
|
1869
|
+
const member = members.get(key);
|
|
1870
|
+
return make(member?.fn ? "function" : "derived", name, node, { type: member?.type ?? "", reads: String(key) });
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
function givenNames(id) {
|
|
1874
|
+
const local = (v) => v?.type === "AssignmentPattern" ? v.left : v;
|
|
1875
|
+
if (id.type === "ObjectPattern") {
|
|
1876
|
+
return id.properties.filter((p) => local(p.value)?.type === "Identifier" && (p.key?.type === "Identifier" || p.key?.type === "StringLiteral")).map((p) => ({ key: p.key.name ?? p.key.value, name: local(p.value).name }));
|
|
1877
|
+
}
|
|
1878
|
+
if (id.type === "ArrayPattern") {
|
|
1879
|
+
return id.elements.flatMap((el, i) => local(el)?.type === "Identifier" ? [{ key: i, name: local(el).name }] : []);
|
|
1880
|
+
}
|
|
1881
|
+
return [];
|
|
1882
|
+
}
|
|
1883
|
+
function typeMembers(t, parsed2, depth = 0) {
|
|
1884
|
+
const src = (n) => n ? parsed2.source.slice(n.start, n.end) : "";
|
|
1885
|
+
const out = /* @__PURE__ */ new Map();
|
|
1886
|
+
if (!t || depth > 4) return out;
|
|
1887
|
+
if (t.type === "TSUnionType") return typeMembers(t.types.find((u) => !/^TS(Null|Undefined)Keyword$/.test(u.type)), parsed2, depth + 1);
|
|
1888
|
+
if (t.type === "TSTypeOperator") return typeMembers(t.typeAnnotation, parsed2, depth + 1);
|
|
1889
|
+
if (t.type === "TSTypeReference" && t.typeName?.type === "Identifier") {
|
|
1890
|
+
const decl = parsed2.typeDecls.get(t.typeName.name);
|
|
1891
|
+
return typeMembers(decl?.type === "TSInterfaceDeclaration" ? decl.body : decl?.typeAnnotation, parsed2, depth + 1);
|
|
1892
|
+
}
|
|
1893
|
+
if (t.type === "TSTupleType") {
|
|
1894
|
+
t.elementTypes.forEach((e, i) => {
|
|
1895
|
+
const el = e.type === "TSNamedTupleMember" ? e.elementType : e;
|
|
1896
|
+
out.set(i, { fn: el.type === "TSFunctionType", type: src(el) });
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
for (const m of t.type === "TSTypeLiteral" ? t.members : t.type === "TSInterfaceBody" ? t.body : []) {
|
|
1900
|
+
const key = m.key?.name ?? m.key?.value;
|
|
1901
|
+
if (key == null) continue;
|
|
1902
|
+
if (m.type === "TSMethodSignature") out.set(key, { fn: true, type: `(${m.params?.map(src).join(", ") ?? ""}) => ${src(m.returnType?.typeAnnotation) || "void"}` });
|
|
1903
|
+
else out.set(key, { fn: m.typeAnnotation?.typeAnnotation?.type === "TSFunctionType", type: src(m.typeAnnotation?.typeAnnotation) });
|
|
1904
|
+
}
|
|
1905
|
+
return out;
|
|
1906
|
+
}
|
|
1907
|
+
function hookGives(root, file, hook) {
|
|
1908
|
+
const found = findFunction(root, file, hook, 1);
|
|
1909
|
+
const parsed2 = found && parseFile(root, found.file);
|
|
1910
|
+
if (!found || !parsed2) return /* @__PURE__ */ new Map();
|
|
1911
|
+
const src = (n) => n ? parsed2.source.slice(n.start, n.end) : "";
|
|
1912
|
+
const unwrap = (n) => /^TS(As|Satisfies|NonNull)Expression$|^ParenthesizedExpression$/.test(n?.type) ? unwrap(n.expression) : n;
|
|
1913
|
+
const typeArg = (call) => call?.typeParameters?.params?.[0] ?? call?.typeArguments?.params?.[0];
|
|
1914
|
+
const typeShape = (t) => typeMembers(t, parsed2);
|
|
1915
|
+
if (found.fn.returnType) return typeShape(found.fn.returnType.typeAnnotation);
|
|
1916
|
+
const stmts = found.fn.body?.type === "BlockStatement" ? found.fn.body.body : [];
|
|
1917
|
+
const locals = /* @__PURE__ */ new Map();
|
|
1918
|
+
for (const stmt of stmts) {
|
|
1919
|
+
if (stmt.type === "FunctionDeclaration" && stmt.id) locals.set(stmt.id.name, { fn: true, type: signature2(stmt, src) });
|
|
1920
|
+
if (stmt.type !== "VariableDeclaration") continue;
|
|
1921
|
+
for (const d of stmt.declarations) {
|
|
1922
|
+
if (d.id.type === "Identifier") locals.set(d.id.name, d);
|
|
1923
|
+
else if (d.id.type === "ArrayPattern" && d.init?.type === "CallExpression" && /^use(State|Reducer)$/.test(calleeName(d.init) ?? "")) {
|
|
1924
|
+
const [value, set] = d.id.elements;
|
|
1925
|
+
const initial = d.init.arguments[calleeName(d.init) === "useState" ? 0 : 1];
|
|
1926
|
+
const type = src(typeArg(d.init)) || literalType(initial) || (initial?.type === "Identifier" ? paramType(found.fn, initial.name, src) : "");
|
|
1927
|
+
if (value?.type === "Identifier") locals.set(value.name, { fn: false, type });
|
|
1928
|
+
if (set?.type === "Identifier") locals.set(set.name, { fn: true, type: "" });
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
const memberOf = (expr) => {
|
|
1933
|
+
const e = unwrap(expr);
|
|
1934
|
+
if (/Function/.test(e?.type)) return { fn: true, type: signature2(e, src) };
|
|
1935
|
+
const call = e?.type === "CallExpression" ? calleeName(e) : null;
|
|
1936
|
+
if (call === "useCallback") return { fn: true, type: signature2(e.arguments[0], src) };
|
|
1937
|
+
if (call === "useMemo") return { fn: false, type: src(typeArg(e)) };
|
|
1938
|
+
if (e?.type === "Identifier") {
|
|
1939
|
+
const local = locals.get(e.name);
|
|
1940
|
+
if (local && "fn" in local) return local;
|
|
1941
|
+
if (local) return local.id.typeAnnotation ? { fn: false, type: src(local.id.typeAnnotation.typeAnnotation) } : memberOf(local.init);
|
|
1942
|
+
const fn = parsed2.functions.get(e.name);
|
|
1943
|
+
if (fn) return { fn: true, type: signature2(fn, src) };
|
|
1944
|
+
}
|
|
1945
|
+
return { fn: false, type: literalType(e) };
|
|
1946
|
+
};
|
|
1947
|
+
const shape = (expr, depth = 0) => {
|
|
1948
|
+
const e = unwrap(expr);
|
|
1949
|
+
const out = /* @__PURE__ */ new Map();
|
|
1950
|
+
if (e?.type === "ObjectExpression") {
|
|
1951
|
+
for (const p of e.properties) {
|
|
1952
|
+
const key = p.key?.name ?? p.key?.value;
|
|
1953
|
+
if (key == null || p.computed) continue;
|
|
1954
|
+
out.set(key, p.type === "ObjectMethod" ? { fn: true, type: `(${p.params.map(src).join(", ")})` } : memberOf(p.value));
|
|
1955
|
+
}
|
|
1956
|
+
} else if (e?.type === "ArrayExpression") {
|
|
1957
|
+
e.elements.forEach((el, i) => el && out.set(i, memberOf(el)));
|
|
1958
|
+
} else if (e?.type === "CallExpression" && /^use(Context)?$/.test(calleeName(e) ?? "") && e.arguments[0]?.type === "Identifier") {
|
|
1959
|
+
return typeShape(typeArg(parsed2.contexts.get(e.arguments[0].name)));
|
|
1960
|
+
} else if (e?.type === "Identifier" && depth < 4) {
|
|
1961
|
+
const local = locals.get(e.name);
|
|
1962
|
+
if (local && !("fn" in local)) return local.id.typeAnnotation ? typeShape(local.id.typeAnnotation.typeAnnotation) : shape(local.init, depth + 1);
|
|
1963
|
+
}
|
|
1964
|
+
return out;
|
|
1965
|
+
};
|
|
1966
|
+
const returned = found.fn.body?.type === "BlockStatement" ? [...stmts].reverse().find((s) => s.type === "ReturnStatement")?.argument : found.fn.body;
|
|
1967
|
+
return shape(returned);
|
|
1968
|
+
}
|
|
1969
|
+
function contextMembers(root, file, name) {
|
|
1970
|
+
const found = findContext(root, file, name);
|
|
1971
|
+
const t = found && (found.call.typeParameters?.params?.[0] ?? found.call.typeArguments?.params?.[0]);
|
|
1972
|
+
return t ? typeMembers(t, found.parsed) : /* @__PURE__ */ new Map();
|
|
1973
|
+
}
|
|
1974
|
+
function hookReturnType(root, file, hook) {
|
|
1975
|
+
const found = findFunction(root, file, hook, 1);
|
|
1976
|
+
const parsed2 = found && parseFile(root, found.file);
|
|
1977
|
+
const t = found?.fn.returnType?.typeAnnotation;
|
|
1978
|
+
return parsed2 && t ? parsed2.source.slice(t.start, t.end) : "";
|
|
1979
|
+
}
|
|
1980
|
+
function contextType(root, file, name) {
|
|
1981
|
+
const found = findContext(root, file, name);
|
|
1982
|
+
if (!found) return "";
|
|
1983
|
+
const { call, parsed: parsed2 } = found;
|
|
1984
|
+
const t = call.typeParameters?.params?.[0] ?? call.typeArguments?.params?.[0];
|
|
1985
|
+
return t ? parsed2.source.slice(t.start, t.end) : literalType(call.arguments[0]);
|
|
1986
|
+
}
|
|
1987
|
+
function paramType(fn, name, src) {
|
|
1988
|
+
const fromDefault = (p) => src(p.left.typeAnnotation?.typeAnnotation) || literalType(p.right);
|
|
1989
|
+
for (const p of fn?.params ?? []) {
|
|
1990
|
+
if (p.type === "Identifier" && p.name === name) return src(p.typeAnnotation?.typeAnnotation);
|
|
1991
|
+
if (p.type === "AssignmentPattern" && p.left.type === "Identifier" && p.left.name === name) return fromDefault(p);
|
|
1992
|
+
const pattern = p.type === "AssignmentPattern" ? p.left : p;
|
|
1993
|
+
if (pattern.type !== "ObjectPattern") continue;
|
|
1994
|
+
for (const prop of pattern.properties) {
|
|
1995
|
+
if (prop.value?.type === "AssignmentPattern" && prop.value.left.name === name) return literalType(prop.value.right);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
return "";
|
|
1999
|
+
}
|
|
2000
|
+
function signature2(fn, src) {
|
|
2001
|
+
if (!fn || !/Function/.test(fn.type)) return "";
|
|
2002
|
+
const ret = src(fn.returnType?.typeAnnotation);
|
|
2003
|
+
return `(${fn.params.map(src).join(", ")})${ret ? ` => ${ret}` : ""}`;
|
|
2004
|
+
}
|
|
2005
|
+
function literalType(node) {
|
|
2006
|
+
switch (node?.type) {
|
|
2007
|
+
case "StringLiteral":
|
|
2008
|
+
case "TemplateLiteral":
|
|
2009
|
+
return "string";
|
|
2010
|
+
case "NumericLiteral":
|
|
2011
|
+
return "number";
|
|
2012
|
+
case "BooleanLiteral":
|
|
2013
|
+
return "boolean";
|
|
2014
|
+
case "NullLiteral":
|
|
2015
|
+
return "null";
|
|
2016
|
+
default:
|
|
2017
|
+
return "";
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
function hasCleanup(fn) {
|
|
2021
|
+
if (!fn || !/Function/.test(fn.type) || fn.body?.type !== "BlockStatement") return false;
|
|
2022
|
+
return fn.body.body.some((s) => s.type === "ReturnStatement" && s.argument);
|
|
2023
|
+
}
|
|
2024
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".next", "out", "coverage", ".turbo", ".vercel", ".cache", ".react-detective"]);
|
|
2025
|
+
var SOURCE_EXT = /\.(tsx|jsx|ts|js|mdx)$/;
|
|
2026
|
+
var MAX_FILES2 = 4e3;
|
|
2027
|
+
function findUsages(root, component, limit = 20) {
|
|
2028
|
+
const re = new RegExp(`<${component.replace(/[$.]/g, "\\$&")}[\\s/>]`);
|
|
2029
|
+
const out = [];
|
|
2030
|
+
let seen = 0;
|
|
2031
|
+
const visit = (dir) => {
|
|
2032
|
+
let entries;
|
|
2033
|
+
try {
|
|
2034
|
+
entries = fs5.readdirSync(dir, { withFileTypes: true });
|
|
2035
|
+
} catch {
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
for (const e of entries) {
|
|
2039
|
+
if (out.length >= limit || seen >= MAX_FILES2) return;
|
|
2040
|
+
if (e.isDirectory()) {
|
|
2041
|
+
if (!SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) visit(path6.join(dir, e.name));
|
|
2042
|
+
continue;
|
|
2043
|
+
}
|
|
2044
|
+
if (!SOURCE_EXT.test(e.name)) continue;
|
|
2045
|
+
seen++;
|
|
2046
|
+
const abs = path6.join(dir, e.name);
|
|
2047
|
+
let text;
|
|
2048
|
+
try {
|
|
2049
|
+
text = fs5.readFileSync(abs, "utf8");
|
|
2050
|
+
} catch {
|
|
2051
|
+
continue;
|
|
2052
|
+
}
|
|
2053
|
+
if (!re.test(text)) continue;
|
|
2054
|
+
const rel = path6.relative(root, abs).split(path6.sep).join("/");
|
|
2055
|
+
text.split(/\r?\n/).forEach((line, i) => {
|
|
2056
|
+
if (out.length < limit && re.test(line)) out.push(`${rel}:${i + 1}`);
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
};
|
|
2060
|
+
visit(root);
|
|
2061
|
+
return out;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
// src/server/callsites.ts
|
|
2065
|
+
import { parse as parse4 } from "@babel/parser";
|
|
2066
|
+
import fs6 from "fs";
|
|
2067
|
+
import path7 from "path";
|
|
2068
|
+
var asts = /* @__PURE__ */ new Map();
|
|
2069
|
+
function parsed(root, rel) {
|
|
2070
|
+
const abs = path7.join(root, rel);
|
|
2071
|
+
let mtimeMs;
|
|
2072
|
+
try {
|
|
2073
|
+
mtimeMs = fs6.statSync(abs).mtimeMs;
|
|
2074
|
+
} catch {
|
|
2075
|
+
return null;
|
|
2076
|
+
}
|
|
2077
|
+
const hit = asts.get(abs);
|
|
2078
|
+
if (hit !== void 0 && hit?.mtimeMs === mtimeMs) return hit;
|
|
2079
|
+
let entry = null;
|
|
2080
|
+
try {
|
|
2081
|
+
const source = fs6.readFileSync(abs, "utf8").replace(/\r\n/g, "\n");
|
|
2082
|
+
entry = { mtimeMs, source, ast: parse4(source, { sourceType: "module", errorRecovery: true, plugins: ["typescript", "jsx", "decorators-legacy"] }) };
|
|
2083
|
+
} catch {
|
|
2084
|
+
}
|
|
2085
|
+
asts.set(abs, entry);
|
|
2086
|
+
return entry;
|
|
2087
|
+
}
|
|
2088
|
+
function describeCallSites(root, component, usedIn) {
|
|
2089
|
+
return usedIn.map((u) => {
|
|
2090
|
+
const m = u.ref.match(/^(.+?):(\d+)(?::\d+)?$/);
|
|
2091
|
+
const file = m && parsed(root, m[1]);
|
|
2092
|
+
if (!m || !file) return u;
|
|
2093
|
+
const found = findElement(file.ast, component, Number(m[2]));
|
|
2094
|
+
if (!found) return u;
|
|
2095
|
+
const src = (n) => file.source.slice(n.start, n.end);
|
|
2096
|
+
const declaredAt = (name) => declarationLine(name, found.scopes);
|
|
2097
|
+
const props = [];
|
|
2098
|
+
for (const attr of found.element.openingElement.attributes) {
|
|
2099
|
+
if (attr.type === "JSXSpreadAttribute") {
|
|
2100
|
+
props.push(withLine({ name: "...", value: src(attr.argument) }, attr.argument, declaredAt));
|
|
2101
|
+
continue;
|
|
2102
|
+
}
|
|
2103
|
+
const name = attr.name.type === "JSXNamespacedName" ? `${attr.name.namespace.name}:${attr.name.name.name}` : attr.name.name;
|
|
2104
|
+
const v = attr.value;
|
|
2105
|
+
if (!v) props.push({ name, value: "true" });
|
|
2106
|
+
else if (v.type === "StringLiteral") props.push({ name, value: JSON.stringify(v.value) });
|
|
2107
|
+
else if (v.type === "JSXExpressionContainer" && v.expression.type !== "JSXEmptyExpression") props.push(withLine({ name, value: src(v.expression) }, v.expression, declaredAt));
|
|
2108
|
+
else props.push({ name, value: src(v) });
|
|
2109
|
+
}
|
|
2110
|
+
const children = found.element.children.filter((c) => !(c.type === "JSXText" && !c.value.trim()) && c.type !== "JSXEmptyExpression");
|
|
2111
|
+
if (children.length) props.push({ name: "children", value: children.map((c) => c.type === "JSXText" ? c.value.trim() : src(c)).join(" ") });
|
|
2112
|
+
return { ...u, parent: found.parent, props };
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
function withLine(prop, expr, declaredAt) {
|
|
2116
|
+
let base = expr;
|
|
2117
|
+
while (base?.type === "MemberExpression" || base?.type === "OptionalMemberExpression") base = base.object;
|
|
2118
|
+
const line = base?.type === "Identifier" ? declaredAt(base.name) : void 0;
|
|
2119
|
+
return line ? { ...prop, line } : prop;
|
|
2120
|
+
}
|
|
2121
|
+
function findElement(ast, component, line) {
|
|
2122
|
+
let result = null;
|
|
2123
|
+
const visit = (node, scopes, parent) => {
|
|
2124
|
+
if (result || !node || typeof node !== "object") return;
|
|
2125
|
+
if (Array.isArray(node)) return node.forEach((n) => visit(n, scopes, parent));
|
|
2126
|
+
if (node.type === "JSXElement" && node.loc?.start.line === line && jsxName2(node.openingElement.name) === component) {
|
|
2127
|
+
result = { element: node, parent, scopes };
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
let inner = scopes;
|
|
2131
|
+
let name = parent;
|
|
2132
|
+
if (/Function/.test(node.type) || node.type === "Program") {
|
|
2133
|
+
inner = [node, ...scopes];
|
|
2134
|
+
name = functionName(node) ?? parent;
|
|
2135
|
+
}
|
|
2136
|
+
for (const key of Object.keys(node)) {
|
|
2137
|
+
if (key === "loc" || key === "start" || key === "end" || key.endsWith("Comments")) continue;
|
|
2138
|
+
const child = node[key];
|
|
2139
|
+
if (node.type === "VariableDeclarator" && key === "init" && /Function/.test(child?.type ?? "") && node.id.type === "Identifier") {
|
|
2140
|
+
child.__name = node.id.name;
|
|
2141
|
+
}
|
|
2142
|
+
visit(child, inner, name);
|
|
2143
|
+
}
|
|
2144
|
+
};
|
|
2145
|
+
visit(ast.program, []);
|
|
2146
|
+
return result;
|
|
2147
|
+
}
|
|
2148
|
+
function jsxName2(n) {
|
|
2149
|
+
return n.type === "JSXIdentifier" ? n.name : n.type === "JSXMemberExpression" ? `${jsxName2(n.object)}.${n.property.name}` : "";
|
|
2150
|
+
}
|
|
2151
|
+
function functionName(fn) {
|
|
2152
|
+
return fn.id?.name ?? fn.__name;
|
|
2153
|
+
}
|
|
2154
|
+
function declarationLine(name, scopes) {
|
|
2155
|
+
for (const scope of scopes) {
|
|
2156
|
+
const params = scope.type === "Program" ? [] : scope.params;
|
|
2157
|
+
for (const p of params) {
|
|
2158
|
+
const line = bindingLine(p, name);
|
|
2159
|
+
if (line) return line;
|
|
2160
|
+
}
|
|
2161
|
+
const body = scope.type === "Program" ? scope.body : scope.body?.type === "BlockStatement" ? scope.body.body : [];
|
|
2162
|
+
for (let stmt of body) {
|
|
2163
|
+
if ((stmt.type === "ExportNamedDeclaration" || stmt.type === "ExportDefaultDeclaration") && stmt.declaration) stmt = stmt.declaration;
|
|
2164
|
+
if (stmt.type === "FunctionDeclaration" && stmt.id?.name === name) return stmt.loc.start.line;
|
|
2165
|
+
if (stmt.type === "ImportDeclaration" && stmt.specifiers.some((s) => s.local.name === name)) return stmt.loc.start.line;
|
|
2166
|
+
if (stmt.type !== "VariableDeclaration") continue;
|
|
2167
|
+
for (const d of stmt.declarations) {
|
|
2168
|
+
const line = bindingLine(d.id, name);
|
|
2169
|
+
if (line) return line;
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
return void 0;
|
|
2174
|
+
}
|
|
2175
|
+
function bindingLine(p, name) {
|
|
2176
|
+
if (!p) return void 0;
|
|
2177
|
+
if (p.type === "Identifier") return p.name === name ? p.loc.start.line : void 0;
|
|
2178
|
+
if (p.type === "AssignmentPattern") return bindingLine(p.left, name);
|
|
2179
|
+
if (p.type === "RestElement") return bindingLine(p.argument, name);
|
|
2180
|
+
if (p.type === "TSParameterProperty") return bindingLine(p.parameter, name);
|
|
2181
|
+
const parts = p.type === "ArrayPattern" ? p.elements : p.type === "ObjectPattern" ? p.properties.map((prop) => prop.type === "RestElement" ? prop : prop.value) : [];
|
|
2182
|
+
for (const part of parts) {
|
|
2183
|
+
const line = bindingLine(part, name);
|
|
2184
|
+
if (line) return line;
|
|
2185
|
+
}
|
|
2186
|
+
return void 0;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
// src/server/tstypes.ts
|
|
2190
|
+
import fs7 from "fs";
|
|
2191
|
+
import { createRequire } from "module";
|
|
2192
|
+
import path8 from "path";
|
|
2193
|
+
var services = /* @__PURE__ */ new Map();
|
|
2194
|
+
var WARM_UP_DELAY = 3e3;
|
|
2195
|
+
var MAX_TYPE = 120;
|
|
2196
|
+
function warmUpTypes(root, delay = WARM_UP_DELAY) {
|
|
2197
|
+
if (services.has(root)) return Promise.resolve();
|
|
2198
|
+
services.set(root, "starting");
|
|
2199
|
+
return new Promise((resolve) => {
|
|
2200
|
+
setTimeout(() => {
|
|
2201
|
+
try {
|
|
2202
|
+
services.set(root, createService(root));
|
|
2203
|
+
} catch {
|
|
2204
|
+
services.set(root, null);
|
|
2205
|
+
}
|
|
2206
|
+
resolve();
|
|
2207
|
+
}, delay).unref();
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
2210
|
+
function createService(root) {
|
|
2211
|
+
let ts;
|
|
2212
|
+
try {
|
|
2213
|
+
ts = createRequire(path8.join(root, "package.json"))("typescript");
|
|
2214
|
+
} catch {
|
|
2215
|
+
return null;
|
|
2216
|
+
}
|
|
2217
|
+
const configFile = ts.findConfigFile(root, ts.sys.fileExists, "tsconfig.json");
|
|
2218
|
+
const config = configFile ? ts.parseJsonConfigFileContent(ts.readConfigFile(configFile, ts.sys.readFile).config, ts.sys, path8.dirname(configFile)) : { fileNames: [], options: { jsx: ts.JsxEmit.ReactJSX, allowJs: true, strict: true } };
|
|
2219
|
+
const files = new Set(config.fileNames.map((f) => path8.resolve(f)));
|
|
2220
|
+
const version = (f) => {
|
|
2221
|
+
try {
|
|
2222
|
+
return String(fs7.statSync(f).mtimeMs);
|
|
2223
|
+
} catch {
|
|
2224
|
+
return "0";
|
|
2225
|
+
}
|
|
2226
|
+
};
|
|
2227
|
+
const host = {
|
|
2228
|
+
getScriptFileNames: () => [...files],
|
|
2229
|
+
getScriptVersion: version,
|
|
2230
|
+
getScriptSnapshot: (f) => fs7.existsSync(f) ? ts.ScriptSnapshot.fromString(fs7.readFileSync(f, "utf8")) : void 0,
|
|
2231
|
+
getCurrentDirectory: () => root,
|
|
2232
|
+
getCompilationSettings: () => ({ ...config.options, noEmit: true }),
|
|
2233
|
+
getDefaultLibFileName: (o) => ts.getDefaultLibFilePath(o),
|
|
2234
|
+
fileExists: ts.sys.fileExists,
|
|
2235
|
+
readFile: ts.sys.readFile,
|
|
2236
|
+
readDirectory: ts.sys.readDirectory,
|
|
2237
|
+
directoryExists: ts.sys.directoryExists,
|
|
2238
|
+
getDirectories: ts.sys.getDirectories
|
|
2239
|
+
};
|
|
2240
|
+
const service = ts.createLanguageService(host, ts.createDocumentRegistry());
|
|
2241
|
+
service.getProgram();
|
|
2242
|
+
return { ts, service };
|
|
2243
|
+
}
|
|
2244
|
+
function withCheckedTypes(root, file, card) {
|
|
2245
|
+
const entry = services.get(root);
|
|
2246
|
+
if (!file || !entry || entry === "starting") return card;
|
|
2247
|
+
const { ts, service } = entry;
|
|
2248
|
+
const abs = path8.resolve(root, file);
|
|
2249
|
+
const program = service.getProgram();
|
|
2250
|
+
const sf = program?.getSourceFile(abs);
|
|
2251
|
+
if (!program || !sf) return card;
|
|
2252
|
+
const checker = program.getTypeChecker();
|
|
2253
|
+
const lines = sf.text.split("\n");
|
|
2254
|
+
const identifierAt = (name, line) => {
|
|
2255
|
+
const word = new RegExp(`(?<![\\w$])${name.replace(/\$/g, "\\$")}(?![\\w$])`);
|
|
2256
|
+
for (let l = line - 1; l < Math.min(lines.length, line + 9); l++) {
|
|
2257
|
+
const col = lines[l]?.search(word) ?? -1;
|
|
2258
|
+
if (col < 0) continue;
|
|
2259
|
+
const pos = sf.getPositionOfLineAndCharacter(l, col);
|
|
2260
|
+
const node = deepest(ts, sf, pos);
|
|
2261
|
+
if (node && ts.isIdentifier(node) && node.text === name) return node;
|
|
2262
|
+
}
|
|
2263
|
+
return null;
|
|
2264
|
+
};
|
|
2265
|
+
const show = (type, at) => {
|
|
2266
|
+
const text = checker.typeToString(type, at, ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope);
|
|
2267
|
+
return text.length > MAX_TYPE || text === "any" ? "" : text;
|
|
2268
|
+
};
|
|
2269
|
+
const fill = (item) => {
|
|
2270
|
+
const children = item.children.map(fill);
|
|
2271
|
+
if (item.type) return { ...item, children };
|
|
2272
|
+
let type = "";
|
|
2273
|
+
try {
|
|
2274
|
+
if (item.kind === "hook") {
|
|
2275
|
+
const callee = identifierAt(item.name.replace(/\(.*$/s, ""), item.line);
|
|
2276
|
+
const call = callee?.parent && ts.isCallExpression(callee.parent) ? callee.parent : null;
|
|
2277
|
+
const sig = call && checker.getResolvedSignature(call);
|
|
2278
|
+
if (sig) {
|
|
2279
|
+
const ret = show(sig.getReturnType(), call);
|
|
2280
|
+
if (ret) type = `(${sig.parameters.map((p) => p.name).join(", ")}) => ${ret}`;
|
|
2281
|
+
}
|
|
2282
|
+
} else if (["state", "ref", "derived", "memo", "context", "store", "constant", "function", "callback"].includes(item.kind) && /^[\w$]+$/.test(item.name)) {
|
|
2283
|
+
const id = identifierAt(item.name, item.line);
|
|
2284
|
+
if (id) type = show(checker.getTypeAtLocation(id), id);
|
|
2285
|
+
}
|
|
2286
|
+
} catch {
|
|
2287
|
+
}
|
|
2288
|
+
return { ...item, type, children };
|
|
2289
|
+
};
|
|
2290
|
+
return { ...card, outline: card.outline.map(fill) };
|
|
2291
|
+
}
|
|
2292
|
+
function deepest(ts, sf, pos) {
|
|
2293
|
+
let found = null;
|
|
2294
|
+
const visit = (node) => {
|
|
2295
|
+
if (pos < node.getStart(sf) || pos >= node.getEnd()) return;
|
|
2296
|
+
found = node;
|
|
2297
|
+
ts.forEachChild(node, visit);
|
|
2298
|
+
};
|
|
2299
|
+
visit(sf);
|
|
2300
|
+
return found;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
// src/server/handler.ts
|
|
2304
|
+
var MAX_FILE_DIFF = 8e3;
|
|
2305
|
+
var MAX_TOTAL_DIFF = 2e4;
|
|
2306
|
+
var MAX_PRELOAD_FILE = 16e3;
|
|
2307
|
+
var MAX_PRELOAD_TOTAL = 36e3;
|
|
2308
|
+
var MAX_IMPORT_NOTES = 8e3;
|
|
2309
|
+
var DEFAULT_MODEL = "sonnet";
|
|
2310
|
+
function createDetectiveHandler(opts) {
|
|
2311
|
+
const base = opts.base ?? "/__detective";
|
|
2312
|
+
const root = path9.resolve(opts.root);
|
|
2313
|
+
const store = new KnowledgeStore(root, opts.storeDir);
|
|
2314
|
+
warmUpTypes(root);
|
|
2315
|
+
const git = new Git(root);
|
|
2316
|
+
return async function handle(req, res, next) {
|
|
2317
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2318
|
+
if (!url.pathname.startsWith(base)) return next ? next() : notFound(res);
|
|
2319
|
+
const origin = req.headers.origin;
|
|
2320
|
+
if (origin && !isLocalOrigin(origin)) return send(res, 403, { error: "Only local origins may use react-detective." });
|
|
2321
|
+
if (origin) {
|
|
2322
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
2323
|
+
res.setHeader("Access-Control-Allow-Headers", "content-type");
|
|
2324
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
2325
|
+
res.setHeader("Vary", "Origin");
|
|
2326
|
+
}
|
|
2327
|
+
if (req.method === "OPTIONS") return send(res, 204);
|
|
2328
|
+
const route = url.pathname.slice(base.length);
|
|
2329
|
+
try {
|
|
2330
|
+
if (route === "/health" && req.method === "GET") return send(res, 200, { ok: true, root });
|
|
2331
|
+
if (route === "/open" && req.method === "POST") {
|
|
2332
|
+
const body = await readJson(req);
|
|
2333
|
+
const file = safeResolve(root, body.file);
|
|
2334
|
+
if (!file) return send(res, 400, { error: "Invalid file" });
|
|
2335
|
+
launchEditor(`${file}:${Number(body.line) || 1}:${Number(body.column) || 1}`);
|
|
2336
|
+
return send(res, 200, { ok: true });
|
|
2337
|
+
}
|
|
2338
|
+
if (route === "/explain" && req.method === "POST") return explain(await readJson(req), res);
|
|
2339
|
+
} catch (err) {
|
|
2340
|
+
return send(res, 500, { error: String(err?.message ?? err) });
|
|
2341
|
+
}
|
|
2342
|
+
return notFound(res);
|
|
2343
|
+
};
|
|
2344
|
+
async function explain(body, res) {
|
|
2345
|
+
if (!body?.component || typeof body.component !== "string") return send(res, 400, { error: "component is required" });
|
|
2346
|
+
if (body.file && !safeResolve(root, body.file)) return send(res, 400, { error: "Invalid file" });
|
|
2347
|
+
res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" });
|
|
2348
|
+
const emit = (event) => res.write(JSON.stringify(event) + "\n");
|
|
2349
|
+
const rec = store.getComponent(body.component, body.file);
|
|
2350
|
+
const saved = isCurrentCard(rec.explanation?.card) ? rec.explanation : void 0;
|
|
2351
|
+
const context = {};
|
|
2352
|
+
const reliedOn = new Set(body.file ? [body.file] : []);
|
|
2353
|
+
const slotsFor = () => hookSlots(root, body.file, body.component);
|
|
2354
|
+
const fromSource = (card) => card ? withCheckedTypes(root, body.file, { ...card, usedIn: describeCallSites(root, body.component, card.usedIn) }) : card;
|
|
2355
|
+
const typesFor = (content) => resolveTypes(root, body.file, typeof content === "string" ? content : JSON.stringify(content));
|
|
2356
|
+
const gitInfo = (async () => {
|
|
2357
|
+
const isRepo2 = await git.isRepo();
|
|
2358
|
+
if (!isRepo2 || !body.file) return { isRepo: isRepo2, history: [], uncommitted: false };
|
|
2359
|
+
const [history2, uncommitted] = await Promise.all([git.recentCommits(body.file, 5), body.question ? false : git.isDirty([body.file])]);
|
|
2360
|
+
return { isRepo: isRepo2, history: history2, uncommitted };
|
|
2361
|
+
})();
|
|
2362
|
+
let contextSent = false;
|
|
2363
|
+
const sendContext = async () => {
|
|
2364
|
+
const g = await gitInfo;
|
|
2365
|
+
if (contextSent || body.question || !g.isRepo || !body.file) return;
|
|
2366
|
+
contextSent = true;
|
|
2367
|
+
emit({ type: "context", lastChange: g.history[0] ?? null, uncommitted: g.uncommitted });
|
|
2368
|
+
};
|
|
2369
|
+
const finishCached = async (event) => {
|
|
2370
|
+
emit(event);
|
|
2371
|
+
await sendContext();
|
|
2372
|
+
res.end();
|
|
2373
|
+
};
|
|
2374
|
+
let prev;
|
|
2375
|
+
if (body.question) {
|
|
2376
|
+
prev = store.findQuestion(rec, body.question);
|
|
2377
|
+
if (prev && !body.refresh && store.check(prev.deps).fresh) {
|
|
2378
|
+
return finishCached({ type: "result", markdown: prev.answer, types: typesFor(prev.answer), cached: true, createdAt: prev.createdAt, git: prev.git });
|
|
2379
|
+
}
|
|
2380
|
+
if (saved && store.check(saved.deps).fresh) {
|
|
2381
|
+
context.explanation = saved.markdown;
|
|
2382
|
+
Object.keys(saved.deps).forEach((f) => reliedOn.add(f));
|
|
2383
|
+
}
|
|
2384
|
+
} else {
|
|
2385
|
+
emit({
|
|
2386
|
+
type: "history",
|
|
2387
|
+
questions: rec.questions.map((q) => ({
|
|
2388
|
+
question: q.question,
|
|
2389
|
+
answer: q.answer,
|
|
2390
|
+
createdAt: q.createdAt,
|
|
2391
|
+
stale: !store.check(q.deps).fresh
|
|
2392
|
+
}))
|
|
2393
|
+
});
|
|
2394
|
+
if (saved?.card && !body.refresh) {
|
|
2395
|
+
const { fresh, changed } = store.check(saved.deps);
|
|
2396
|
+
const card = fromSource(mergeWithSource(saved.card, skeletonCard(root, body.file, body.component)));
|
|
2397
|
+
if (fresh) {
|
|
2398
|
+
return finishCached({ type: "result", markdown: saved.markdown, card, types: typesFor(card), slots: slotsFor(), cached: true, createdAt: saved.createdAt, git: saved.git });
|
|
2399
|
+
}
|
|
2400
|
+
const changes = await describeChanges(saved.deps, changed);
|
|
2401
|
+
emit({ type: "stale", markdown: saved.markdown, card, types: typesFor(card), slots: slotsFor(), changes, createdAt: saved.createdAt, git: saved.git });
|
|
2402
|
+
context.previous = { markdown: saved.markdown, changes, unchanged: Object.keys(saved.deps) };
|
|
2403
|
+
Object.keys(saved.deps).forEach((f) => reliedOn.add(f));
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
const usages = findUsages(root, body.component);
|
|
2407
|
+
if (!body.question && !context.previous) {
|
|
2408
|
+
const skeleton = fromSource(skeletonCard(root, body.file, body.component, usages));
|
|
2409
|
+
if (skeleton) emit({ type: "skeleton", card: skeleton, types: typesFor(skeleton), slots: slotsFor() });
|
|
2410
|
+
}
|
|
2411
|
+
const apiKey = findApiKey(root);
|
|
2412
|
+
if (!apiKey) {
|
|
2413
|
+
emit({ type: "error", code: "no-api-key", message: MISSING_KEY_MESSAGE(root) });
|
|
2414
|
+
await sendContext();
|
|
2415
|
+
return void res.end();
|
|
2416
|
+
}
|
|
2417
|
+
await sendContext();
|
|
2418
|
+
const { isRepo, history } = await gitInfo;
|
|
2419
|
+
if (history.length) context.history = history;
|
|
2420
|
+
const related = relatedRecords(rec, body);
|
|
2421
|
+
context.pastQuestions = pastAnswers(rec, related, reliedOn, prev);
|
|
2422
|
+
context.notes = relevantNotes(rec, body.file, related);
|
|
2423
|
+
Object.keys(context.notes).forEach((f) => reliedOn.add(f));
|
|
2424
|
+
context.imports = importedExports(body.file, reliedOn);
|
|
2425
|
+
context.usages = usages;
|
|
2426
|
+
context.sources = preloadSources(body.file, context);
|
|
2427
|
+
Object.keys(context.sources).forEach((f) => reliedOn.add(f));
|
|
2428
|
+
const abort = new AbortController();
|
|
2429
|
+
res.on("close", () => {
|
|
2430
|
+
if (!res.writableFinished) abort.abort();
|
|
2431
|
+
});
|
|
2432
|
+
try {
|
|
2433
|
+
const model = opts.model ?? DEFAULT_MODEL;
|
|
2434
|
+
const result = await explainComponent(body, {
|
|
2435
|
+
root,
|
|
2436
|
+
model,
|
|
2437
|
+
maxTurns: opts.maxTurns,
|
|
2438
|
+
signal: abort.signal,
|
|
2439
|
+
context,
|
|
2440
|
+
apiKey,
|
|
2441
|
+
git: isRepo ? git : void 0,
|
|
2442
|
+
onProgress: (text) => emit({ type: "progress", text })
|
|
2443
|
+
});
|
|
2444
|
+
if (result.card) {
|
|
2445
|
+
result.card = fromSource(mergeWithSource(result.card, skeletonCard(root, body.file, body.component, usages)));
|
|
2446
|
+
result.markdown = cardToMarkdown(result.card);
|
|
2447
|
+
}
|
|
2448
|
+
const deps = store.snapshot([...reliedOn, ...result.filesRead]);
|
|
2449
|
+
store.saveBlobs(deps);
|
|
2450
|
+
store.saveNotes(result.notes, store.snapshot([...result.filesRead, ...Object.keys(context.sources ?? {})]), result.exports);
|
|
2451
|
+
const stamp = await gitStamp(Object.keys(deps));
|
|
2452
|
+
const now = Date.now();
|
|
2453
|
+
if (body.question) {
|
|
2454
|
+
store.saveQuestion(body.component, body.file, { question: body.question, answer: result.markdown, deps, createdAt: now, git: stamp });
|
|
2455
|
+
} else {
|
|
2456
|
+
store.saveExplanation(body.component, body.file, { markdown: result.markdown, card: result.card, deps, createdAt: now, model: result.model ?? model, git: stamp });
|
|
2457
|
+
}
|
|
2458
|
+
emit({
|
|
2459
|
+
type: "result",
|
|
2460
|
+
markdown: result.markdown,
|
|
2461
|
+
card: result.card,
|
|
2462
|
+
types: typesFor(result.card ?? result.markdown),
|
|
2463
|
+
slots: body.question ? void 0 : slotsFor(),
|
|
2464
|
+
cached: false,
|
|
2465
|
+
costUsd: result.costUsd,
|
|
2466
|
+
turns: result.turns,
|
|
2467
|
+
model: result.model ?? model,
|
|
2468
|
+
filesRead: result.filesRead.length,
|
|
2469
|
+
notesReused: Object.keys(context.notes ?? {}).length,
|
|
2470
|
+
exportsReused: context.imports?.length ?? 0,
|
|
2471
|
+
answersReused: context.pastQuestions?.length ?? 0,
|
|
2472
|
+
git: stamp
|
|
2473
|
+
});
|
|
2474
|
+
} catch (err) {
|
|
2475
|
+
if (!abort.signal.aborted) emit({ type: "error", message: friendlyError(err) });
|
|
2476
|
+
}
|
|
2477
|
+
res.end();
|
|
2478
|
+
}
|
|
2479
|
+
async function describeChanges(deps, changed) {
|
|
2480
|
+
let budget = MAX_TOTAL_DIFF;
|
|
2481
|
+
const out = [];
|
|
2482
|
+
for (const file of changed) {
|
|
2483
|
+
const now = store.readFile(file);
|
|
2484
|
+
if (now === null) {
|
|
2485
|
+
out.push({ file, deleted: true });
|
|
2486
|
+
continue;
|
|
2487
|
+
}
|
|
2488
|
+
const old = store.readBlob(deps[file]) ?? (await git.catBlob(deps[file]))?.replace(/\r\n/g, "\n") ?? null;
|
|
2489
|
+
if (old === null) {
|
|
2490
|
+
out.push({ file });
|
|
2491
|
+
continue;
|
|
2492
|
+
}
|
|
2493
|
+
const diff = createTwoFilesPatch(`a/${file}`, `b/${file}`, old, now, "explained version", "now", { context: 3 }).replace(/^=+\n/, "").trimEnd();
|
|
2494
|
+
if (diff.length > MAX_FILE_DIFF || diff.length > budget) out.push({ file });
|
|
2495
|
+
else {
|
|
2496
|
+
budget -= diff.length;
|
|
2497
|
+
out.push({ file, diff });
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
return out;
|
|
2501
|
+
}
|
|
2502
|
+
async function gitStamp(files) {
|
|
2503
|
+
const head = await git.head();
|
|
2504
|
+
return head ? { ...head, dirty: await git.isDirty(files) } : void 0;
|
|
2505
|
+
}
|
|
2506
|
+
function relatedRecords(rec, body) {
|
|
2507
|
+
const names = new Set((body.chain ?? []).filter((c) => c !== body.component));
|
|
2508
|
+
const files = new Set(body.file ? localImports(root, body.file) : []);
|
|
2509
|
+
callerFiles(rec).forEach((f) => files.add(f));
|
|
2510
|
+
if (body.file) files.add(body.file);
|
|
2511
|
+
return store.listComponents().filter((r) => !(r.component === body.component && r.file === (body.file ?? null))).filter((r) => names.has(r.component) || r.file && files.has(r.file));
|
|
2512
|
+
}
|
|
2513
|
+
function callerFiles(rec) {
|
|
2514
|
+
const usedIn = isCurrentCard(rec.explanation?.card) ? rec.explanation.card.usedIn : [];
|
|
2515
|
+
return usedIn.map((u) => u.ref.replace(/:\d+(:\d+)?$/, "")).filter((f) => !!safeResolve(root, f));
|
|
2516
|
+
}
|
|
2517
|
+
function pastAnswers(rec, related, reliedOn, exclude) {
|
|
2518
|
+
const own = rec.questions.filter((q) => q !== exclude && store.check(q.deps).fresh).slice(-5).reverse().map((entry) => ({ entry }));
|
|
2519
|
+
const others = related.flatMap((r) => r.questions.map((entry) => ({ entry, component: r.component }))).filter((c) => store.check(c.entry.deps).fresh).sort((a, b) => b.entry.createdAt - a.entry.createdAt).slice(0, 4);
|
|
2520
|
+
const out = [];
|
|
2521
|
+
let budget = 8e3;
|
|
2522
|
+
for (const { entry, component } of [...own, ...others]) {
|
|
2523
|
+
const answer = entry.answer.slice(0, 1500);
|
|
2524
|
+
if ((budget -= answer.length + entry.question.length) < 0) break;
|
|
2525
|
+
out.push({ question: entry.question, answer, component });
|
|
2526
|
+
if (!component) Object.keys(entry.deps).forEach((f) => reliedOn.add(f));
|
|
2527
|
+
}
|
|
2528
|
+
return out;
|
|
2529
|
+
}
|
|
2530
|
+
function preloadSources(file, ctx) {
|
|
2531
|
+
const out = {};
|
|
2532
|
+
if (!file) return out;
|
|
2533
|
+
const changedWithDiff = new Set((ctx.previous?.changes ?? []).filter((c) => c.diff).map((c) => c.file));
|
|
2534
|
+
const unchanged = new Set(ctx.previous?.unchanged ?? []);
|
|
2535
|
+
let budget = MAX_PRELOAD_TOTAL;
|
|
2536
|
+
const described = /* @__PURE__ */ new Set();
|
|
2537
|
+
for (const { file: from, names } of importsOf(root, file)) {
|
|
2538
|
+
if (!names.includes("*") && names.every((n) => ctx.imports?.some((i) => i.from === from && i.name === n))) described.add(from);
|
|
2539
|
+
}
|
|
2540
|
+
const add = (f) => {
|
|
2541
|
+
if (out[f] !== void 0 || changedWithDiff.has(f) || unchanged.has(f) || ctx.notes?.[f] || described.has(f)) return;
|
|
2542
|
+
const text = store.readFile(f);
|
|
2543
|
+
if (text === null || text.length > MAX_PRELOAD_FILE || text.length > budget) return;
|
|
2544
|
+
budget -= text.length;
|
|
2545
|
+
out[f] = text;
|
|
2546
|
+
};
|
|
2547
|
+
const own = store.readFile(file);
|
|
2548
|
+
if (own !== null && own.length <= MAX_PRELOAD_FILE && !changedWithDiff.has(file) && !(ctx.previous && unchanged.has(file))) {
|
|
2549
|
+
budget -= own.length;
|
|
2550
|
+
out[file] = own;
|
|
2551
|
+
}
|
|
2552
|
+
if (!ctx.previous) localImports(root, file).forEach(add);
|
|
2553
|
+
return out;
|
|
2554
|
+
}
|
|
2555
|
+
function importedExports(file, reliedOn) {
|
|
2556
|
+
if (!file) return [];
|
|
2557
|
+
const out = [];
|
|
2558
|
+
let budget = MAX_IMPORT_NOTES;
|
|
2559
|
+
for (const { file: from, names } of importsOf(root, file)) {
|
|
2560
|
+
const exported = exportsOf(root, from);
|
|
2561
|
+
const wanted = names.includes("*") ? [...exported.keys()] : names;
|
|
2562
|
+
for (const name of wanted) {
|
|
2563
|
+
const e = exported.get(name);
|
|
2564
|
+
if (!e) continue;
|
|
2565
|
+
const own = store.validExports(e.file);
|
|
2566
|
+
const description = own[e.declaredAs] ?? (e.declaredAs === "default" ? own[e.local] : void 0) ?? (e.file !== from ? store.validExports(from)[name] : void 0);
|
|
2567
|
+
if (!description) continue;
|
|
2568
|
+
const entry = { name, from, declaredIn: e.file !== from ? e.file : void 0, line: e.line, kind: e.kind, signature: e.signature, description };
|
|
2569
|
+
if ((budget -= description.length + e.signature.length + name.length + from.length) < 0) return out;
|
|
2570
|
+
out.push(entry);
|
|
2571
|
+
reliedOn.add(e.file);
|
|
2572
|
+
reliedOn.add(from);
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
return out;
|
|
2576
|
+
}
|
|
2577
|
+
function relevantNotes(rec, file, related) {
|
|
2578
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
2579
|
+
if (file) {
|
|
2580
|
+
candidates.add(file);
|
|
2581
|
+
localImports(root, file).forEach((f) => candidates.add(f));
|
|
2582
|
+
}
|
|
2583
|
+
callerFiles(rec).forEach((f) => candidates.add(f));
|
|
2584
|
+
related.forEach((r) => r.file && candidates.add(r.file));
|
|
2585
|
+
Object.keys(rec.explanation?.deps ?? {}).forEach((f) => candidates.add(f));
|
|
2586
|
+
rec.questions.forEach((q) => Object.keys(q.deps).forEach((f) => candidates.add(f)));
|
|
2587
|
+
const out = {};
|
|
2588
|
+
let budget = 12e3;
|
|
2589
|
+
const close = store.validNotes(candidates);
|
|
2590
|
+
const rest = store.validNotes(store.noteFiles().filter((f) => !candidates.has(f)));
|
|
2591
|
+
for (const [f, summary] of [...Object.entries(close), ...Object.entries(rest)]) {
|
|
2592
|
+
if ((budget -= summary.length + f.length) < 0) break;
|
|
2593
|
+
out[f] = summary;
|
|
2594
|
+
}
|
|
2595
|
+
return out;
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
function isLocalOrigin(origin) {
|
|
2599
|
+
try {
|
|
2600
|
+
const { hostname } = new URL(origin);
|
|
2601
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname.endsWith(".localhost");
|
|
2602
|
+
} catch {
|
|
2603
|
+
return false;
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
function safeResolve(root, rel) {
|
|
2607
|
+
if (typeof rel !== "string" || !rel) return null;
|
|
2608
|
+
const abs = path9.resolve(root, rel);
|
|
2609
|
+
const relative = path9.relative(root, abs);
|
|
2610
|
+
return relative && !relative.startsWith("..") && !path9.isAbsolute(relative) ? abs : null;
|
|
2611
|
+
}
|
|
2612
|
+
function friendlyError(err) {
|
|
2613
|
+
const msg = String(err?.message ?? err);
|
|
2614
|
+
if (/auth|api key|x-api-key|invalid.*key|401|credential/i.test(msg)) {
|
|
2615
|
+
return `${msg}
|
|
2616
|
+
|
|
2617
|
+
The Anthropic API key was rejected. Check ANTHROPIC_API_KEY in your .env.local (or environment) and try again.`;
|
|
2618
|
+
}
|
|
2619
|
+
return msg;
|
|
2620
|
+
}
|
|
2621
|
+
function readJson(req) {
|
|
2622
|
+
return new Promise((resolve, reject) => {
|
|
2623
|
+
let data = "";
|
|
2624
|
+
req.on("data", (chunk) => {
|
|
2625
|
+
data += chunk;
|
|
2626
|
+
if (data.length > 1e6) reject(new Error("Body too large"));
|
|
2627
|
+
});
|
|
2628
|
+
req.on("end", () => {
|
|
2629
|
+
try {
|
|
2630
|
+
resolve(data ? JSON.parse(data) : {});
|
|
2631
|
+
} catch {
|
|
2632
|
+
reject(new Error("Invalid JSON"));
|
|
2633
|
+
}
|
|
2634
|
+
});
|
|
2635
|
+
req.on("error", reject);
|
|
2636
|
+
});
|
|
2637
|
+
}
|
|
2638
|
+
function send(res, status, body) {
|
|
2639
|
+
res.statusCode = status;
|
|
2640
|
+
if (body) res.setHeader("Content-Type", "application/json");
|
|
2641
|
+
res.end(body ? JSON.stringify(body) : void 0);
|
|
2642
|
+
}
|
|
2643
|
+
function notFound(res) {
|
|
2644
|
+
send(res, 404, { error: "Not found" });
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
// src/server/reload.ts
|
|
2648
|
+
import fs8 from "fs";
|
|
2649
|
+
import path10 from "path";
|
|
2650
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
2651
|
+
function reloadingHandler(opts, onReload) {
|
|
2652
|
+
let handler = createDetectiveHandler(opts);
|
|
2653
|
+
const here = fileURLToPath(import.meta.url);
|
|
2654
|
+
if (here.split(path10.sep).includes("node_modules") || path10.basename(path10.dirname(here)) !== "dist") return handler;
|
|
2655
|
+
const entry = path10.join(path10.dirname(here), "server.js");
|
|
2656
|
+
const mtime = () => {
|
|
2657
|
+
try {
|
|
2658
|
+
return fs8.statSync(entry).mtimeMs;
|
|
2659
|
+
} catch {
|
|
2660
|
+
return 0;
|
|
2661
|
+
}
|
|
2662
|
+
};
|
|
2663
|
+
let loaded = mtime();
|
|
2664
|
+
let seen = loaded;
|
|
2665
|
+
const timer = setInterval(async () => {
|
|
2666
|
+
const now = mtime();
|
|
2667
|
+
if (!now || now === loaded || now !== seen) return void (seen = now);
|
|
2668
|
+
loaded = now;
|
|
2669
|
+
try {
|
|
2670
|
+
const mod = await import(`${pathToFileURL(entry).href}?t=${now}`);
|
|
2671
|
+
handler = mod.createDetectiveHandler(opts);
|
|
2672
|
+
console.log("[react-detective] rebuilt: reloaded the server code");
|
|
2673
|
+
onReload?.();
|
|
2674
|
+
} catch (err) {
|
|
2675
|
+
console.warn("[react-detective] couldn't reload the rebuilt server code:", err);
|
|
2676
|
+
}
|
|
2677
|
+
}, 1e3);
|
|
2678
|
+
timer.unref();
|
|
2679
|
+
return ((req, res, next) => handler(req, res, next));
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
export {
|
|
2683
|
+
explainComponent,
|
|
2684
|
+
createDetectiveHandler,
|
|
2685
|
+
reloadingHandler
|
|
2686
|
+
};
|