@dreamtree-org/graphify 1.0.0 → 1.1.2

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