explainmyrepo 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.
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ // entrypoints.mjs — GENERIC, config-driven entrypoint/command extractor.
3
+ //
4
+ // Emits kb/stores/<slug>/<slug>-entrypoints.json: the build/test/run commands + the main
5
+ // crates/binaries/packages an AI needs to actually USE a repo. Parsed from Cargo.toml
6
+ // (workspace + per-crate [[bin]]/[[example]]), package.json (bin/scripts), and the README
7
+ // (fenced shell command lines: cargo/npx/npm/wasm-pack/pnpm/yarn/make/docker).
8
+ //
9
+ // Repo shape is DATA (kb.config.mjs target: repoDir, scopeExclude, componentRoots). NO repo
10
+ // name is baked in here — the same script runs for ruqu / photonlayer / ruvn / metaharness.
11
+ //
12
+ // This file ships in the drop-in for-ai/ so the AI gets EXACT entrypoint lookups (how do I
13
+ // build / test / run / install this) instead of guessing from semantic search.
14
+ //
15
+ // Usage: node kb/entrypoints.mjs --target ruqu
16
+ // node kb/entrypoints.mjs ruqu
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { getTarget, defaultTarget } from './kb.config.mjs';
22
+
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+
25
+ function parseArgs(argv) {
26
+ const a = { target: defaultTarget };
27
+ for (let i = 0; i < argv.length; i++) {
28
+ const v = argv[i];
29
+ if (v === '--target') a.target = argv[++i];
30
+ else if (v.startsWith('--target=')) a.target = v.slice(9);
31
+ else if (!v.startsWith('--')) a.target = v;
32
+ }
33
+ return a;
34
+ }
35
+
36
+ const tryRead = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } };
37
+
38
+ // Walk the target's own tree, honoring scopeExclude (shared convention with build-kb.mjs).
39
+ function* walk(dir, skip) {
40
+ let dirents;
41
+ try { dirents = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); }
42
+ catch { return; }
43
+ for (const e of dirents) {
44
+ const p = path.join(dir, e.name);
45
+ if (e.isDirectory()) { if (!skip.has(e.name)) yield* walk(p, skip); }
46
+ else if (e.isFile()) yield p;
47
+ }
48
+ }
49
+
50
+ // Extract fenced shell command lines that look like real entrypoint commands.
51
+ const CMD_RE = /^\s*(?:\$\s*)?((?:cargo|npx|npm|pnpm|yarn|wasm-pack|make|docker|node|deno|bun|python3?|uv|go)\b[^\n#]*)/;
52
+ function commandsFromMarkdown(text) {
53
+ const cmds = [];
54
+ // Only look inside fenced code blocks (```...```), where commands live.
55
+ const fences = text.match(/```[\s\S]*?```/g) || [];
56
+ for (const block of fences) {
57
+ for (const rawLine of block.split('\n')) {
58
+ const m = rawLine.match(CMD_RE);
59
+ if (!m) continue;
60
+ const cmd = m[1].replace(/\s+#.*$/, '').trim(); // strip trailing comment
61
+ if (cmd.length < 4) continue;
62
+ // trailing inline comment as a description (after the command, before fences)
63
+ const cmtMatch = rawLine.match(/#\s*(.+?)\s*$/);
64
+ cmds.push({ cmd, note: cmtMatch ? cmtMatch[1].trim() : null });
65
+ }
66
+ }
67
+ return cmds;
68
+ }
69
+
70
+ // Classify a command into a category so an AI can ask "how do I test/build/run/install".
71
+ function classify(cmd) {
72
+ if (/\b(test|vitest|jest|cargo test|nextest)\b/.test(cmd)) return 'test';
73
+ if (/\b(build|tsc|wasm-pack build|cargo build|compile)\b/.test(cmd)) return 'build';
74
+ if (/\b(add|install|npm i|npm install|pnpm add|yarn add|cargo add)\b/.test(cmd)) return 'install';
75
+ if (/\b(bench|criterion)\b/.test(cmd)) return 'bench';
76
+ if (/\b(run|simulate|exec|start|dev|serve|doctor|init)\b/.test(cmd) || /^npx /.test(cmd)) return 'run';
77
+ return 'other';
78
+ }
79
+
80
+ // Parse a Cargo.toml for name, [[bin]] and [[example]] targets (cheap line scan; no TOML dep).
81
+ function parseCargo(text) {
82
+ const name = (text.match(/^\s*name\s*=\s*"([^"]+)"/m) || [])[1] || null;
83
+ const desc = (text.match(/^\s*description\s*=\s*"([^"]*)"/m) || [])[1] || null;
84
+ const bins = [];
85
+ const examples = [];
86
+ // [[bin]] / [[example]] blocks: capture the name = "..." within each block.
87
+ for (const m of text.matchAll(/\[\[bin\]\]([\s\S]*?)(?=\n\[|\n*$)/g)) {
88
+ const bn = (m[1].match(/name\s*=\s*"([^"]+)"/) || [])[1];
89
+ if (bn) bins.push(bn);
90
+ }
91
+ for (const m of text.matchAll(/\[\[example\]\]([\s\S]*?)(?=\n\[|\n*$)/g)) {
92
+ const en = (m[1].match(/name\s*=\s*"([^"]+)"/) || [])[1];
93
+ if (en) examples.push(en);
94
+ }
95
+ return { name, desc, bins, examples };
96
+ }
97
+
98
+ function main() {
99
+ const { target: slug } = parseArgs(process.argv.slice(2));
100
+ const target = getTarget(slug);
101
+ const repoDir = path.resolve(__dirname, target.repoDir);
102
+ if (!fs.existsSync(repoDir)) { console.error(`[entrypoints] repoDir not found: ${repoDir}`); process.exit(1); }
103
+ const skip = new Set(target.scopeExclude || []);
104
+ const rel = (p) => path.relative(repoDir, p);
105
+
106
+ const out = {
107
+ target: slug,
108
+ metaName: target.metaName,
109
+ generated: new Date().toISOString(),
110
+ repo: target.bundle?.blurb ? undefined : undefined,
111
+ workspace: { kind: null, members: [] }, // rust workspace | npm | mixed
112
+ components: [], // { name, path, kind:'crate'|'package', description, bins, examples, scripts, deps }
113
+ binaries: [], // { name, component, path }
114
+ commands: [], // { cmd, category, source, note }
115
+ install: [], // dedup'd install commands (convenience subset)
116
+ quickstart: [], // README "run"-category commands (convenience subset)
117
+ };
118
+
119
+ // ---- Rust workspace + per-crate Cargo.toml ----
120
+ const rootCargo = tryRead(path.join(repoDir, 'Cargo.toml'));
121
+ if (rootCargo) {
122
+ out.workspace.kind = 'rust';
123
+ const members = [...rootCargo.matchAll(/members\s*=\s*\[([\s\S]*?)\]/g)]
124
+ .flatMap((m) => [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]));
125
+ out.workspace.members = members;
126
+ }
127
+
128
+ // Component roots (e.g. ['crates']) — each immediate child with a manifest is a component.
129
+ const roots = (target.componentRoots && target.componentRoots.length) ? target.componentRoots : ['crates', 'packages'];
130
+ // Always also consider the CLI dir + npm/packages if present (real-world mixed monorepos), AND
131
+ // the repo root '.' so a single-package-at-root repo (ruvn: one top-level package.json with the
132
+ // `bin`, scripts, and deps) is captured as a component too.
133
+ const extraDirs = ['cli', 'npm/packages', 'packages', 'apps', '.'];
134
+ const compDirs = new Set();
135
+ for (const r of [...roots, ...extraDirs]) {
136
+ const abs = path.join(repoDir, r);
137
+ if (!fs.existsSync(abs)) continue;
138
+ // A componentRoot holds child dirs; a leaf like 'cli' may itself be a component.
139
+ const cargoHere = tryRead(path.join(abs, 'Cargo.toml'));
140
+ const pkgHere = tryRead(path.join(abs, 'package.json'));
141
+ if (cargoHere || pkgHere) { compDirs.add(abs); continue; }
142
+ for (const d of fs.readdirSync(abs, { withFileTypes: true })) {
143
+ if (d.isDirectory() && !skip.has(d.name)) compDirs.add(path.join(abs, d.name));
144
+ }
145
+ }
146
+
147
+ for (const cdir of [...compDirs].sort()) {
148
+ const cargo = tryRead(path.join(cdir, 'Cargo.toml'));
149
+ const pkgRaw = tryRead(path.join(cdir, 'package.json'));
150
+ if (cargo) {
151
+ const { name, desc, bins, examples } = parseCargo(cargo);
152
+ out.components.push({ name: name || path.basename(cdir), path: rel(cdir), kind: 'crate', description: desc, bins, examples });
153
+ for (const b of bins) out.binaries.push({ name: b, component: name, path: rel(cdir), kind: 'rust-bin' });
154
+ } else if (pkgRaw) {
155
+ try {
156
+ const j = JSON.parse(pkgRaw);
157
+ const scripts = j.scripts || {};
158
+ const binNames = j.bin ? (typeof j.bin === 'string' ? [j.name] : Object.keys(j.bin)) : [];
159
+ out.components.push({
160
+ name: j.name || path.basename(cdir), path: rel(cdir), kind: 'package',
161
+ description: j.description || null, scripts, bins: binNames,
162
+ deps: Object.keys(j.dependencies || {}),
163
+ });
164
+ for (const b of binNames) out.binaries.push({ name: b, component: j.name, path: rel(cdir), kind: 'npm-bin' });
165
+ for (const [k, v] of Object.entries(scripts)) out.commands.push({ cmd: `npm run ${k}`, raw: v, category: classify(`${k} ${v}`), source: rel(path.join(cdir, 'package.json')), note: null });
166
+ } catch { /* unparseable */ }
167
+ }
168
+ }
169
+ if (out.components.some((c) => c.kind === 'package') && out.components.some((c) => c.kind === 'crate')) out.workspace.kind = 'mixed';
170
+ else if (!out.workspace.kind && out.components.some((c) => c.kind === 'package')) out.workspace.kind = 'npm';
171
+
172
+ // ---- README + key docs: real command lines (the AI's quickstart) ----
173
+ const docCandidates = new Set(['README.md', 'cli/README.md', 'docs/README.md', 'docs/USAGE.md', 'docs/QUICKSTART.md', 'CONTRIBUTING.md']);
174
+ // Plus any top-level *.md (cheap, deduped).
175
+ for (const p of walk(repoDir, skip)) {
176
+ if (path.extname(p) === '.md' && rel(p).split('/').length <= 2) docCandidates.add(rel(p));
177
+ }
178
+ const seenCmd = new Set();
179
+ for (const d of docCandidates) {
180
+ const text = tryRead(path.join(repoDir, d));
181
+ if (!text) continue;
182
+ for (const { cmd, note } of commandsFromMarkdown(text)) {
183
+ const key = cmd.toLowerCase();
184
+ if (seenCmd.has(key)) continue;
185
+ seenCmd.add(key);
186
+ out.commands.push({ cmd, category: classify(cmd), source: d, note });
187
+ }
188
+ }
189
+
190
+ // Convenience subsets (deduped, first-seen order).
191
+ const seenInstall = new Set(), seenRun = new Set();
192
+ for (const c of out.commands) {
193
+ if (c.category === 'install' && !seenInstall.has(c.cmd)) { seenInstall.add(c.cmd); out.install.push(c.cmd); }
194
+ if (c.category === 'run' && !seenRun.has(c.cmd) && out.quickstart.length < 12) { seenRun.add(c.cmd); out.quickstart.push(c.cmd); }
195
+ }
196
+
197
+ // ---- write ----
198
+ const storeDir = path.join(__dirname, 'stores', slug);
199
+ fs.mkdirSync(storeDir, { recursive: true });
200
+ const outFile = path.join(storeDir, `${slug}-entrypoints.json`);
201
+ fs.writeFileSync(outFile, JSON.stringify(out, null, 2) + '\n');
202
+ console.log(`[entrypoints] ${slug}: ${out.components.length} components, ${out.binaries.length} binaries, ${out.commands.length} commands`);
203
+ console.log(`[entrypoints] install: ${out.install.length} | quickstart(run): ${out.quickstart.length}`);
204
+ console.log(`[entrypoints] wrote ${path.relative(__dirname, outFile)}`);
205
+ }
206
+
207
+ main();
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env node
2
+ // extract-symbols.mjs — GENERIC, config-driven symbol/API index extractor.
3
+ //
4
+ // Emits kb/stores/<slug>/<slug>-symbols.json: every public symbol an AI needs to USE the
5
+ // repo — name, kind, signature, module/crate path, doc summary, source location.
6
+ //
7
+ // Strategy (best-available, graceful fallback):
8
+ // Rust: prefer `cargo +nightly rustdoc -p <crate> -- -Z unstable-options --output-format json`
9
+ // (rich: real signatures + docs). If nightly/rustdoc-json is unavailable OR the build
10
+ // fails, fall back to a ripgrep-style source scan of `pub fn|struct|enum|trait|mod` +
11
+ // the leading doc-comment.
12
+ // TS/JS: ripgrep-style scan of `export ...` (function/class/interface/type/const) + JSDoc.
13
+ //
14
+ // Repo shape is DATA (kb.config.mjs target: repoDir, scopeExclude, componentRoots, codeExt). NO
15
+ // repo name is baked in here. Ships in the drop-in for-ai/ so the AI gets EXACT API lookups.
16
+ //
17
+ // Usage: node kb/extract-symbols.mjs --target ruqu [--no-rustdoc]
18
+ // node kb/extract-symbols.mjs ruqu
19
+
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { execFileSync } from 'node:child_process';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { getTarget, defaultTarget } from './kb.config.mjs';
25
+
26
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
+
28
+ function parseArgs(argv) {
29
+ const a = { target: defaultTarget, rustdoc: true };
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const v = argv[i];
32
+ if (v === '--target') a.target = argv[++i];
33
+ else if (v.startsWith('--target=')) a.target = v.slice(9);
34
+ else if (v === '--no-rustdoc') a.rustdoc = false;
35
+ else if (!v.startsWith('--')) a.target = v;
36
+ }
37
+ return a;
38
+ }
39
+
40
+ const tryRead = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } };
41
+
42
+ function* walk(dir, skip) {
43
+ let dirents;
44
+ try { dirents = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); }
45
+ catch { return; }
46
+ for (const e of dirents) {
47
+ const p = path.join(dir, e.name);
48
+ if (e.isDirectory()) { if (!skip.has(e.name)) yield* walk(p, skip); }
49
+ else if (e.isFile()) yield p;
50
+ }
51
+ }
52
+
53
+ // First sentence/line of a doc string, normalized to one line.
54
+ function docSummary(doc) {
55
+ if (!doc) return null;
56
+ const firstPara = doc.split(/\n\s*\n/)[0].replace(/\s+/g, ' ').trim();
57
+ return firstPara.slice(0, 280) || null;
58
+ }
59
+
60
+ // ---------------- rustdoc-json type rendering ----------------
61
+ // Render a rustdoc `type` node into a readable, lossy-but-useful Rust-ish string.
62
+ function renderType(t) {
63
+ if (t == null) return '_';
64
+ if (typeof t === 'string') return t;
65
+ const k = Object.keys(t)[0];
66
+ const v = t[k];
67
+ switch (k) {
68
+ case 'primitive': return v;
69
+ case 'generic': return v;
70
+ case 'resolved_path': {
71
+ const name = (v.path || '').split('::').pop() || v.path;
72
+ const args = v.args && v.args.angle_bracketed && v.args.angle_bracketed.args || [];
73
+ const inner = args.map((a) => (a.type ? renderType(a.type) : (a.lifetime || ''))).filter(Boolean);
74
+ return inner.length ? `${name}<${inner.join(', ')}>` : name;
75
+ }
76
+ case 'borrowed_ref': return `&${v.is_mutable ? 'mut ' : ''}${renderType(v.type)}`;
77
+ case 'tuple': return `(${(v || []).map(renderType).join(', ')})`;
78
+ case 'slice': return `[${renderType(v)}]`;
79
+ case 'array': return `[${renderType(v.type)}; ${v.len}]`;
80
+ case 'raw_pointer': return `*${v.is_mutable ? 'mut ' : 'const '}${renderType(v.type)}`;
81
+ case 'qualified_path': return v.name || 'Self::_';
82
+ case 'impl_trait': return 'impl _';
83
+ case 'dyn_trait': return 'dyn _';
84
+ default: return '_';
85
+ }
86
+ }
87
+
88
+ function renderFnSig(name, fn) {
89
+ const inputs = (fn.sig?.inputs || []).map(([pn, pt]) => `${pn}: ${renderType(pt)}`).join(', ');
90
+ const out = fn.sig?.output ? ` -> ${renderType(fn.sig.output)}` : '';
91
+ const asyncK = fn.header?.is_async ? 'async ' : '';
92
+ const unsafeK = fn.header?.is_unsafe ? 'unsafe ' : '';
93
+ return `${asyncK}${unsafeK}fn ${name}(${inputs})${out}`;
94
+ }
95
+
96
+ // Extract symbols from a single rustdoc-json file (local crate items only). Expands struct FIELDS
97
+ // and impl METHODS so "what are the fields of X" / "what methods does Y have" resolve exactly.
98
+ function symbolsFromRustdoc(jsonPath, crateName) {
99
+ const j = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
100
+ const idx = j.index || {};
101
+ const paths = j.paths || {};
102
+ const out = [];
103
+ const get = (id) => idx[id];
104
+
105
+ // Render a struct's fields into a compact "{ name: Type, … }" string + emit per-field symbols.
106
+ const renderStructFields = (st, parentName) => {
107
+ const k = st.kind || {};
108
+ const fieldIds = k.plain ? (k.plain.fields || []) : (Array.isArray(k.fields) ? k.fields : []);
109
+ const fields = [];
110
+ for (const fid of fieldIds) {
111
+ const f = get(fid);
112
+ if (!f || !f.name) continue;
113
+ const ty = f.inner && f.inner.struct_field ? renderType(f.inner.struct_field) : '_';
114
+ fields.push(`${f.name}: ${ty}`);
115
+ }
116
+ return fields;
117
+ };
118
+
119
+ // Collect impl methods for a type id (its inherent + trait impls), as fn symbols on the type.
120
+ const methodsForType = (st, parentName, parentSpanFile) => {
121
+ const methods = [];
122
+ for (const implId of (st.impls || [])) {
123
+ const im = get(implId);
124
+ if (!im || !im.inner || !im.inner.impl) continue;
125
+ const impl = im.inner.impl;
126
+ // skip auto/blanket trait impls without our crate's items
127
+ for (const mid of (impl.items || [])) {
128
+ const m = get(mid);
129
+ if (!m || !m.name || !m.inner || !m.inner.function) continue;
130
+ if (m.crate_id !== 0) continue;
131
+ const sig = renderFnSig(m.name, m.inner.function);
132
+ methods.push({
133
+ name: m.name, kind: 'method', signature: `${parentName}::${sig}`,
134
+ module: `${crateName}::${parentName}`, crate: crateName, doc: docSummary(m.docs),
135
+ file: m.span ? m.span.filename : parentSpanFile, line: m.span && m.span.begin ? m.span.begin[0] : null,
136
+ source: 'rustdoc-json',
137
+ });
138
+ }
139
+ }
140
+ return methods;
141
+ };
142
+
143
+ for (const [id, it] of Object.entries(idx)) {
144
+ if (it.crate_id !== 0) continue; // local crate only
145
+ if (!it.name || !it.span) continue; // authored item with a source span
146
+ const inner = it.inner || {};
147
+ const kind = Object.keys(inner)[0];
148
+ if (!['function', 'struct', 'enum', 'trait', 'module', 'type_alias', 'constant'].includes(kind)) continue;
149
+ if (it.visibility && it.visibility !== 'public' && it.visibility !== 'default') continue;
150
+ const modPath = (paths[id] && paths[id].path) ? paths[id].path.join('::') : `${crateName}::${it.name}`;
151
+ let signature;
152
+ if (kind === 'function') signature = renderFnSig(it.name, inner.function);
153
+ else if (kind === 'struct') {
154
+ const fields = renderStructFields(inner.struct, it.name);
155
+ signature = fields.length ? `struct ${it.name} { ${fields.join(', ')} }` : `struct ${it.name}`;
156
+ } else if (kind === 'enum') {
157
+ const variants = (inner.enum.variants || []).map((vid) => { const v = get(vid); return v && v.name; }).filter(Boolean);
158
+ signature = variants.length ? `enum ${it.name} { ${variants.join(', ')} }` : `enum ${it.name}`;
159
+ } else if (kind === 'trait') signature = `trait ${it.name}`;
160
+ else if (kind === 'module') signature = `mod ${it.name}`;
161
+ else if (kind === 'type_alias') signature = `type ${it.name}`;
162
+ else signature = `const ${it.name}`;
163
+ out.push({
164
+ name: it.name,
165
+ kind: kind === 'function' ? 'fn' : kind,
166
+ signature,
167
+ module: modPath,
168
+ crate: crateName,
169
+ doc: docSummary(it.docs),
170
+ file: it.span.filename,
171
+ line: it.span.begin ? it.span.begin[0] : null,
172
+ source: 'rustdoc-json',
173
+ });
174
+ // emit impl methods for structs/enums so "X's methods" resolves.
175
+ if (kind === 'struct') out.push(...methodsForType(inner.struct, it.name, it.span.filename));
176
+ else if (kind === 'enum') out.push(...methodsForType(inner.enum, it.name, it.span.filename));
177
+ }
178
+ return out;
179
+ }
180
+
181
+ // ---------------- ripgrep-style source fallback ----------------
182
+ // Leading doc-comment ABOVE a line index (Rust /// //!, or JSDoc /** */).
183
+ function leadingDoc(lines, i) {
184
+ const acc = [];
185
+ let j = i - 1;
186
+ // JSDoc block
187
+ if (/^\s*\*\//.test(lines[j] || '')) {
188
+ const block = [];
189
+ while (j >= 0 && !/\/\*\*/.test(lines[j])) { block.unshift(lines[j]); j--; }
190
+ return block.map((l) => l.replace(/^\s*\*\s?/, '').replace(/\*\/\s*$/, '')).join('\n').trim();
191
+ }
192
+ while (j >= 0 && /^\s*\/\/[!/]/.test(lines[j])) { acc.unshift(lines[j].replace(/^\s*\/\/[!/]\s?/, '')); j--; }
193
+ return acc.join('\n').trim();
194
+ }
195
+
196
+ const RUST_SYM = /^\s*pub(?:\s*\([^)]*\))?\s+(?:async\s+)?(?:unsafe\s+)?(?:const\s+)?(fn|struct|enum|trait|mod|type|union)\s+([A-Za-z_][A-Za-z0-9_]*)/;
197
+ const TS_SYM = /^\s*export\s+(?:default\s+)?(?:async\s+)?(function|class|interface|type|const|enum)\s+([A-Za-z_$][A-Za-z0-9_$]*)/;
198
+
199
+ function symbolsFromSourceScan(repoDir, skip, rel, codeExt) {
200
+ const out = [];
201
+ const exts = new Set((codeExt || ['.rs', '.ts', '.tsx', '.js', '.mjs']).map((e) => e.toLowerCase()));
202
+ for (const p of walk(repoDir, skip)) {
203
+ const ext = path.extname(p).toLowerCase();
204
+ if (!exts.has(ext)) continue;
205
+ const text = tryRead(p);
206
+ if (!text) continue;
207
+ const lines = text.split('\n');
208
+ const isRust = ext === '.rs';
209
+ const re = isRust ? RUST_SYM : TS_SYM;
210
+ // crate = first path segment under componentRoots (best-effort)
211
+ const relp = rel(p);
212
+ const crate = relp.split('/').slice(0, 2).join('/');
213
+ for (let i = 0; i < lines.length; i++) {
214
+ const m = lines[i].match(re);
215
+ if (!m) continue;
216
+ let [, kw, name] = m;
217
+ const kindMap = { fn: 'fn', function: 'fn', struct: 'struct', class: 'class', enum: 'enum', trait: 'trait', interface: 'interface', mod: 'module', type: 'type_alias', union: 'struct', const: 'const' };
218
+ const kind = kindMap[kw] || kw;
219
+ const signature = lines[i].trim().replace(/\s*\{?\s*$/, '').slice(0, 240);
220
+ out.push({
221
+ name, kind, signature,
222
+ module: crate.replace(/\//g, '::'),
223
+ crate, doc: docSummary(leadingDoc(lines, i)),
224
+ file: relp, line: i + 1, source: 'source-scan',
225
+ });
226
+ }
227
+ }
228
+ return out;
229
+ }
230
+
231
+ function main() {
232
+ const args = parseArgs(process.argv.slice(2));
233
+ const target = getTarget(args.target);
234
+ const slug = args.target;
235
+ const repoDir = path.resolve(__dirname, target.repoDir);
236
+ if (!fs.existsSync(repoDir)) { console.error(`[symbols] repoDir not found: ${repoDir}`); process.exit(1); }
237
+ const skip = new Set(target.scopeExclude || []);
238
+ const rel = (p) => path.relative(repoDir, p);
239
+
240
+ let symbols = [];
241
+ let method = 'source-scan';
242
+
243
+ // ---- Rust: try rustdoc-json per workspace crate ----
244
+ const rootCargo = tryRead(path.join(repoDir, 'Cargo.toml'));
245
+ const isRust = !!rootCargo;
246
+ if (isRust && args.rustdoc) {
247
+ const members = [...(rootCargo.matchAll(/members\s*=\s*\[([\s\S]*?)\]/g))]
248
+ .flatMap((m) => [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]));
249
+ const crateNames = [];
250
+ for (const mem of members) {
251
+ const cargo = tryRead(path.join(repoDir, mem, 'Cargo.toml'));
252
+ const name = cargo && (cargo.match(/^\s*name\s*=\s*"([^"]+)"/m) || [])[1];
253
+ if (name) crateNames.push(name);
254
+ }
255
+ var rustdocCrates = new Set(); // crate dirs (member paths) rustdoc actually covered
256
+ let anyDoc = false;
257
+ for (let mi = 0; mi < members.length; mi++) {
258
+ const cn = crateNames[mi];
259
+ if (!cn) continue;
260
+ try {
261
+ execFileSync('cargo', ['+nightly', 'rustdoc', '-p', cn, '--', '-Z', 'unstable-options', '--output-format', 'json'],
262
+ { cwd: repoDir, stdio: ['ignore', 'ignore', 'ignore'], timeout: 240000 });
263
+ } catch (e) {
264
+ console.warn(`[symbols] rustdoc failed for ${cn} (${e.message?.slice(0, 60)}); will source-scan that crate instead`);
265
+ continue;
266
+ }
267
+ const docJson = path.join(repoDir, 'target', 'doc', `${cn.replace(/-/g, '_')}.json`);
268
+ if (fs.existsSync(docJson)) {
269
+ const syms = symbolsFromRustdoc(docJson, cn);
270
+ symbols.push(...syms);
271
+ anyDoc = true;
272
+ rustdocCrates.add(members[mi]); // e.g. "crates/ruqu-core"
273
+ console.log(`[symbols] rustdoc ${cn}: ${syms.length} symbols`);
274
+ }
275
+ }
276
+ if (anyDoc) method = symbols.length ? 'rustdoc-json' : 'source-scan';
277
+ var rustdocMemberSet = rustdocCrates;
278
+ }
279
+
280
+ // ---- Fallback / complement: source scan for any file type not covered by rustdoc-json ----
281
+ // Always run the source scan for NON-.rs files (TS/JS), and for .rs files if rustdoc produced
282
+ // nothing. De-dup by (file,name,kind) so rustdoc wins where it ran.
283
+ const seen = new Set(symbols.map((s) => `${s.file}|${s.name}|${s.kind}`));
284
+ const scan = symbolsFromSourceScan(repoDir, skip, rel, target.codeExt);
285
+ const haveRustdoc = method === 'rustdoc-json';
286
+ const coveredByRustdoc = (typeof rustdocMemberSet !== 'undefined') ? rustdocMemberSet : new Set();
287
+ // A .rs file is authoritatively covered ONLY if its crate member dir was rustdoc'd. Crates whose
288
+ // rustdoc failed (e.g. a build error) still get their public symbols from the source scan.
289
+ const fileCoveredByRustdoc = (relp) => {
290
+ for (const mem of coveredByRustdoc) { if (relp === mem || relp.startsWith(mem + '/')) return true; }
291
+ return false;
292
+ };
293
+ for (const s of scan) {
294
+ if (haveRustdoc && s.file.endsWith('.rs') && fileCoveredByRustdoc(s.file)) continue;
295
+ const key = `${s.file}|${s.name}|${s.kind}`;
296
+ if (seen.has(key)) continue;
297
+ seen.add(key);
298
+ symbols.push(s);
299
+ }
300
+ if (!haveRustdoc) method = 'source-scan';
301
+
302
+ // Sort: by crate, then file, then line.
303
+ symbols.sort((a, b) => (a.crate || '').localeCompare(b.crate || '') || (a.file || '').localeCompare(b.file || '') || (a.line || 0) - (b.line || 0));
304
+
305
+ // Stats by kind + by crate.
306
+ const byKind = {}, byCrate = {};
307
+ for (const s of symbols) { byKind[s.kind] = (byKind[s.kind] || 0) + 1; byCrate[s.crate] = (byCrate[s.crate] || 0) + 1; }
308
+
309
+ const out = {
310
+ target: slug, metaName: target.metaName, generated: new Date().toISOString(),
311
+ method, count: symbols.length, byKind, byCrate, symbols,
312
+ };
313
+ const storeDir = path.join(__dirname, 'stores', slug);
314
+ fs.mkdirSync(storeDir, { recursive: true });
315
+ const outFile = path.join(storeDir, `${slug}-symbols.json`);
316
+ fs.writeFileSync(outFile, JSON.stringify(out, null, 2) + '\n');
317
+ console.log(`[symbols] ${slug}: ${symbols.length} symbols via ${method}`);
318
+ console.log(`[symbols] byKind ${JSON.stringify(byKind)}`);
319
+ console.log(`[symbols] wrote ${path.relative(__dirname, outFile)}`);
320
+ }
321
+
322
+ main();