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,341 @@
1
+ // corpus-rules.mjs — config-driven corpus rule-type implementations.
2
+ //
3
+ // Each rule type is a pure function that takes a `ctx` (the shared build context: repo root,
4
+ // walk helper, exclusion set, chunker, addDoc collector, etc.) plus the rule's own config object
5
+ // (from `kb.config.mjs` target.include[]) and APPENDS entries to the corpus via ctx.addDoc(...).
6
+ //
7
+ // The mechanics are generalized from the proven Cognitum prototype (build-ruview-kb.mjs):
8
+ // repo-shape is now DATA (roots / exts / files / headLines) instead of hard-coded ruview/ruvector
9
+ // paths. NO repo name is baked in here. build-kb.mjs wires these rules together per config.
10
+ //
11
+ // Rule types (build-plan-metaharness.md §2a [D5]):
12
+ // mdSweepFullText — every *.md/.mdx/.txt under the given roots, verbatim (untruncated).
13
+ // componentManifests— each component's package.json / Cargo.toml summarized (name/desc/scripts/deps).
14
+ // componentLead — each component's README / lead doc (full text) + lead source doc-block.
15
+ // sourceBodies — full file bodies of implementing source under the roots (chunked).
16
+ // docCommentSweep — leading doc comments only (//!, /** */, """...""", #-prefixed) from source.
17
+ // literalFiles — an explicit list of files, full text (guarantees key docs are present).
18
+ // htmlText — visible text of *.html under the roots (scripts/styles/tags stripped).
19
+ // templates — scaffolding templates (.tmpl/.hbs/...): first-N-lines + path, kind:'template'.
20
+ //
21
+ // A `kind` is attached to every entry so the intent layer (ask-kb) can route by content kind.
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+
26
+ // ---------- small shared text helpers ----------
27
+ const read = (p) => fs.readFileSync(p, 'utf8');
28
+ const tryRead = (p) => { try { return read(p); } catch { return null; } };
29
+ const firstLines = (s, n) => s.split('\n').slice(0, n).join('\n');
30
+
31
+ export function titleOf(text, fallback) {
32
+ const m = text.match(/^#\s+(.+)$/m);
33
+ return (m ? m[1] : fallback).slice(0, 200).trim();
34
+ }
35
+
36
+ // Visible text of an HTML document (scripts/styles/comments/tags stripped, entities decoded).
37
+ export function htmlToText(html) {
38
+ return html
39
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
40
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
41
+ .replace(/<!--[\s\S]*?-->/g, ' ')
42
+ .replace(/<[^>]+>/g, ' ')
43
+ .replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<')
44
+ .replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
45
+ .replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
46
+ }
47
+
48
+ // Leading doc-comment block from the first N lines of a source file, language-aware.
49
+ // Rust //! / /// C-style /** ... */ or // leading run
50
+ // Python/JS module docstring """ ... """ or # leading run
51
+ // Returns '' when there is no leading doc block.
52
+ export function docBlock(text, n = 40) {
53
+ const head = text.split('\n').slice(0, n);
54
+ // Rust //! and /// runs
55
+ const rust = head.filter((l) => /^\s*\/\/[!/]/.test(l))
56
+ .map((l) => l.replace(/^\s*\/\/[!/]\s?/, '')).join('\n').trim();
57
+ if (rust) return rust;
58
+ // JSDoc / C-style /** ... */ at the very top
59
+ const joined = head.join('\n');
60
+ const block = joined.match(/^\s*\/\*\*([\s\S]*?)\*\//);
61
+ if (block) {
62
+ return block[1].split('\n').map((l) => l.replace(/^\s*\*\s?/, '')).join('\n').trim();
63
+ }
64
+ // Python / triple-quoted module docstring
65
+ const py = joined.match(/^\s*(?:[rRbBuU]{0,2})("""|''')([\s\S]*?)\1/);
66
+ if (py) return py[2].trim();
67
+ // leading run of #-prefixed comment lines (shell / python / toml)
68
+ const hash = head.filter((l) => /^\s*#/.test(l) && !/^\s*#!/.test(l))
69
+ .map((l) => l.replace(/^\s*#\s?/, '')).join('\n').trim();
70
+ return hash;
71
+ }
72
+
73
+ // Resolve a list of root-relative roots to absolute dirs that exist under the repo.
74
+ function resolveRoots(ctx, roots) {
75
+ const out = [];
76
+ for (const r of roots || []) {
77
+ const abs = r === '.' ? ctx.repoDir : path.join(ctx.repoDir, r);
78
+ if (fs.existsSync(abs)) out.push(abs);
79
+ }
80
+ return out;
81
+ }
82
+
83
+ const hasExt = (p, exts) => !exts || !exts.length || exts.includes(path.extname(p).toLowerCase());
84
+
85
+ // ============================ RULE TYPES ============================
86
+
87
+ // mdSweepFullText — every prose file under the roots, full text (untruncated). De-duped by
88
+ // absolute path so a file already ingested (e.g. by literalFiles) is not added twice.
89
+ export function mdSweepFullText(ctx, rule) {
90
+ const exts = rule.ext || ['.md', '.mdx', '.txt'];
91
+ let n = 0;
92
+ for (const root of resolveRoots(ctx, rule.roots)) {
93
+ for (const p of ctx.walk(root)) {
94
+ if (!hasExt(p, exts)) continue;
95
+ if (ctx.alreadyIngested(p)) continue;
96
+ const rel = ctx.rel(p);
97
+ const text = read(p);
98
+ let kind = 'doc';
99
+ if (/(^|\/)adrs?\//i.test(rel) || /(^|\/)adr[-_]/i.test(rel)) kind = 'adr';
100
+ else if (/(^|\/)tutorials?\//i.test(rel)) kind = 'tutorial';
101
+ ctx.addDoc(rel, kind, titleOf(text, path.basename(p)), text, p);
102
+ n++;
103
+ }
104
+ }
105
+ return n;
106
+ }
107
+
108
+ // componentManifests — each component's package.json (or Cargo.toml) summarized so "what packages
109
+ // make up X" and dependency questions resolve. Walks each componentRoot's immediate children.
110
+ export function componentManifests(ctx, rule) {
111
+ let n = 0;
112
+ for (const root of resolveRoots(ctx, rule.roots)) {
113
+ for (const dirent of fs.readdirSync(root, { withFileTypes: true })) {
114
+ if (!dirent.isDirectory()) continue;
115
+ const cdir = path.join(root, dirent.name);
116
+ // npm manifest
117
+ const pkgPath = path.join(cdir, 'package.json');
118
+ const pkg = tryRead(pkgPath);
119
+ if (pkg) {
120
+ try {
121
+ const j = JSON.parse(pkg);
122
+ const scripts = j.scripts ? Object.entries(j.scripts).map(([k, v]) => ` ${k}: ${v}`).join('\n') : '';
123
+ const bin = j.bin ? (typeof j.bin === 'string' ? j.bin : Object.keys(j.bin).join(', ')) : '';
124
+ const deps = Object.keys(j.dependencies || {});
125
+ const dev = Object.keys(j.devDependencies || {});
126
+ const text =
127
+ `npm package: ${j.name || dirent.name}\nVersion: ${j.version || ''}\nPath: ${ctx.rel(pkgPath)}\n` +
128
+ `Description: ${j.description || ''}\n` +
129
+ (bin ? `Bin: ${bin}\n` : '') +
130
+ (j.type ? `Module type: ${j.type}\n` : '') +
131
+ (scripts ? `Scripts:\n${scripts}\n` : '') +
132
+ (deps.length ? `Dependencies: ${deps.join(', ')}\n` : '') +
133
+ (dev.length ? `DevDependencies: ${dev.join(', ')}\n` : '') +
134
+ (j.keywords?.length ? `Keywords: ${j.keywords.join(', ')}\n` : '');
135
+ ctx.addDoc(ctx.rel(pkgPath), 'npm', j.name || dirent.name, text, pkgPath);
136
+ n++;
137
+ } catch {
138
+ ctx.addDoc(ctx.rel(pkgPath), 'npm', dirent.name, `npm package manifest (unparseable) at ${ctx.rel(pkgPath)}`, pkgPath);
139
+ n++;
140
+ }
141
+ }
142
+ // Cargo manifest (mixed monorepos)
143
+ const cargoPath = path.join(cdir, 'Cargo.toml');
144
+ const cargo = tryRead(cargoPath);
145
+ if (cargo) {
146
+ const name = (cargo.match(/^name\s*=\s*"([^"]*)"/m) || [])[1] || dirent.name;
147
+ const desc = (cargo.match(/^description\s*=\s*"([^"]*)"/m) || [])[1] || '';
148
+ ctx.addDoc(ctx.rel(cargoPath), 'crate', name,
149
+ `Crate: ${name}\nDescription: ${desc}\nPath: ${ctx.rel(cargoPath)}\n\n${cargo}`, cargoPath);
150
+ n++;
151
+ }
152
+ }
153
+ }
154
+ return n;
155
+ }
156
+
157
+ // componentLead — each component's README (full text) + the leading doc block of its lead source
158
+ // file (index/lib/main). Gives every component an orientation entry under componentRoots.
159
+ export function componentLead(ctx, rule) {
160
+ const leadCandidates = rule.leads || [
161
+ 'README.md', 'readme.md', 'src/index.ts', 'src/index.js', 'src/lib.rs', 'src/main.rs',
162
+ 'index.ts', 'index.js', 'lib.rs', 'main.rs',
163
+ ];
164
+ let n = 0;
165
+ for (const root of resolveRoots(ctx, rule.roots)) {
166
+ for (const dirent of fs.readdirSync(root, { withFileTypes: true })) {
167
+ if (!dirent.isDirectory()) continue;
168
+ const cdir = path.join(root, dirent.name);
169
+ // README full text
170
+ for (const rd of ['README.md', 'readme.md', 'README.mdx']) {
171
+ const rp = path.join(cdir, rd);
172
+ if (fs.existsSync(rp) && !ctx.alreadyIngested(rp)) {
173
+ const text = read(rp);
174
+ ctx.addDoc(ctx.rel(rp), 'doc', titleOf(text, dirent.name), text, rp);
175
+ n++;
176
+ break;
177
+ }
178
+ }
179
+ // lead source doc-block (orientation, not full body — sourceBodies covers bodies)
180
+ const lead = leadCandidates.map((f) => path.join(cdir, f)).find((p) => fs.existsSync(p));
181
+ if (lead) {
182
+ const text = read(lead);
183
+ const doc = docBlock(text, 200);
184
+ if (doc) {
185
+ ctx.addDoc(ctx.rel(lead), 'crate-src', `${dirent.name} ${path.basename(lead)}`,
186
+ `Component ${dirent.name} — ${path.basename(lead)} leading doc:\n${doc}`, /*absPath*/ undefined);
187
+ n++;
188
+ }
189
+ }
190
+ }
191
+ }
192
+ return n;
193
+ }
194
+
195
+ // sourceBodies — full file bodies of implementing source under the roots, chunked like docs.
196
+ // SCOPE: files under any `/src/` dir, or crate/module roots (lib/main/mod/index). Excludes
197
+ // tests/benches and minified output. Tagged kind:'source'.
198
+ export function sourceBodies(ctx, rule) {
199
+ const exts = (rule.ext || ['.ts', '.tsx', '.js', '.mjs', '.cjs', '.rs', '.py', '.go']).map((e) => e.toLowerCase());
200
+ const ROOTS_FILE = /(^|\/)(lib\.rs|main\.rs|mod\.rs|index\.ts|index\.js|index\.mjs)$/i;
201
+ const MINIFIED = /\.(min|bundle)\.(js|css|mjs)$/i;
202
+ const TESTY = /(^|\/)(tests?|benches?|__tests__|__mocks__|spec)\//i;
203
+ const inScope = (rel) => {
204
+ if (MINIFIED.test(rel)) return false;
205
+ if (TESTY.test(rel)) return false;
206
+ if (/(^|\/)src\//.test(rel)) return true;
207
+ return ROOTS_FILE.test(rel);
208
+ };
209
+ let n = 0;
210
+ for (const root of resolveRoots(ctx, rule.roots)) {
211
+ for (const p of ctx.walk(root)) {
212
+ if (!hasExt(p, exts)) continue;
213
+ const rel = ctx.rel(p);
214
+ if (!inScope(rel)) continue;
215
+ if (ctx.isFullBody(p)) continue;
216
+ const body = read(p);
217
+ if (!body.trim()) continue;
218
+ ctx.markFullBody(p);
219
+ ctx.addDoc(rel, 'source', path.basename(p), `Source ${rel} (full):\n${body}`, /*absPath*/ undefined);
220
+ n++;
221
+ }
222
+ }
223
+ return n;
224
+ }
225
+
226
+ // testsAndExamples — full bodies of TEST / EXAMPLE / BENCH source files under the roots. These are
227
+ // the single best USAGE documentation a repo has (real call sites + expected results), so they are
228
+ // ingested deliberately (sourceBodies excludes them on purpose; this rule is the complement). The
229
+ // source_type tagger in build-kb.mjs marks each as 'test' or 'example' from its path. kind stays
230
+ // 'source' so the existing source-routing/reranking treats them like code.
231
+ export function testsAndExamples(ctx, rule) {
232
+ const exts = (rule.ext || ['.rs', '.ts', '.tsx', '.js', '.mjs']).map((e) => e.toLowerCase());
233
+ const IS_TEST_OR_EX = /(^|\/)(tests?|benches?|examples?|__tests__|spec)\//i;
234
+ const NAMED_TEST = /[._-](test|spec)\.[a-z]+$|\.test$/i;
235
+ let n = 0;
236
+ for (const root of resolveRoots(ctx, rule.roots)) {
237
+ for (const p of ctx.walk(root)) {
238
+ if (!hasExt(p, exts)) continue;
239
+ const rel = ctx.rel(p);
240
+ if (!IS_TEST_OR_EX.test(rel) && !NAMED_TEST.test(rel)) continue;
241
+ if (ctx.isFullBody(p)) continue; // already ingested (shouldn't happen — sourceBodies skips these)
242
+ const body = read(p);
243
+ if (!body.trim()) continue;
244
+ ctx.markFullBody(p);
245
+ const isEx = /(^|\/)(examples?|demos?)\//i.test(rel);
246
+ const label = isEx ? 'Example' : 'Test';
247
+ ctx.addDoc(rel, 'source', path.basename(p), `${label} ${rel} (full):\n${body}`, /*absPath*/ undefined);
248
+ n++;
249
+ }
250
+ }
251
+ return n;
252
+ }
253
+
254
+ // docCommentSweep — leading doc comment ONLY from every source file under the roots that has one
255
+ // and is NOT already indexed as a full body. Cheap orientation breadcrumbs across the whole tree.
256
+ export function docCommentSweep(ctx, rule) {
257
+ const exts = (rule.ext || ['.ts', '.tsx', '.js', '.mjs', '.cjs', '.rs', '.py', '.go']).map((e) => e.toLowerCase());
258
+ let n = 0;
259
+ for (const root of resolveRoots(ctx, rule.roots)) {
260
+ for (const p of ctx.walk(root)) {
261
+ if (!hasExt(p, exts)) continue;
262
+ if (ctx.isFullBody(p)) continue; // already ingested in full by sourceBodies
263
+ const doc = docBlock(firstLines(read(p), 40), 40);
264
+ if (!doc) continue;
265
+ const rel = ctx.rel(p);
266
+ ctx.addDoc(rel, 'crate-src', path.basename(p), `Module ${rel} — doc comment:\n${doc}`, /*absPath*/ undefined);
267
+ n++;
268
+ }
269
+ }
270
+ return n;
271
+ }
272
+
273
+ // literalFiles — an explicit list of root-relative files, full text. Guarantees key docs (README,
274
+ // OVERVIEW, ARCHITECTURE, …) are in the corpus regardless of the sweep roots. De-duped by path.
275
+ export function literalFiles(ctx, rule) {
276
+ let n = 0;
277
+ for (const f of rule.files || []) {
278
+ const abs = path.join(ctx.repoDir, f);
279
+ if (!fs.existsSync(abs) || ctx.alreadyIngested(abs)) continue;
280
+ const text = read(abs);
281
+ const rel = ctx.rel(abs);
282
+ let kind = 'doc';
283
+ if (/(^|\/)adrs?\//i.test(rel) || /(^|\/)adr[-_]/i.test(rel)) kind = 'adr';
284
+ ctx.addDoc(rel, kind, titleOf(text, path.basename(abs)), text, abs);
285
+ n++;
286
+ }
287
+ return n;
288
+ }
289
+
290
+ // htmlText — visible text content of *.html under the roots. Tagged kind:'ui'.
291
+ export function htmlText(ctx, rule) {
292
+ const exts = ['.html', '.htm'];
293
+ let n = 0;
294
+ for (const root of resolveRoots(ctx, rule.roots)) {
295
+ for (const p of ctx.walk(root)) {
296
+ if (!hasExt(p, exts)) continue;
297
+ const text = htmlToText(read(p));
298
+ if (!text) continue;
299
+ const rel = ctx.rel(p);
300
+ ctx.addDoc(rel, 'ui', path.basename(p), `UI page ${rel} full text content:\n${text}`, p);
301
+ n++;
302
+ }
303
+ }
304
+ return n;
305
+ }
306
+
307
+ // templates — scaffolding templates (.tmpl/.hbs/.handlebars): path + first-N-lines, kind:'template'.
308
+ // These are the bulk of "what does a generated harness contain" — neither prose nor runnable source,
309
+ // so we index a head sample + the path (decided IN, not skipped — plan top risk #2).
310
+ export function templates(ctx, rule) {
311
+ const exts = (rule.ext || ['.tmpl', '.hbs', '.handlebars']).map((e) => e.toLowerCase());
312
+ const headLines = rule.headLines || 40;
313
+ let n = 0;
314
+ for (const root of resolveRoots(ctx, rule.roots)) {
315
+ for (const p of ctx.walk(root)) {
316
+ if (!hasExt(p, exts)) continue;
317
+ const rel = ctx.rel(p);
318
+ const body = read(p);
319
+ const head = firstLines(body, headLines);
320
+ ctx.addDoc(rel, 'template', path.basename(p),
321
+ `Template ${rel} (first ${headLines} lines):\n${head}`, p);
322
+ n++;
323
+ }
324
+ }
325
+ return n;
326
+ }
327
+
328
+ // Registry: rule name -> implementation. build-kb.mjs dispatches config.include[] through this.
329
+ export const RULE_IMPLS = {
330
+ mdSweepFullText,
331
+ componentManifests,
332
+ componentLead,
333
+ sourceBodies,
334
+ testsAndExamples,
335
+ docCommentSweep,
336
+ literalFiles,
337
+ htmlText,
338
+ templates,
339
+ };
340
+
341
+ export default RULE_IMPLS;
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env node
2
+ // dep-graph.mjs — GENERIC, config-driven dependency-graph extractor.
3
+ //
4
+ // Emits kb/stores/<slug>/<slug>-dep-graph.json: how the repo's components depend on each other
5
+ // + their external deps. An AI uses this to reason about layering / blast-radius before editing.
6
+ //
7
+ // Strategy:
8
+ // Rust: `cargo metadata --format-version 1` → workspace crate graph (internal edges between
9
+ // workspace members + each crate's external dependencies).
10
+ // TS/JS: import scan of the source tree (no madge dependency required) → module/package graph
11
+ // (internal edges between componentRoots packages via workspace imports + external deps
12
+ // from each package.json).
13
+ //
14
+ // Repo shape is DATA (kb.config.mjs target: repoDir, scopeExclude, componentRoots). Ships in the
15
+ // drop-in for-ai/. NO repo name is baked in here.
16
+ //
17
+ // Usage: node kb/dep-graph.mjs --target ruqu | node kb/dep-graph.mjs ruqu
18
+
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { execFileSync } from 'node:child_process';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { getTarget, defaultTarget } from './kb.config.mjs';
24
+
25
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
+
27
+ function parseArgs(argv) {
28
+ const a = { target: defaultTarget };
29
+ for (let i = 0; i < argv.length; i++) {
30
+ const v = argv[i];
31
+ if (v === '--target') a.target = argv[++i];
32
+ else if (v.startsWith('--target=')) a.target = v.slice(9);
33
+ else if (!v.startsWith('--')) a.target = v;
34
+ }
35
+ return a;
36
+ }
37
+
38
+ const tryRead = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } };
39
+
40
+ function* walk(dir, skip) {
41
+ let dirents;
42
+ try { dirents = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); }
43
+ catch { return; }
44
+ for (const e of dirents) {
45
+ const p = path.join(dir, e.name);
46
+ if (e.isDirectory()) { if (!skip.has(e.name)) yield* walk(p, skip); }
47
+ else if (e.isFile()) yield p;
48
+ }
49
+ }
50
+
51
+ // ---------------- Rust: cargo metadata ----------------
52
+ function rustGraph(repoDir) {
53
+ let raw;
54
+ try {
55
+ raw = execFileSync('cargo', ['metadata', '--format-version', '1', '--no-deps'],
56
+ { cwd: repoDir, maxBuffer: 64 * 1024 * 1024, timeout: 120000 }).toString();
57
+ } catch (e) {
58
+ return { ok: false, reason: `cargo metadata failed: ${e.message?.slice(0, 80)}` };
59
+ }
60
+ const m = JSON.parse(raw);
61
+ const wsNames = new Set(m.packages.map((p) => p.name));
62
+ const nodes = [];
63
+ const internalEdges = []; // { from, to } (workspace member -> workspace member)
64
+ const externalDeps = {}; // pkg -> [ {name, req, kind} ] (non-workspace)
65
+ for (const p of m.packages) {
66
+ const targets = (p.targets || []).map((t) => ({ name: t.name, kinds: t.kind }));
67
+ nodes.push({
68
+ name: p.name, version: p.version, description: p.description || null,
69
+ manifest: path.relative(repoDir, p.manifest_path),
70
+ targets,
71
+ isLib: (p.targets || []).some((t) => t.kind.includes('lib')),
72
+ bins: (p.targets || []).filter((t) => t.kind.includes('bin')).map((t) => t.name),
73
+ });
74
+ const ext = [];
75
+ for (const d of p.dependencies || []) {
76
+ if (wsNames.has(d.name)) internalEdges.push({ from: p.name, to: d.name, kind: d.kind || 'normal' });
77
+ else ext.push({ name: d.name, req: d.req, kind: d.kind || 'normal' });
78
+ }
79
+ externalDeps[p.name] = ext;
80
+ }
81
+ // unique external deps across the workspace
82
+ const allExternal = [...new Set(Object.values(externalDeps).flat().map((d) => d.name))].sort();
83
+ return { ok: true, ecosystem: 'rust', nodes, internalEdges, externalDeps, externalDepNames: allExternal };
84
+ }
85
+
86
+ // ---------------- TS/JS: import scan + package.json ----------------
87
+ const IMPORT_RE = /(?:import\s[^'"]*from\s*|import\s*|require\(\s*|export\s[^'"]*from\s*)['"]([^'"]+)['"]/g;
88
+ function tsGraph(repoDir, skip, componentRoots) {
89
+ // discover packages under componentRoots (+ cli/apps) AND the repo root itself ('.'), so a
90
+ // single-package-at-root repo (e.g. ruvn: one package.json at the top, no packages/ dir) is
91
+ // discovered too — not just a multi-package monorepo.
92
+ const pkgDirs = [];
93
+ for (const r of [...(componentRoots || ['packages']), 'cli', 'apps', 'npm/packages', '.']) {
94
+ const abs = path.join(repoDir, r);
95
+ if (!fs.existsSync(abs)) continue;
96
+ if (fs.existsSync(path.join(abs, 'package.json'))) { pkgDirs.push(abs); continue; }
97
+ for (const d of fs.readdirSync(abs, { withFileTypes: true })) {
98
+ if (d.isDirectory() && !skip.has(d.name) && fs.existsSync(path.join(abs, d.name, 'package.json'))) pkgDirs.push(path.join(abs, d.name));
99
+ }
100
+ }
101
+ const nodes = [];
102
+ const nameToDir = new Map();
103
+ for (const dir of pkgDirs) {
104
+ try {
105
+ const j = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
106
+ nodes.push({ name: j.name || path.basename(dir), version: j.version, description: j.description || null, manifest: path.relative(repoDir, path.join(dir, 'package.json')), deps: Object.keys(j.dependencies || {}) });
107
+ if (j.name) nameToDir.set(j.name, dir);
108
+ } catch { /* skip */ }
109
+ }
110
+ const wsNames = new Set(nodes.map((n) => n.name));
111
+ const internalEdges = [];
112
+ const externalDeps = {};
113
+ const seenEdge = new Set();
114
+ for (const n of nodes) {
115
+ externalDeps[n.name] = (n.deps || []).filter((d) => !wsNames.has(d)).map((d) => ({ name: d, req: '*', kind: 'normal' }));
116
+ for (const d of n.deps || []) {
117
+ if (wsNames.has(d)) { const k = `${n.name}->${d}`; if (!seenEdge.has(k)) { seenEdge.add(k); internalEdges.push({ from: n.name, to: d, kind: 'normal' }); } }
118
+ }
119
+ }
120
+ // Also scan source imports of workspace package names (covers monorepos without explicit deps).
121
+ for (const [pkgName, dir] of nameToDir) {
122
+ for (const p of walk(dir, skip)) {
123
+ if (!/\.(ts|tsx|js|mjs|cjs)$/.test(p)) continue;
124
+ const text = tryRead(p); if (!text) continue;
125
+ for (const m of text.matchAll(IMPORT_RE)) {
126
+ const spec = m[1];
127
+ for (const w of wsNames) {
128
+ if (w !== pkgName && (spec === w || spec.startsWith(w + '/'))) {
129
+ const k = `${pkgName}->${w}`;
130
+ if (!seenEdge.has(k)) { seenEdge.add(k); internalEdges.push({ from: pkgName, to: w, kind: 'import' }); }
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ const allExternal = [...new Set(Object.values(externalDeps).flat().map((d) => d.name))].sort();
137
+ return { ok: true, ecosystem: 'npm', nodes, internalEdges, externalDeps, externalDepNames: allExternal };
138
+ }
139
+
140
+ function main() {
141
+ const args = parseArgs(process.argv.slice(2));
142
+ const target = getTarget(args.target);
143
+ const slug = args.target;
144
+ const repoDir = path.resolve(__dirname, target.repoDir);
145
+ if (!fs.existsSync(repoDir)) { console.error(`[dep-graph] repoDir not found: ${repoDir}`); process.exit(1); }
146
+ const skip = new Set(target.scopeExclude || []);
147
+
148
+ const graphs = [];
149
+ if (tryRead(path.join(repoDir, 'Cargo.toml'))) {
150
+ const g = rustGraph(repoDir);
151
+ if (g.ok) graphs.push(g); else console.warn(`[dep-graph] ${g.reason}`);
152
+ }
153
+ // npm graph too (mixed monorepos like ruqu have BOTH a Cargo workspace + a cli/ package).
154
+ // Honor the target's componentRoots and the repo root ('.') so a single-package-at-root repo
155
+ // (ruvn: just a top-level package.json) flips hasNpm, not only the conventional monorepo dirs.
156
+ const npmRoots = [...new Set([...(target.componentRoots || []), 'packages', 'cli', 'apps', 'npm/packages', '.'])];
157
+ const hasNpm = npmRoots.some((r) => {
158
+ const abs = path.join(repoDir, r);
159
+ return fs.existsSync(abs) && (fs.existsSync(path.join(abs, 'package.json')) ||
160
+ (fs.statSync(abs).isDirectory() && fs.readdirSync(abs).some((d) => { try { return fs.existsSync(path.join(abs, d, 'package.json')); } catch { return false; } })));
161
+ });
162
+ if (hasNpm) { const g = tsGraph(repoDir, skip, target.componentRoots); if (g.ok && g.nodes.length) graphs.push(g); }
163
+
164
+ // Merge ecosystems into one report.
165
+ const nodes = graphs.flatMap((g) => g.nodes.map((n) => ({ ...n, ecosystem: g.ecosystem })));
166
+ const internalEdges = graphs.flatMap((g) => g.internalEdges);
167
+ const externalDeps = Object.assign({}, ...graphs.map((g) => g.externalDeps));
168
+ const externalDepNames = [...new Set(graphs.flatMap((g) => g.externalDepNames))].sort();
169
+
170
+ const out = {
171
+ target: slug, metaName: target.metaName, generated: new Date().toISOString(),
172
+ ecosystems: graphs.map((g) => g.ecosystem),
173
+ componentCount: nodes.length, internalEdgeCount: internalEdges.length, externalDepCount: externalDepNames.length,
174
+ nodes, internalEdges, externalDeps, externalDepNames,
175
+ };
176
+ const storeDir = path.join(__dirname, 'stores', slug);
177
+ fs.mkdirSync(storeDir, { recursive: true });
178
+ const outFile = path.join(storeDir, `${slug}-dep-graph.json`);
179
+ fs.writeFileSync(outFile, JSON.stringify(out, null, 2) + '\n');
180
+ console.log(`[dep-graph] ${slug}: ${nodes.length} components, ${internalEdges.length} internal edges, ${externalDepNames.length} external deps (${out.ecosystems.join('+')})`);
181
+ console.log(`[dep-graph] wrote ${path.relative(__dirname, outFile)}`);
182
+ }
183
+
184
+ main();