@dreamtree-org/graphify 1.0.0 → 1.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/dist/cli/index.js CHANGED
@@ -1,28 +1,187 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  analyze,
4
+ checkRules,
4
5
  cluster,
5
6
  exportGraph,
7
+ loadResults,
8
+ mergeGraphs,
9
+ renderLessons,
6
10
  renderReport,
7
- runPipeline
8
- } from "../chunk-DG5FECXV.js";
11
+ renderReview,
12
+ reviewRevisions,
13
+ runPipeline,
14
+ saveResult,
15
+ validateRules
16
+ } from "../chunk-EW23BLJC.js";
9
17
  import {
18
+ affectedBy,
19
+ buildContextPack,
10
20
  explainNode,
11
21
  loadGraph,
12
22
  queryGraph,
23
+ renderContextPack,
13
24
  shortestPath,
25
+ testsForChangedFiles,
26
+ testsForNode
27
+ } from "../chunk-YT7B6DOD.js";
28
+ import {
29
+ validateDsn,
14
30
  validateUrl
15
- } from "../chunk-5ANIDX3G.js";
31
+ } from "../chunk-6JLEILYF.js";
16
32
 
17
33
  // src/cli/index.ts
18
- import { execFile } from "child_process";
19
- import * as fs2 from "fs/promises";
20
- import * as os from "os";
21
- import * as path2 from "path";
22
- import { promisify } from "util";
34
+ import { execFile as execFile2 } from "child_process";
35
+ import * as fs7 from "fs/promises";
36
+ import * as os2 from "os";
37
+ import * as path5 from "path";
38
+ import { promisify as promisify2 } from "util";
23
39
  import { watch as watchFiles } from "chokidar";
24
40
  import { Command } from "commander";
25
41
 
42
+ // src/cli/commands/affected.ts
43
+ async function runAffected(nodeName, options = {}) {
44
+ const graph = await loadGraph();
45
+ const result = affectedBy(graph, nodeName, { maxDepth: options.depth, limit: options.limit });
46
+ if (!result) {
47
+ console.log(`No node matched "${nodeName}".`);
48
+ return;
49
+ }
50
+ console.log(`Impact of changing ${result.target.label} (${result.target.id}):`);
51
+ if (result.affected.length === 0) {
52
+ console.log(" Nothing in the graph depends on it \u2014 no incoming dependency edges.");
53
+ return;
54
+ }
55
+ let currentDepth = 0;
56
+ for (const node of result.affected) {
57
+ if (node.depth !== currentDepth) {
58
+ currentDepth = node.depth;
59
+ console.log("");
60
+ console.log(currentDepth === 1 ? "Directly affected:" : `Affected at depth ${currentDepth}:`);
61
+ }
62
+ const location = node.sourceLocation ? `:${node.sourceLocation}` : "";
63
+ console.log(
64
+ ` ${node.label} \u2014 ${node.sourceFile}${location} (--${node.via.relation}--> ${node.via.dependsOn}, ${node.via.confidence})`
65
+ );
66
+ }
67
+ console.log("");
68
+ const { EXTRACTED, INFERRED, AMBIGUOUS } = result.byConfidence;
69
+ console.log(
70
+ `Blast radius: ${result.affected.length} node(s) within ${result.maxDepth} hop(s) \u2014 ${EXTRACTED} certain (EXTRACTED), ${INFERRED} likely (INFERRED), ${AMBIGUOUS} uncertain (AMBIGUOUS).`
71
+ );
72
+ if (result.truncated) {
73
+ console.log("(List truncated \u2014 raise --limit to see more.)");
74
+ }
75
+ }
76
+
77
+ // src/cli/commands/benchmark.ts
78
+ import * as fs from "fs/promises";
79
+ var tokensOf = (text) => Math.ceil(text.length / 4);
80
+ async function benchmarkQuestion(graph, question, readFile7) {
81
+ const result = queryGraph(graph, question);
82
+ const graphTokens = tokensOf(JSON.stringify(result));
83
+ const sourceFiles = /* @__PURE__ */ new Set();
84
+ for (const node of result.visited) {
85
+ if (node.sourceFile && !node.sourceFile.startsWith("<") && !node.sourceFile.includes("://")) {
86
+ sourceFiles.add(node.sourceFile);
87
+ }
88
+ }
89
+ let naiveTokens = 0;
90
+ let filesRead = 0;
91
+ let filesMissing = 0;
92
+ for (const file of [...sourceFiles].sort((a, b) => a.localeCompare(b))) {
93
+ try {
94
+ naiveTokens += tokensOf(await readFile7(file));
95
+ filesRead++;
96
+ } catch {
97
+ filesMissing++;
98
+ }
99
+ }
100
+ return {
101
+ question,
102
+ graphTokens,
103
+ naiveTokens,
104
+ reduction: graphTokens > 0 && naiveTokens > 0 ? naiveTokens / graphTokens : 0,
105
+ filesRead,
106
+ filesMissing
107
+ };
108
+ }
109
+ function defaultQuestions(graph, count = 5) {
110
+ const entries = [...graph.nodes()].map((id) => ({
111
+ id,
112
+ degree: graph.degree(id),
113
+ label: graph.getNodeAttribute(id, "label") ?? id
114
+ })).filter((e) => e.id.includes("::")).sort((a, b) => b.degree - a.degree || a.id.localeCompare(b.id));
115
+ return entries.slice(0, count).map((e) => `how does ${e.label} work`);
116
+ }
117
+ async function runBenchmark(questions) {
118
+ const graph = await loadGraph();
119
+ const effective = questions.length > 0 ? questions : defaultQuestions(graph);
120
+ if (effective.length === 0) {
121
+ console.log("The graph has no entity nodes to benchmark against \u2014 build it first.");
122
+ return [];
123
+ }
124
+ const rows = [];
125
+ for (const question of effective) {
126
+ rows.push(await benchmarkQuestion(graph, question, (p) => fs.readFile(p, "utf-8")));
127
+ }
128
+ console.log("Token cost per question \u2014 graph answer vs reading the files it touches:\n");
129
+ for (const row of rows) {
130
+ const reduction = row.reduction > 0 ? `${row.reduction.toFixed(1)}x` : "n/a";
131
+ console.log(` "${row.question}"`);
132
+ console.log(
133
+ ` graph: ~${row.graphTokens} tokens | files: ~${row.naiveTokens} tokens (${row.filesRead} file(s)${row.filesMissing ? `, ${row.filesMissing} unreadable` : ""}) | reduction: ${reduction}`
134
+ );
135
+ }
136
+ const scored = rows.filter((r) => r.reduction > 0);
137
+ if (scored.length > 0) {
138
+ const avg = scored.reduce((s, r) => s + r.reduction, 0) / scored.length;
139
+ console.log(`
140
+ Average reduction: ${avg.toFixed(1)}x across ${scored.length} question(s).`);
141
+ } else {
142
+ console.log("\nNo reduction could be measured \u2014 run from the project root so source files are readable.");
143
+ }
144
+ return rows;
145
+ }
146
+
147
+ // src/cli/commands/check.ts
148
+ import * as fs2 from "fs/promises";
149
+ import * as path from "path";
150
+ async function runCheck(options = {}) {
151
+ const rulesPath = path.resolve(options.rules ?? "graphify.rules.json");
152
+ let raw;
153
+ try {
154
+ raw = await fs2.readFile(rulesPath, "utf-8");
155
+ } catch {
156
+ throw new Error(
157
+ `No rules file at ${rulesPath} \u2014 create graphify.rules.json with { "rules": [{ "name": "...", "from": "src/a/**", "disallow": ["src/b/**"] }] }.`
158
+ );
159
+ }
160
+ const config = validateRules(JSON.parse(raw));
161
+ const graph = await loadGraph();
162
+ const violations = checkRules(graph, config);
163
+ if (violations.length === 0) {
164
+ console.log(`OK \u2014 ${config.rules.length} rule(s), no violations.`);
165
+ return;
166
+ }
167
+ console.log(`${violations.length} violation(s):`);
168
+ for (const v of violations) {
169
+ console.log(` [${v.rule}] ${v.fromNode} --${v.relation}--> ${v.toNode}`);
170
+ console.log(` ${v.fromFile} must not depend on ${v.toFile}${v.reason ? ` \u2014 ${v.reason}` : ""}`);
171
+ }
172
+ process.exitCode = 1;
173
+ }
174
+
175
+ // src/cli/commands/context.ts
176
+ import * as fs3 from "fs/promises";
177
+ async function runContext(task, options = {}) {
178
+ const graph = await loadGraph();
179
+ const pack = await buildContextPack(graph, task, (p) => fs3.readFile(p, "utf-8"), {
180
+ tokenBudget: options.budget
181
+ });
182
+ console.log(renderContextPack(pack));
183
+ }
184
+
26
185
  // src/cli/commands/explain.ts
27
186
  async function runExplain(nodeName) {
28
187
  const graph = await loadGraph();
@@ -57,31 +216,372 @@ async function runExplain(nodeName) {
57
216
  }
58
217
  }
59
218
 
219
+ // src/cli/commands/review.ts
220
+ async function runReview(base, head) {
221
+ const diff = await reviewRevisions(process.cwd(), base, head ?? "HEAD");
222
+ console.log(renderReview(diff));
223
+ }
224
+
225
+ // src/cli/commands/tests.ts
226
+ import { execFile } from "child_process";
227
+ import { promisify } from "util";
228
+ var execFileAsync = promisify(execFile);
229
+ async function runTests(nodeQuery, options = {}) {
230
+ const graph = await loadGraph();
231
+ let selection;
232
+ if (options.changed !== void 0 && options.changed !== false) {
233
+ const args = ["diff", "--name-only"];
234
+ if (typeof options.changed === "string") args.push(options.changed);
235
+ const { stdout } = await execFileAsync("git", args);
236
+ const changedFiles = stdout.split("\n").map((l) => l.trim()).filter(Boolean);
237
+ if (changedFiles.length === 0) {
238
+ console.log("No changed files in the diff.");
239
+ return;
240
+ }
241
+ selection = testsForChangedFiles(graph, changedFiles);
242
+ } else if (nodeQuery) {
243
+ selection = testsForNode(graph, nodeQuery);
244
+ if (!selection) {
245
+ console.log(`No node matched "${nodeQuery}".`);
246
+ return;
247
+ }
248
+ } else {
249
+ console.log("Provide a node to select tests for, or --changed for the working-tree diff.");
250
+ return;
251
+ }
252
+ if (selection.testFiles.length === 0) {
253
+ console.log(
254
+ `No test files found in the blast radius of ${selection.target} (${selection.affectedNodeCount} affected node(s) checked) \u2014 either it's untested or the tests reach it through a path the graph does not capture.`
255
+ );
256
+ return;
257
+ }
258
+ console.log(`Test files for ${selection.target} (most direct first):`);
259
+ for (const test of selection.testFiles) {
260
+ const via = test.depth === 0 ? test.via : `depth ${test.depth}, via ${test.via}`;
261
+ console.log(` ${test.file} (${via})`);
262
+ }
263
+ }
264
+
265
+ // src/cli/commands/global.ts
266
+ import * as fs4 from "fs/promises";
267
+ import * as os from "os";
268
+ import * as path2 from "path";
269
+ function registryDir() {
270
+ return path2.join(os.homedir(), ".graphify");
271
+ }
272
+ function registryPath() {
273
+ return path2.join(registryDir(), "global.json");
274
+ }
275
+ async function readRegistry() {
276
+ try {
277
+ const raw = await fs4.readFile(registryPath(), "utf-8");
278
+ const parsed = JSON.parse(raw);
279
+ const projects = parsed.projects;
280
+ if (!Array.isArray(projects)) return { projects: [] };
281
+ return { projects: projects.filter((p) => typeof p?.name === "string" && typeof p?.root === "string") };
282
+ } catch {
283
+ return { projects: [] };
284
+ }
285
+ }
286
+ async function writeRegistry(registry) {
287
+ await fs4.mkdir(registryDir(), { recursive: true });
288
+ await fs4.writeFile(registryPath(), JSON.stringify(registry, null, 2), "utf-8");
289
+ }
290
+ async function projectNameFor(root) {
291
+ try {
292
+ const raw = await fs4.readFile(path2.join(root, "package.json"), "utf-8");
293
+ const name = JSON.parse(raw).name;
294
+ if (typeof name === "string" && name.length > 0) return name;
295
+ } catch {
296
+ }
297
+ return path2.basename(root);
298
+ }
299
+ async function runGlobalAdd(target = ".") {
300
+ const root = path2.resolve(target);
301
+ const graphJson = path2.join(root, "graphify-out", "graph.json");
302
+ try {
303
+ await fs4.access(graphJson);
304
+ } catch {
305
+ throw new Error(`${root} has no graphify-out/graph.json \u2014 run \`graphify ${target}\` first, then add it.`);
306
+ }
307
+ const name = await projectNameFor(root);
308
+ const registry = await readRegistry();
309
+ const existing = registry.projects.find((p) => p.name === name);
310
+ if (existing) {
311
+ if (existing.root === root) {
312
+ console.log(`${name} is already registered (${root}).`);
313
+ return;
314
+ }
315
+ throw new Error(
316
+ `A different project is already registered as "${name}" (${existing.root}). Remove it first with \`graphify global remove\`.`
317
+ );
318
+ }
319
+ registry.projects.push({ name, root });
320
+ registry.projects.sort((a, b) => a.name.localeCompare(b.name));
321
+ await writeRegistry(registry);
322
+ console.log(`Registered ${name} (${root}). ${registry.projects.length} project(s) in the global graph.`);
323
+ }
324
+ async function runGlobalRemove(name) {
325
+ const registry = await readRegistry();
326
+ const before = registry.projects.length;
327
+ registry.projects = registry.projects.filter((p) => p.name !== name);
328
+ if (registry.projects.length === before) {
329
+ throw new Error(`No project named "${name}" is registered. See \`graphify global list\`.`);
330
+ }
331
+ await writeRegistry(registry);
332
+ console.log(`Removed ${name}. ${registry.projects.length} project(s) remain.`);
333
+ }
334
+ async function runGlobalList() {
335
+ const registry = await readRegistry();
336
+ if (registry.projects.length === 0) {
337
+ console.log("No projects registered. Add one with `graphify global add <path>`.");
338
+ return;
339
+ }
340
+ for (const project of registry.projects) {
341
+ console.log(`${project.name} ${project.root}`);
342
+ }
343
+ }
344
+ async function loadEntries(sources) {
345
+ const entries = [];
346
+ for (const source of sources) {
347
+ try {
348
+ entries.push({ name: source.name, graph: await loadGraph(source.outDir) });
349
+ } catch (error) {
350
+ console.warn(`Skipping ${source.name}: ${error.message}`);
351
+ }
352
+ }
353
+ return entries;
354
+ }
355
+ async function mergeAndExport(entries, outDir) {
356
+ if (entries.length === 0) {
357
+ throw new Error("Nothing to merge \u2014 no project graphs could be loaded.");
358
+ }
359
+ const merged = mergeGraphs(entries);
360
+ cluster(merged);
361
+ const analysis = analyze(merged);
362
+ const report = renderReport(merged, analysis);
363
+ await exportGraph(merged, { outDir }, report);
364
+ console.log(
365
+ `Merged ${entries.length} project(s) into ${outDir} \u2014 ${merged.order} node(s), ${merged.size} edge(s).`
366
+ );
367
+ }
368
+ async function runGlobalBuild(options = {}) {
369
+ const registry = await readRegistry();
370
+ if (registry.projects.length === 0) {
371
+ throw new Error("No projects registered. Add some with `graphify global add <path>` first.");
372
+ }
373
+ const entries = await loadEntries(
374
+ registry.projects.map((p) => ({ name: p.name, outDir: path2.join(p.root, "graphify-out") }))
375
+ );
376
+ await mergeAndExport(entries, path2.resolve(options.out ?? path2.join(registryDir(), "global-out")));
377
+ }
378
+ async function runMerge(targets, options = {}) {
379
+ if (targets.length < 2) {
380
+ throw new Error("Provide at least two project directories to merge.");
381
+ }
382
+ const sources = [];
383
+ for (const target of targets) {
384
+ const root = path2.resolve(target);
385
+ sources.push({ name: await projectNameFor(root), outDir: path2.join(root, "graphify-out") });
386
+ }
387
+ const names = sources.map((s) => s.name);
388
+ const duplicate = names.find((name, i) => names.indexOf(name) !== i);
389
+ if (duplicate !== void 0) {
390
+ throw new Error(`Two of the given projects resolve to the same name ("${duplicate}") \u2014 rename one.`);
391
+ }
392
+ const entries = await loadEntries(sources);
393
+ await mergeAndExport(entries, path2.resolve(options.out ?? "graphify-merged"));
394
+ }
395
+
396
+ // src/cli/commands/hook.ts
397
+ import * as fs5 from "fs/promises";
398
+ import * as path3 from "path";
399
+ var MARKER_BEGIN = "# >>> graphify >>>";
400
+ var MARKER_END = "# <<< graphify <<<";
401
+ var HOOK_NAMES = ["post-commit", "post-merge"];
402
+ var HOOK_BLOCK = `${MARKER_BEGIN}
403
+ # Rebuild the graphify knowledge graph in the background so the hook never
404
+ # slows down the commit. Errors are silenced \u2014 a failed rebuild should never
405
+ # break a git operation. Installed by \`graphify hook install\`.
406
+ (graphify . --update --no-viz >/dev/null 2>&1 &)
407
+ ${MARKER_END}`;
408
+ async function hooksDir(cwd) {
409
+ const gitDir = path3.join(cwd, ".git");
410
+ let stats;
411
+ try {
412
+ stats = await fs5.stat(gitDir);
413
+ } catch {
414
+ throw new Error(`${cwd} is not a git repository (no .git directory) \u2014 run this from the repo root.`);
415
+ }
416
+ if (!stats.isDirectory()) {
417
+ const content = (await fs5.readFile(gitDir, "utf-8")).trim();
418
+ const match = /^gitdir:\s*(.+)$/.exec(content);
419
+ if (!match) throw new Error(`${gitDir} exists but is neither a directory nor a gitdir pointer.`);
420
+ return path3.resolve(cwd, match[1], "hooks");
421
+ }
422
+ return path3.join(gitDir, "hooks");
423
+ }
424
+ async function runHookInstall(cwd = process.cwd()) {
425
+ const dir = await hooksDir(cwd);
426
+ await fs5.mkdir(dir, { recursive: true });
427
+ const results = [];
428
+ for (const hook of HOOK_NAMES) {
429
+ const hookPath = path3.join(dir, hook);
430
+ let existing = null;
431
+ try {
432
+ existing = await fs5.readFile(hookPath, "utf-8");
433
+ } catch {
434
+ }
435
+ if (existing === null) {
436
+ await fs5.writeFile(hookPath, `#!/bin/sh
437
+ ${HOOK_BLOCK}
438
+ `, { mode: 493 });
439
+ results.push({ hook, path: hookPath, action: "created" });
440
+ } else if (existing.includes(MARKER_BEGIN)) {
441
+ results.push({ hook, path: hookPath, action: "already-installed" });
442
+ } else {
443
+ const separator = existing.endsWith("\n") ? "" : "\n";
444
+ await fs5.writeFile(hookPath, `${existing}${separator}${HOOK_BLOCK}
445
+ `, "utf-8");
446
+ await fs5.chmod(hookPath, 493);
447
+ results.push({ hook, path: hookPath, action: "appended" });
448
+ }
449
+ }
450
+ for (const result of results) {
451
+ console.log(`${result.hook}: ${result.action} (${result.path})`);
452
+ }
453
+ console.log("");
454
+ console.log("The graph will rebuild in the background after each commit and pull.");
455
+ console.log("Remove with `graphify hook uninstall`.");
456
+ return results;
457
+ }
458
+ async function runHookUninstall(cwd = process.cwd()) {
459
+ const dir = await hooksDir(cwd);
460
+ const results = [];
461
+ for (const hook of HOOK_NAMES) {
462
+ const hookPath = path3.join(dir, hook);
463
+ let existing = null;
464
+ try {
465
+ existing = await fs5.readFile(hookPath, "utf-8");
466
+ } catch {
467
+ }
468
+ if (existing === null || !existing.includes(MARKER_BEGIN)) {
469
+ results.push({ hook, path: hookPath, action: "not-installed" });
470
+ continue;
471
+ }
472
+ const blockPattern = new RegExp(`\\n?${MARKER_BEGIN}[\\s\\S]*?${MARKER_END}\\n?`);
473
+ const remaining = existing.replace(blockPattern, "\n").trim();
474
+ if (remaining === "" || remaining === "#!/bin/sh") {
475
+ await fs5.unlink(hookPath);
476
+ } else {
477
+ await fs5.writeFile(hookPath, `${remaining}
478
+ `, "utf-8");
479
+ }
480
+ results.push({ hook, path: hookPath, action: "removed" });
481
+ }
482
+ for (const result of results) {
483
+ console.log(`${result.hook}: ${result.action}`);
484
+ }
485
+ return results;
486
+ }
487
+
60
488
  // src/cli/commands/install.ts
61
489
  import { createRequire } from "module";
62
- import * as fs from "fs/promises";
63
- import * as path from "path";
490
+ import * as fs6 from "fs/promises";
491
+ import * as path4 from "path";
64
492
  var require2 = createRequire(import.meta.url);
65
493
  function packageRoot() {
66
494
  const packageJsonPath = require2.resolve("@dreamtree-org/graphify/package.json");
67
- return path.dirname(packageJsonPath);
495
+ return path4.dirname(packageJsonPath);
496
+ }
497
+ var MD_MARKER_BEGIN = "<!-- >>> graphify >>> -->";
498
+ var MD_MARKER_END = "<!-- <<< graphify <<< -->";
499
+ async function writeOwnedFile(filePath, content) {
500
+ await fs6.mkdir(path4.dirname(filePath), { recursive: true });
501
+ await fs6.writeFile(filePath, content, "utf-8");
502
+ }
503
+ async function upsertMarkedBlock(filePath, content) {
504
+ const block = `${MD_MARKER_BEGIN}
505
+ ${content.trim()}
506
+ ${MD_MARKER_END}`;
507
+ let existing = null;
508
+ try {
509
+ existing = await fs6.readFile(filePath, "utf-8");
510
+ } catch {
511
+ }
512
+ if (existing === null) {
513
+ await fs6.mkdir(path4.dirname(filePath), { recursive: true });
514
+ await fs6.writeFile(filePath, `${block}
515
+ `, "utf-8");
516
+ return;
517
+ }
518
+ if (existing.includes(MD_MARKER_BEGIN)) {
519
+ const pattern = new RegExp(`${MD_MARKER_BEGIN}[\\s\\S]*?${MD_MARKER_END}`);
520
+ await fs6.writeFile(filePath, existing.replace(pattern, block), "utf-8");
521
+ return;
522
+ }
523
+ const separator = existing.endsWith("\n") ? "\n" : "\n\n";
524
+ await fs6.writeFile(filePath, `${existing}${separator}${block}
525
+ `, "utf-8");
68
526
  }
69
- async function runInstall(cwd = process.cwd()) {
70
- const sourcePath = path.join(packageRoot(), "src", "skill", "SKILL.md");
71
- const content = await fs.readFile(sourcePath, "utf-8");
527
+ var PLATFORMS = {
528
+ claude: async (cwd, content) => {
529
+ const target = path4.join(cwd, ".claude", "skills", "graphify", "SKILL.md");
530
+ await writeOwnedFile(target, content);
531
+ return { host: "Claude Code", path: target };
532
+ },
533
+ cursor: async (cwd, content) => {
534
+ const target = path4.join(cwd, ".cursor", "rules", "graphify.mdc");
535
+ const body = content.replace(/^---[\s\S]*?---\n/, "");
536
+ await writeOwnedFile(
537
+ target,
538
+ `---
539
+ description: Query the graphify knowledge graph for codebase structure questions
540
+ alwaysApply: false
541
+ ---
542
+ ${body}`
543
+ );
544
+ return { host: "Cursor", path: target };
545
+ },
546
+ windsurf: async (cwd, content) => {
547
+ const target = path4.join(cwd, ".windsurf", "rules", "graphify.md");
548
+ await writeOwnedFile(target, content.replace(/^---[\s\S]*?---\n/, ""));
549
+ return { host: "Windsurf", path: target };
550
+ },
551
+ cline: async (cwd, content) => {
552
+ const target = path4.join(cwd, ".clinerules", "graphify.md");
553
+ await writeOwnedFile(target, content.replace(/^---[\s\S]*?---\n/, ""));
554
+ return { host: "Cline", path: target };
555
+ },
556
+ agents: async (cwd, content) => {
557
+ const target = path4.join(cwd, "AGENTS.md");
558
+ await upsertMarkedBlock(target, content.replace(/^---[\s\S]*?---\n/, ""));
559
+ return { host: "AGENTS.md agents (Codex, opencode, Amp, ...)", path: target };
560
+ },
561
+ gemini: async (cwd, content) => {
562
+ const target = path4.join(cwd, "GEMINI.md");
563
+ await upsertMarkedBlock(target, content.replace(/^---[\s\S]*?---\n/, ""));
564
+ return { host: "Gemini CLI", path: target };
565
+ }
566
+ };
567
+ var SUPPORTED_PLATFORMS = Object.keys(PLATFORMS).sort((a, b) => a.localeCompare(b));
568
+ async function runInstall(platforms = ["claude"], cwd = process.cwd()) {
569
+ const sourcePath = path4.join(packageRoot(), "src", "skill", "SKILL.md");
570
+ const content = await fs6.readFile(sourcePath, "utf-8");
571
+ const requested = platforms.includes("all") ? SUPPORTED_PLATFORMS : platforms;
72
572
  const results = [];
73
- const claudeCodeDir = path.join(cwd, ".claude", "skills", "graphify");
74
- await fs.mkdir(claudeCodeDir, { recursive: true });
75
- const claudeCodePath = path.join(claudeCodeDir, "SKILL.md");
76
- await fs.writeFile(claudeCodePath, content, "utf-8");
77
- results.push({ host: "Claude Code", path: claudeCodePath });
573
+ for (const platform of requested) {
574
+ const installer = PLATFORMS[platform];
575
+ if (!installer) {
576
+ throw new Error(
577
+ `Unknown platform "${platform}" \u2014 supported: ${SUPPORTED_PLATFORMS.join(", ")}, or "all".`
578
+ );
579
+ }
580
+ results.push(await installer(cwd, content));
581
+ }
78
582
  for (const result of results) {
79
- console.log(`Installed graphify skill for ${result.host} -> ${result.path}`);
583
+ console.log(`Installed graphify for ${result.host} -> ${result.path}`);
80
584
  }
81
- console.log("");
82
- console.log(
83
- "Other hosts (Cursor, etc.) are not implemented yet by `graphify install` \u2014 see ARCHITECTURE.md."
84
- );
85
585
  return results;
86
586
  }
87
587
 
@@ -113,7 +613,7 @@ async function runPath(fromNode, toNode) {
113
613
  // src/cli/commands/query.ts
114
614
  async function runQuery(question, options = {}) {
115
615
  const graph = await loadGraph();
116
- const result = queryGraph(graph, question, { dfs: options.dfs, budget: options.budget });
616
+ const result = queryGraph(graph, question, { dfs: options.dfs, tokenBudget: options.budget });
117
617
  if (result.seeds.length === 0) {
118
618
  console.log(
119
619
  `No nodes matched "${question}". Try different keywords, or run \`graphify explain <node>\` if you already know the name.`
@@ -138,15 +638,15 @@ async function runQuery(question, options = {}) {
138
638
  }
139
639
 
140
640
  // src/cli/index.ts
141
- var execFileAsync = promisify(execFile);
641
+ var execFileAsync2 = promisify2(execFile2);
142
642
  function looksLikeGitUrl(value) {
143
643
  if (!/^https?:\/\//i.test(value)) return false;
144
644
  return /github\.com|gitlab\.com|bitbucket\.org/i.test(value) || value.endsWith(".git");
145
645
  }
146
646
  async function cloneRepo(url) {
147
647
  const validated = validateUrl(url);
148
- const dest = await fs2.mkdtemp(path2.join(os.tmpdir(), "graphify-clone-"));
149
- await execFileAsync("git", ["clone", "--depth", "1", validated, dest]);
648
+ const dest = await fs7.mkdtemp(path5.join(os2.tmpdir(), "graphify-clone-"));
649
+ await execFileAsync2("git", ["clone", "--depth", "1", validated, dest]);
150
650
  return dest;
151
651
  }
152
652
  function exportOptionsFrom(options) {
@@ -159,7 +659,7 @@ function clusterOptionsFrom(options) {
159
659
  };
160
660
  }
161
661
  async function runClusterOnly(root, options) {
162
- const outDir = path2.join(root, "graphify-out");
662
+ const outDir = path5.join(root, "graphify-out");
163
663
  const graph = await loadGraph(outDir);
164
664
  cluster(graph, clusterOptionsFrom(options));
165
665
  const analysis = analyze(graph);
@@ -190,12 +690,12 @@ async function watchAndRebuild(root, runOnce) {
190
690
  });
191
691
  }
192
692
  var program = new Command();
193
- program.name("graphify").description("Turn a folder of code/docs/papers into a queryable knowledge graph.").argument("[path]", "path to scan, or a GitHub URL", ".").option("--mode <mode>", "extraction mode (deep for richer INFERRED edges \u2014 reserved, no-op in v1)").option("--update", "incremental re-extract of changed files only (not implemented in v1 \u2014 runs full pipeline)").option("--watch", "rebuild on file change").option("--cluster-only", "rerun clustering on an existing graph").option("--leiden", "use Leiden instead of Louvain for community detection (v2 \u2014 better community quality)").option("--max-iterations <n>", "Leiden only: cap on outer iterations for very large graphs", Number).option("--no-viz", "skip graph.html").option("--svg", "also export graph.svg (not implemented in v1)").option("--graphml", "also export graph.graphml (not implemented in v1)").option("--neo4j", "generate graphify-out/cypher.txt for Neo4j (not implemented in v1)").option("--mcp", "start MCP stdio server instead of running the pipeline").action(async (targetArg, options) => {
693
+ program.name("graphify").description("Turn a folder of code/docs/papers into a queryable knowledge graph.").argument("[path]", "path to scan, or a GitHub URL", ".").option("--mode <mode>", "extraction mode (deep for richer INFERRED edges \u2014 reserved, no-op in v1)").option("--update", "incremental rebuild \u2014 reuse cached extractions for unchanged files").option("--watch", "rebuild on file change").option("--cluster-only", "rerun clustering on an existing graph").option("--leiden", "use Leiden instead of Louvain for community detection (v2 \u2014 better community quality)").option("--max-iterations <n>", "Leiden only: cap on outer iterations for very large graphs", Number).option("--no-viz", "skip graph.html").option("--svg", "also export graph.svg (not implemented in v1)").option("--graphml", "also export graph.graphml (not implemented in v1)").option("--neo4j", "generate graphify-out/cypher.txt for Neo4j (not implemented in v1)").option("--mcp", "start MCP stdio server instead of running the pipeline").option("--mysql <dsn>", "also extract a MySQL schema (mysql://user:pass@host:port/db) into the graph").action(async (targetArg, options) => {
194
694
  try {
195
695
  if (options.mcp) {
196
- const outDir = path2.resolve(targetArg === "." ? process.cwd() : targetArg, "graphify-out");
696
+ const outDir = path5.resolve(targetArg === "." ? process.cwd() : targetArg, "graphify-out");
197
697
  const { startServer } = await import("../mcp/server.js");
198
- await startServer(path2.join(outDir, "graph.json"));
698
+ await startServer(path5.join(outDir, "graph.json"));
199
699
  return;
200
700
  }
201
701
  let root = targetArg;
@@ -203,12 +703,7 @@ program.name("graphify").description("Turn a folder of code/docs/papers into a q
203
703
  console.error(`Cloning ${targetArg} ...`);
204
704
  root = await cloneRepo(targetArg);
205
705
  }
206
- root = path2.resolve(root);
207
- if (options.update) {
208
- console.error(
209
- "--update (incremental re-extract) is not implemented yet in v1 \u2014 running the full pipeline instead."
210
- );
211
- }
706
+ root = path5.resolve(root);
212
707
  if (options.mode) {
213
708
  console.error(`--mode ${options.mode} is reserved for a future richer-INFERRED-edges pass \u2014 currently a no-op.`);
214
709
  }
@@ -216,9 +711,17 @@ program.name("graphify").description("Turn a folder of code/docs/papers into a q
216
711
  await runClusterOnly(root, options);
217
712
  return;
218
713
  }
714
+ let extraExtractions;
715
+ if (options.mysql) {
716
+ const { extractMysql } = await import("../mysql-EJ6XOWR4.js");
717
+ console.error(`Extracting MySQL schema from ${validateDsn(options.mysql).safeDisplay} ...`);
718
+ extraExtractions = [await extractMysql(options.mysql)];
719
+ }
219
720
  const runOnce = () => runPipeline(root, {
220
721
  ...exportOptionsFrom(options),
221
722
  ...clusterOptionsFrom(options),
723
+ extraExtractions,
724
+ update: options.update,
222
725
  onProgress: (m) => console.error(m)
223
726
  });
224
727
  await runOnce();
@@ -255,13 +758,153 @@ program.command("explain <node>").description("plain-language explanation of a n
255
758
  process.exitCode = 1;
256
759
  }
257
760
  });
258
- program.command("install").description("install the skill files into the local agent(s)").action(async () => {
761
+ program.command("affected <node>").description("reverse impact analysis \u2014 everything that (transitively) depends on a node").option("--depth <n>", "how many reverse hops to follow (default 3)", Number).option("--limit <n>", "cap on affected nodes reported (default 200)", Number).action(async (node, options) => {
762
+ try {
763
+ await runAffected(node, options);
764
+ } catch (error) {
765
+ console.error(`graphify affected: ${error.message}`);
766
+ process.exitCode = 1;
767
+ }
768
+ });
769
+ program.command("context <task>").description("token-budgeted working-set pack \u2014 the actual code for a task, in one call").option("--budget <tokens>", "approximate token cap for the pack (default 4000)", Number).action(async (task, options) => {
770
+ try {
771
+ await runContext(task, options);
772
+ } catch (error) {
773
+ console.error(`graphify context: ${error.message}`);
774
+ process.exitCode = 1;
775
+ }
776
+ });
777
+ program.command("tests [node]").description("structural test selection \u2014 the minimal test files worth running for a change").option("--changed [rev]", "select for the working-tree diff (optionally vs a revision) instead of a node").action(async (node, options) => {
259
778
  try {
260
- await runInstall();
779
+ await runTests(node, options);
780
+ } catch (error) {
781
+ console.error(`graphify tests: ${error.message}`);
782
+ process.exitCode = 1;
783
+ }
784
+ });
785
+ program.command("review <base> [head]").description("structural review between two git revisions: added/removed/rewired symbols with blast radius + tests").action(async (base, head) => {
786
+ try {
787
+ await runReview(base, head);
788
+ } catch (error) {
789
+ console.error(`graphify review: ${error.message}`);
790
+ process.exitCode = 1;
791
+ }
792
+ });
793
+ program.command("check").description("check the graph against dependency rules (graphify.rules.json) \u2014 exits 1 on violations").option("--rules <file>", "rules file (default ./graphify.rules.json)").action(async (options) => {
794
+ try {
795
+ await runCheck(options);
796
+ } catch (error) {
797
+ console.error(`graphify check: ${error.message}`);
798
+ process.exitCode = 1;
799
+ }
800
+ });
801
+ program.command("benchmark [questions...]").description("measure token savings of graph answers vs reading the files they touch").action(async (questions) => {
802
+ try {
803
+ await runBenchmark(questions);
804
+ } catch (error) {
805
+ console.error(`graphify benchmark: ${error.message}`);
806
+ process.exitCode = 1;
807
+ }
808
+ });
809
+ program.command("install").description("install the skill/rules files into local agents (claude, cursor, windsurf, cline, agents, gemini, all)").option("--platform <names...>", "platform(s) to install for", ["claude"]).action(async (options) => {
810
+ try {
811
+ await runInstall(options.platform);
261
812
  } catch (error) {
262
813
  console.error(`graphify install: ${error.message}`);
263
814
  process.exitCode = 1;
264
815
  }
265
816
  });
817
+ var globalCommand = program.command("global").description("manage the cross-project global graph (registry at ~/.graphify/global.json)");
818
+ globalCommand.command("add [path]").description("register a project (must already have graphify-out/graph.json)").action(async (target) => {
819
+ try {
820
+ await runGlobalAdd(target);
821
+ } catch (error) {
822
+ console.error(`graphify global add: ${error.message}`);
823
+ process.exitCode = 1;
824
+ }
825
+ });
826
+ globalCommand.command("remove <name>").description("unregister a project by name").action(async (name) => {
827
+ try {
828
+ await runGlobalRemove(name);
829
+ } catch (error) {
830
+ console.error(`graphify global remove: ${error.message}`);
831
+ process.exitCode = 1;
832
+ }
833
+ });
834
+ globalCommand.command("list").description("list registered projects").action(async () => {
835
+ try {
836
+ await runGlobalList();
837
+ } catch (error) {
838
+ console.error(`graphify global list: ${error.message}`);
839
+ process.exitCode = 1;
840
+ }
841
+ });
842
+ globalCommand.command("build").description("merge every registered project into one global graph").option("--out <dir>", "output directory (default ~/.graphify/global-out)").action(async (options) => {
843
+ try {
844
+ await runGlobalBuild(options);
845
+ } catch (error) {
846
+ console.error(`graphify global build: ${error.message}`);
847
+ process.exitCode = 1;
848
+ }
849
+ });
850
+ program.command("merge <dirs...>").description("one-shot merge of two or more built project graphs into one").option("--out <dir>", "output directory (default ./graphify-merged)").action(async (dirs, options) => {
851
+ try {
852
+ await runMerge(dirs, options);
853
+ } catch (error) {
854
+ console.error(`graphify merge: ${error.message}`);
855
+ process.exitCode = 1;
856
+ }
857
+ });
858
+ program.command("save-result").description("save a Q&A result to graphify-out/memory/ for the graph feedback loop").requiredOption("--question <q>", "the question that was asked").requiredOption("--answer <a>", "the answer that was given").option("--nodes <ids...>", "node ids/labels cited in the answer", []).option("--type <t>", "query|path|explain|affected", "query").option("--outcome <o>", "useful|dead_end|corrected", "useful").option("--correction <text>", "what the right answer was (pairs with --outcome corrected)").action(async (options) => {
859
+ try {
860
+ const memoryDir = path5.join(process.cwd(), "graphify-out", "memory");
861
+ const file = await saveResult(
862
+ {
863
+ question: options.question,
864
+ answer: options.answer,
865
+ nodes: options.nodes,
866
+ type: options.type,
867
+ outcome: options.outcome,
868
+ correction: options.correction
869
+ },
870
+ memoryDir
871
+ );
872
+ console.log(`Saved -> ${file}`);
873
+ } catch (error) {
874
+ console.error(`graphify save-result: ${error.message}`);
875
+ process.exitCode = 1;
876
+ }
877
+ });
878
+ program.command("reflect").description("aggregate graphify-out/memory/ into a recency-weighted lessons doc").option("--half-life-days <n>", "a result loses half its weight every N days (default 30)", Number).option("--out <file>", "output path (default graphify-out/reflections/LESSONS.md)").action(async (options) => {
879
+ try {
880
+ const memoryDir = path5.join(process.cwd(), "graphify-out", "memory");
881
+ const results = await loadResults(memoryDir);
882
+ const lessons = renderLessons(results, { halfLifeDays: options.halfLifeDays });
883
+ const outPath = path5.resolve(options.out ?? path5.join(process.cwd(), "graphify-out", "reflections", "LESSONS.md"));
884
+ await fs7.mkdir(path5.dirname(outPath), { recursive: true });
885
+ await fs7.writeFile(outPath, lessons, "utf-8");
886
+ console.log(`Reflected ${results.length} result(s) -> ${outPath}`);
887
+ } catch (error) {
888
+ console.error(`graphify reflect: ${error.message}`);
889
+ process.exitCode = 1;
890
+ }
891
+ });
892
+ var hookCommand = program.command("hook").description("manage the git hooks that auto-rebuild the graph on commit/pull");
893
+ hookCommand.command("install").description("install post-commit and post-merge hooks (preserves existing hook content)").action(async () => {
894
+ try {
895
+ await runHookInstall();
896
+ } catch (error) {
897
+ console.error(`graphify hook install: ${error.message}`);
898
+ process.exitCode = 1;
899
+ }
900
+ });
901
+ hookCommand.command("uninstall").description("remove the graphify block from the git hooks").action(async () => {
902
+ try {
903
+ await runHookUninstall();
904
+ } catch (error) {
905
+ console.error(`graphify hook uninstall: ${error.message}`);
906
+ process.exitCode = 1;
907
+ }
908
+ });
266
909
  program.parseAsync(process.argv);
267
910
  //# sourceMappingURL=index.js.map