wicked-brain 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,233 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const CODE_MANIFESTS = [
5
+ "package.json",
6
+ "pyproject.toml",
7
+ "requirements.txt",
8
+ "Pipfile",
9
+ "go.mod",
10
+ "Cargo.toml",
11
+ "pom.xml",
12
+ "build.gradle",
13
+ "build.gradle.kts",
14
+ "Gemfile",
15
+ "composer.json",
16
+ "mix.exs",
17
+ "deno.json",
18
+ "bun.lockb",
19
+ ];
20
+
21
+ const CONTENT_MANIFESTS = [
22
+ "mkdocs.yml",
23
+ "mkdocs.yaml",
24
+ "_config.yml",
25
+ "hugo.toml",
26
+ "hugo.yaml",
27
+ "book.toml",
28
+ "docusaurus.config.js",
29
+ "docusaurus.config.ts",
30
+ "docusaurus.config.mjs",
31
+ "astro.config.mjs",
32
+ "astro.config.ts",
33
+ ".vitepress",
34
+ "antora.yml",
35
+ ];
36
+
37
+ const CODE_DIRS = ["src", "lib", "server", "cmd", "internal", "pkg", "app"];
38
+ const CONTENT_DIRS = ["content", "posts", "chapters", "handbook", "policies", "articles"];
39
+
40
+ const CODE_EXTS = new Set([
41
+ ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
42
+ ".py", ".pyi",
43
+ ".go", ".rs",
44
+ ".java", ".kt", ".kts", ".scala", ".groovy",
45
+ ".rb", ".php", ".ex", ".exs",
46
+ ".c", ".h", ".cpp", ".hpp", ".cc", ".cxx",
47
+ ".cs", ".fs", ".vb",
48
+ ".swift", ".m", ".mm",
49
+ ".sh", ".bash", ".zsh", ".fish",
50
+ ".lua", ".r",
51
+ ]);
52
+
53
+ const PROSE_EXTS = new Set([
54
+ ".md", ".mdx", ".markdown",
55
+ ".adoc", ".asciidoc",
56
+ ".rst",
57
+ ".txt",
58
+ ".tex",
59
+ ".org",
60
+ ]);
61
+
62
+ const SKIP_DIRS = new Set([
63
+ ".git", "node_modules", "dist", "build", "out",
64
+ ".venv", "venv", "env", "__pycache__",
65
+ ".next", ".nuxt", ".svelte-kit", ".cache",
66
+ "target", "bin", "obj",
67
+ "archive", "vendor",
68
+ ".wicked-brain",
69
+ ]);
70
+
71
+ /**
72
+ * Pure classifier. Takes shallow scan inputs, returns mode verdict.
73
+ */
74
+ export function classifyRepo({ manifests = [], dirs = [], codeFileCount = 0, proseFileCount = 0 }) {
75
+ let codeScore = 0;
76
+ let contentScore = 0;
77
+ const reasons = [];
78
+
79
+ for (const m of manifests) {
80
+ if (CODE_MANIFESTS.includes(m)) {
81
+ codeScore += 10;
82
+ reasons.push(`+10 code manifest: ${m}`);
83
+ }
84
+ if (CONTENT_MANIFESTS.includes(m)) {
85
+ contentScore += 10;
86
+ reasons.push(`+10 content manifest: ${m}`);
87
+ }
88
+ }
89
+
90
+ for (const d of dirs) {
91
+ if (CODE_DIRS.includes(d)) {
92
+ codeScore += 5;
93
+ reasons.push(`+5 code dir: ${d}/`);
94
+ }
95
+ if (CONTENT_DIRS.includes(d)) {
96
+ contentScore += 5;
97
+ reasons.push(`+5 content dir: ${d}/`);
98
+ }
99
+ }
100
+
101
+ const total = codeFileCount + proseFileCount;
102
+ const MIN_SAMPLE = 5;
103
+ if (total >= MIN_SAMPLE) {
104
+ const codeRatio = codeFileCount / total;
105
+ const proseRatio = proseFileCount / total;
106
+ const codeBoost = Math.round(codeRatio * 20);
107
+ const contentBoost = Math.round(proseRatio * 20);
108
+ if (codeBoost > 0) {
109
+ codeScore += codeBoost;
110
+ reasons.push(`+${codeBoost} code_ratio=${codeRatio.toFixed(2)} (${codeFileCount}/${total})`);
111
+ }
112
+ if (contentBoost > 0) {
113
+ contentScore += contentBoost;
114
+ reasons.push(`+${contentBoost} prose_ratio=${proseRatio.toFixed(2)} (${proseFileCount}/${total})`);
115
+ }
116
+ } else if (total > 0) {
117
+ reasons.push(`ratio skipped: sample too small (${total} < ${MIN_SAMPLE})`);
118
+ }
119
+
120
+ let mode;
121
+ if (codeScore >= 15 && contentScore < 10) mode = "code";
122
+ else if (contentScore >= 15 && codeScore < 10) mode = "content";
123
+ else if (codeScore >= 10 && contentScore >= 10) mode = "mixed";
124
+ else mode = "unknown";
125
+
126
+ return {
127
+ mode,
128
+ score: { code: codeScore, content: contentScore },
129
+ reasons,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Default paths for the wiki root, given a detection result.
135
+ *
136
+ * Honors the discovery contract's convention-fallback order for an already-
137
+ * present wiki tree: `wiki/` → `docs/wiki/` → default `wiki/`. That way a
138
+ * repo that already has `docs/wiki/` (common when `docs/` is contributor-
139
+ * facing) is detected correctly and mode.json points at the right place.
140
+ */
141
+ export function defaultWikiRoots({ mode }, { hasWikiDir, hasDocsWikiDir, hasDocsDir }) {
142
+ const wikiRoot = hasWikiDir
143
+ ? "wiki"
144
+ : hasDocsWikiDir
145
+ ? "docs/wiki"
146
+ : "wiki";
147
+ if (mode === "mixed") {
148
+ return {
149
+ wiki_root: wikiRoot,
150
+ content_root: hasDocsDir ? "docs" : "content",
151
+ };
152
+ }
153
+ if (mode === "content") {
154
+ return {
155
+ wiki_root: wikiRoot,
156
+ content_root: hasDocsDir ? "docs" : "content",
157
+ };
158
+ }
159
+ return {
160
+ wiki_root: wikiRoot,
161
+ content_root: null,
162
+ };
163
+ }
164
+
165
+ /**
166
+ * I/O wrapper: scans a repo root with caps, then classifies.
167
+ */
168
+ export async function detectRepoMode(repoRoot, { maxFiles = 10000, maxDepth = 6 } = {}) {
169
+ const absRoot = path.resolve(repoRoot);
170
+ const topLevel = await readDirSafe(absRoot);
171
+
172
+ const manifests = topLevel.filter((e) => e.isFile).map((e) => e.name);
173
+ const dirs = topLevel.filter((e) => e.isDir && !SKIP_DIRS.has(e.name)).map((e) => e.name);
174
+
175
+ let codeFileCount = 0;
176
+ let proseFileCount = 0;
177
+ let visited = 0;
178
+ const queue = dirs.map((d) => ({ rel: d, depth: 1 }));
179
+
180
+ while (queue.length > 0 && visited < maxFiles) {
181
+ const { rel, depth } = queue.shift();
182
+ if (depth > maxDepth) continue;
183
+ const abs = path.join(absRoot, rel);
184
+ const entries = await readDirSafe(abs);
185
+ for (const e of entries) {
186
+ if (visited >= maxFiles) break;
187
+ if (e.isDir) {
188
+ if (SKIP_DIRS.has(e.name)) continue;
189
+ queue.push({ rel: path.join(rel, e.name), depth: depth + 1 });
190
+ } else if (e.isFile) {
191
+ visited += 1;
192
+ const ext = path.extname(e.name).toLowerCase();
193
+ if (CODE_EXTS.has(ext)) codeFileCount += 1;
194
+ else if (PROSE_EXTS.has(ext)) proseFileCount += 1;
195
+ }
196
+ }
197
+ }
198
+
199
+ const result = classifyRepo({ manifests, dirs, codeFileCount, proseFileCount });
200
+ const hasWikiDir = dirs.includes("wiki");
201
+ const hasDocsDir = dirs.includes("docs");
202
+ // Check for a pre-existing `docs/wiki/` tree — this is the "contributor-
203
+ // facing docs/" case where the wiki has been nested by convention.
204
+ let hasDocsWikiDir = false;
205
+ if (hasDocsDir) {
206
+ try {
207
+ const docsEntries = await fs.readdir(path.join(absRoot, "docs"), { withFileTypes: true });
208
+ hasDocsWikiDir = docsEntries.some((e) => e.isDirectory() && e.name === "wiki");
209
+ } catch {
210
+ // directory unreadable — treat as absent
211
+ }
212
+ }
213
+ const roots = defaultWikiRoots(result, { hasWikiDir, hasDocsWikiDir, hasDocsDir });
214
+
215
+ return {
216
+ ...result,
217
+ ...roots,
218
+ scanned: { files: visited, capped: visited >= maxFiles },
219
+ };
220
+ }
221
+
222
+ async function readDirSafe(dir) {
223
+ try {
224
+ const entries = await fs.readdir(dir, { withFileTypes: true });
225
+ return entries.map((e) => ({
226
+ name: e.name,
227
+ isDir: e.isDirectory(),
228
+ isFile: e.isFile(),
229
+ }));
230
+ } catch {
231
+ return [];
232
+ }
233
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Minimal YAML-subset frontmatter parser.
3
+ *
4
+ * Supports the exact shape wicked-brain wiki pages use. Does NOT support
5
+ * nested objects, multi-line string folding, anchors, tags, or other full
6
+ * YAML features. If those are ever needed, switch to the `yaml` npm package
7
+ * rather than expanding this.
8
+ *
9
+ * Supported value forms:
10
+ * key: value → string (quotes stripped)
11
+ * key: "value with :" → quoted string
12
+ * key: true / false → boolean
13
+ * key: 42 → number
14
+ * key: 2026-04-17 → date (kept as string for portability)
15
+ * key: [a, b, "c, d"] → inline array of scalars
16
+ * key:
17
+ * - a
18
+ * - b → block array of scalars
19
+ *
20
+ * Lines starting with `#` are treated as comments and ignored. Leading and
21
+ * trailing whitespace on each line is stripped.
22
+ */
23
+
24
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
25
+
26
+ /**
27
+ * Split raw content into { frontmatter, body }.
28
+ * If no frontmatter fence is present, returns { frontmatter: null, body: content }.
29
+ */
30
+ export function extractFrontmatter(content) {
31
+ if (typeof content !== "string") return { frontmatter: null, body: "" };
32
+ const m = content.match(FRONTMATTER_RE);
33
+ if (!m) return { frontmatter: null, body: content };
34
+ return { frontmatter: m[1], body: m[2] };
35
+ }
36
+
37
+ /**
38
+ * Parse a frontmatter block into a flat object. Returns {} on null/empty input.
39
+ * Throws on clearly malformed input (unclosed block array, duplicate key).
40
+ */
41
+ export function parseFrontmatterBlock(block) {
42
+ if (!block) return {};
43
+ const lines = block.split(/\r?\n/);
44
+ const data = {};
45
+ let i = 0;
46
+ while (i < lines.length) {
47
+ const rawLine = lines[i];
48
+ const line = rawLine.trimEnd();
49
+ i++;
50
+ if (line.length === 0) continue;
51
+ if (/^\s*#/.test(line)) continue;
52
+ const trimmed = line.trimStart();
53
+ if (trimmed.startsWith("-")) {
54
+ throw new Error(`unexpected list item at top level: ${rawLine}`);
55
+ }
56
+ const colonIdx = trimmed.indexOf(":");
57
+ if (colonIdx < 0) {
58
+ throw new Error(`expected 'key: value' but got: ${rawLine}`);
59
+ }
60
+ const key = trimmed.slice(0, colonIdx).trim();
61
+ const rest = trimmed.slice(colonIdx + 1).trim();
62
+ if (Object.prototype.hasOwnProperty.call(data, key)) {
63
+ throw new Error(`duplicate key: ${key}`);
64
+ }
65
+ if (rest.length === 0) {
66
+ const { array, consumed } = readBlockArray(lines, i);
67
+ data[key] = array;
68
+ i += consumed;
69
+ continue;
70
+ }
71
+ data[key] = parseScalarOrInlineArray(rest);
72
+ }
73
+ return data;
74
+ }
75
+
76
+ /**
77
+ * Parse content into { data, body }. Convenience wrapper for the common case.
78
+ */
79
+ export function parseFrontmatter(content) {
80
+ const { frontmatter, body } = extractFrontmatter(content);
81
+ const data = parseFrontmatterBlock(frontmatter);
82
+ return { data, body };
83
+ }
84
+
85
+ /**
86
+ * Get a field value from parsed data. Returns null if missing.
87
+ * Exists primarily so callers can be explicit about "missing" vs "false/0/''".
88
+ */
89
+ export function getField(data, name) {
90
+ if (data == null || !Object.prototype.hasOwnProperty.call(data, name)) return null;
91
+ return data[name];
92
+ }
93
+
94
+ /**
95
+ * Serialize a flat object back to a frontmatter block. Inverse of
96
+ * parseFrontmatterBlock for the supported subset. Arrays are emitted inline
97
+ * when short, block form when any element contains a comma or the total
98
+ * length exceeds 60 chars. Booleans/numbers pass through; strings are quoted
99
+ * only when necessary (contain : # [ ] , or leading/trailing whitespace).
100
+ */
101
+ export function serializeFrontmatterBlock(data) {
102
+ if (!data || typeof data !== "object") return "";
103
+ const out = [];
104
+ for (const [key, value] of Object.entries(data)) {
105
+ if (Array.isArray(value)) {
106
+ const inline = `[${value.map((v) => formatScalar(v)).join(", ")}]`;
107
+ if (value.length === 0) {
108
+ out.push(`${key}: []`);
109
+ } else if (inline.length <= 60 && !value.some((v) => typeof v === "string" && v.includes(","))) {
110
+ out.push(`${key}: ${inline}`);
111
+ } else {
112
+ out.push(`${key}:`);
113
+ for (const v of value) out.push(` - ${formatScalar(v)}`);
114
+ }
115
+ } else {
116
+ out.push(`${key}: ${formatScalar(value)}`);
117
+ }
118
+ }
119
+ return out.join("\n");
120
+ }
121
+
122
+ // --- internals ---
123
+
124
+ function readBlockArray(lines, startIdx) {
125
+ const items = [];
126
+ let i = startIdx;
127
+ while (i < lines.length) {
128
+ const line = lines[i];
129
+ if (line.trim().length === 0) {
130
+ i++;
131
+ continue;
132
+ }
133
+ const m = line.match(/^\s+-\s+(.*)$/);
134
+ if (!m) break;
135
+ items.push(parseScalarOrInlineArray(m[1].trim()));
136
+ i++;
137
+ }
138
+ return { array: items, consumed: i - startIdx };
139
+ }
140
+
141
+ function parseScalarOrInlineArray(raw) {
142
+ if (raw.length === 0) return "";
143
+ if (raw.startsWith("[")) {
144
+ if (!raw.endsWith("]")) {
145
+ throw new Error(`unterminated inline array: ${raw}`);
146
+ }
147
+ const inner = raw.slice(1, -1).trim();
148
+ if (inner.length === 0) return [];
149
+ return splitInlineArray(inner).map(parseScalar);
150
+ }
151
+ return parseScalar(raw);
152
+ }
153
+
154
+ function splitInlineArray(inner) {
155
+ const out = [];
156
+ let buf = "";
157
+ let inQuote = null;
158
+ for (let i = 0; i < inner.length; i++) {
159
+ const ch = inner[i];
160
+ if (inQuote) {
161
+ buf += ch;
162
+ if (ch === inQuote) inQuote = null;
163
+ continue;
164
+ }
165
+ if (ch === '"' || ch === "'") {
166
+ inQuote = ch;
167
+ buf += ch;
168
+ continue;
169
+ }
170
+ if (ch === ",") {
171
+ out.push(buf.trim());
172
+ buf = "";
173
+ continue;
174
+ }
175
+ buf += ch;
176
+ }
177
+ if (buf.trim().length > 0) out.push(buf.trim());
178
+ return out;
179
+ }
180
+
181
+ function parseScalar(raw) {
182
+ const trimmed = raw.trim();
183
+ if (trimmed.length === 0) return "";
184
+ if (/^".*"$/.test(trimmed) || /^'.*'$/.test(trimmed)) {
185
+ return trimmed.slice(1, -1);
186
+ }
187
+ if (trimmed === "true") return true;
188
+ if (trimmed === "false") return false;
189
+ if (trimmed === "null" || trimmed === "~") return null;
190
+ if (/^-?\d+$/.test(trimmed)) return Number(trimmed);
191
+ if (/^-?\d+\.\d+$/.test(trimmed)) return Number(trimmed);
192
+ // Dates kept as strings — downstream often wants the original form.
193
+ return trimmed;
194
+ }
195
+
196
+ function formatScalar(value) {
197
+ if (value === null) return "null";
198
+ if (typeof value === "boolean") return value ? "true" : "false";
199
+ if (typeof value === "number") return String(value);
200
+ const s = String(value);
201
+ if (s.length === 0) return '""';
202
+ if (/[:#\[\],]/.test(s) || /^\s|\s$/.test(s)) return `"${s.replace(/"/g, '\\"')}"`;
203
+ return s;
204
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Contract API generator.
3
+ *
4
+ * Reads the `const actions = {...}` object literal in wicked-brain-server.mjs
5
+ * and produces:
6
+ * - A structured JSON manifest of every action (name, implementation, notes)
7
+ * - A markdown page with a stable H2 taxonomy and symbol-named anchors
8
+ *
9
+ * Pure functions — no I/O. The CLI glue (scripts/gen-wiki.mjs) reads the
10
+ * source, calls the extractor, and writes the outputs.
11
+ *
12
+ * The extractor is intentionally text-based, not AST-based: the file we
13
+ * target is disciplined (one object literal, one handler per line after the
14
+ * `=>`). If the shape changes, this extractor breaks loudly with a failing
15
+ * test rather than silently producing stale output.
16
+ */
17
+
18
+ const ACTIONS_BLOCK_RE = /const\s+actions\s*=\s*\{\s*\n([\s\S]*?)\n\};/;
19
+ const ACTION_LINE_RE = /^\s*(?:"([^"]+)"|([a-zA-Z_][a-zA-Z0-9_-]*))\s*:\s*(async\s+)?\(([^)]*)\)\s*=>/;
20
+ const DB_CALL_RE = /\bdb\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/g;
21
+ const LSP_CALL_RE = /\blsp\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/g;
22
+
23
+ /**
24
+ * Extract actions from the server source. Returns an array in source order.
25
+ */
26
+ export function extractActions(source) {
27
+ const block = source.match(ACTIONS_BLOCK_RE);
28
+ if (!block) {
29
+ throw new Error("Could not find `const actions = {...}` block");
30
+ }
31
+ const body = block[1];
32
+ const lines = body.split("\n");
33
+ const actions = [];
34
+ let i = 0;
35
+ while (i < lines.length) {
36
+ const line = lines[i];
37
+ const m = line.match(ACTION_LINE_RE);
38
+ if (!m) { i++; continue; }
39
+ const name = m[1] ?? m[2];
40
+ const isAsync = Boolean(m[3]);
41
+ const paramsSig = m[4].trim();
42
+ // Collect the handler body (rest of this line + following lines until a
43
+ // line that looks like another action or closes the object).
44
+ const handlerLines = [line];
45
+ i++;
46
+ while (i < lines.length) {
47
+ const next = lines[i];
48
+ if (ACTION_LINE_RE.test(next)) break;
49
+ handlerLines.push(next);
50
+ i++;
51
+ }
52
+ const handlerText = handlerLines.join("\n");
53
+ const impls = collectImplCalls(handlerText);
54
+ actions.push({
55
+ name,
56
+ async: isAsync,
57
+ params: parseParamUses(paramsSig, handlerText),
58
+ impls,
59
+ });
60
+ }
61
+ return actions;
62
+ }
63
+
64
+ /**
65
+ * Render a contract-api.md page from the extracted actions.
66
+ * Declares canonical_for: [CONTRACT-API] so it's the single source.
67
+ */
68
+ export function renderContractApi({ actions, generatedAt, sourcePath }) {
69
+ const lines = [];
70
+ lines.push("---");
71
+ lines.push("status: published");
72
+ lines.push("canonical_for: [CONTRACT-API]");
73
+ lines.push("references: []");
74
+ lines.push(`owner: core`);
75
+ lines.push(`last_reviewed: ${generatedAt}`);
76
+ lines.push("generated: true");
77
+ lines.push(`source: ${sourcePath}`);
78
+ lines.push("---");
79
+ lines.push("");
80
+ lines.push("# Contract: `POST /api`");
81
+ lines.push("");
82
+ lines.push("Single endpoint, action-dispatched. Body shape:");
83
+ lines.push("");
84
+ lines.push("```json");
85
+ lines.push('{ "action": "<name>", "params": { ... } }');
86
+ lines.push("```");
87
+ lines.push("");
88
+ lines.push(
89
+ "This page is **generated** from the server source. Do not hand-edit — "
90
+ + "changes will be overwritten on the next `npm run gen:wiki`. The truth "
91
+ + `lives at \`${sourcePath}\`; update that, then regenerate.`,
92
+ );
93
+ lines.push("");
94
+ lines.push("## Actions");
95
+ lines.push("");
96
+ lines.push("| Action | Params referenced | Implementation |");
97
+ lines.push("|---|---|---|");
98
+ for (const a of actions) {
99
+ const params = a.params.length ? a.params.map((p) => `\`${p}\``).join(", ") : "—";
100
+ const impls = a.impls.length
101
+ ? a.impls.map((i) => `\`${i.target}#${i.method}\``).join(", ")
102
+ : "—";
103
+ lines.push(`| \`${a.name}\` | ${params} | ${impls} |`);
104
+ }
105
+ lines.push("");
106
+ lines.push("## Per-action anchors");
107
+ lines.push("");
108
+ for (const a of actions) {
109
+ lines.push(`### \`${a.name}\``);
110
+ lines.push("");
111
+ if (a.impls.length) {
112
+ for (const impl of a.impls) {
113
+ lines.push(`- Implementation: \`${impl.target}#${impl.method}\``);
114
+ }
115
+ } else {
116
+ lines.push("- Implementation: inline in the action handler (no single delegate).");
117
+ }
118
+ if (a.params.length) {
119
+ lines.push(`- Params referenced: ${a.params.map((p) => `\`${p}\``).join(", ")}`);
120
+ }
121
+ if (a.async) lines.push("- Async handler.");
122
+ lines.push("");
123
+ }
124
+ return lines.join("\n") + "\n";
125
+ }
126
+
127
+ /**
128
+ * Produce the structured manifest used by tests and machine consumers.
129
+ */
130
+ export function renderActionsJson({ actions, generatedAt, sourcePath }) {
131
+ return {
132
+ generated_at: generatedAt,
133
+ source: sourcePath,
134
+ canonical_id: "CONTRACT-API",
135
+ count: actions.length,
136
+ actions,
137
+ };
138
+ }
139
+
140
+ // --- internals ---
141
+
142
+ function collectImplCalls(text) {
143
+ const out = [];
144
+ const seen = new Set();
145
+ DB_CALL_RE.lastIndex = 0;
146
+ let m;
147
+ while ((m = DB_CALL_RE.exec(text)) !== null) {
148
+ const key = `db:${m[1]}`;
149
+ if (seen.has(key)) continue;
150
+ seen.add(key);
151
+ out.push({ target: "server/lib/sqlite-search.mjs", method: m[1] });
152
+ }
153
+ LSP_CALL_RE.lastIndex = 0;
154
+ while ((m = LSP_CALL_RE.exec(text)) !== null) {
155
+ const key = `lsp:${m[1]}`;
156
+ if (seen.has(key)) continue;
157
+ seen.add(key);
158
+ out.push({ target: "server/lib/lsp-client.mjs", method: m[1] });
159
+ }
160
+ return out;
161
+ }
162
+
163
+ function parseParamUses(paramsSig, handlerText) {
164
+ if (paramsSig.length === 0) return [];
165
+ // paramsSig is typically `p` — find `p.xxx` references in the body.
166
+ const name = paramsSig.replace(/[={}:[\]].*$/, "").trim().split(/\s|,/)[0];
167
+ if (!name || /[^a-zA-Z_]/.test(name)) return [];
168
+ const re = new RegExp(`\\b${name}\\.([a-zA-Z_][a-zA-Z0-9_]*)`, "g");
169
+ const out = [];
170
+ const seen = new Set();
171
+ let m;
172
+ while ((m = re.exec(handlerText)) !== null) {
173
+ if (seen.has(m[1])) continue;
174
+ seen.add(m[1]);
175
+ out.push(m[1]);
176
+ }
177
+ return out;
178
+ }