@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/index.d.cts CHANGED
@@ -51,15 +51,22 @@ interface ExportOptions {
51
51
  */
52
52
  declare function collectFiles(root: string): FileManifest;
53
53
 
54
- type Extractor = (path: string) => Promise<ExtractionResult>;
54
+ /** `displayPath`, when given, is used for node ids/sourceFile instead of the read path — see extract(). */
55
+ type Extractor = (path: string, displayPath?: string) => Promise<ExtractionResult>;
55
56
  /** File extension -> extractor module. Register new languages here. */
56
57
  declare const EXTRACTOR_REGISTRY: Record<string, Extractor>;
57
58
  /**
58
59
  * Dispatch a single file to its language extractor. Returns an empty
59
60
  * extraction (not a thrown error) for unregistered extensions so detect's
60
61
  * file list and extract's coverage can diverge without crashing a run.
62
+ *
63
+ * `displayPath` decouples where the file is read from (relative to the
64
+ * process cwd) from the path used in node ids and sourceFile attributes —
65
+ * the pipeline passes the project-root-relative path so ids stay stable
66
+ * and portable no matter which directory the pipeline was launched from
67
+ * (this is what makes cross-project merging's namespacing meaningful).
61
68
  */
62
- declare function extract(filePath: string): Promise<ExtractionResult>;
69
+ declare function extract(filePath: string, displayPath?: string): Promise<ExtractionResult>;
63
70
 
64
71
  /**
65
72
  * Merge per-file extraction results into one graphology graph.
@@ -83,6 +90,28 @@ declare function extract(filePath: string): Promise<ExtractionResult>;
83
90
  */
84
91
  declare function buildGraph(extractions: ExtractionResult[]): Graph;
85
92
 
93
+ interface ResolveOptions {
94
+ /**
95
+ * The scanned package's own name(s) (from package.json). Tests very
96
+ * commonly import the package's PUBLIC entry (`import { persist } from
97
+ * 'zustand/middleware'`) rather than a relative path — without this,
98
+ * those land on an opaque `module:` node and the test never connects to
99
+ * the source it exercises (so `graphify tests` under-selects).
100
+ */
101
+ selfNames?: string[];
102
+ }
103
+ interface ResolveStats {
104
+ /** Edge endpoints re-pointed from a guessed id to a real node id. */
105
+ resolvedEndpoints: number;
106
+ /** Placeholder module nodes dropped because the real file node replaced them. */
107
+ droppedPlaceholders: number;
108
+ }
109
+ /**
110
+ * Mutates `extractions` in place: re-points guessed edge endpoints at real
111
+ * node ids and drops the placeholder module nodes those guesses created.
112
+ */
113
+ declare function resolveCrossFileReferences(extractions: ExtractionResult[], options?: ResolveOptions): ResolveStats;
114
+
86
115
  type ClusterAlgorithm = 'louvain' | 'leiden';
87
116
  interface ClusterOptions {
88
117
  /**
@@ -155,6 +184,17 @@ interface PipelineOptions {
155
184
  algorithm?: ClusterAlgorithm;
156
185
  /** Leiden-only: cap on outer iterations for very large graphs. */
157
186
  maxIterations?: number;
187
+ /**
188
+ * Pre-computed extractions from non-filesystem sources (e.g. a MySQL
189
+ * schema via extractMysql()) merged into the same graph as the code.
190
+ */
191
+ extraExtractions?: ExtractionResult[];
192
+ /**
193
+ * Incremental mode: reuse cached per-file extractions (keyed by content
194
+ * hash) and re-parse only changed files. The cache is warmed on every
195
+ * run either way — `update` only controls whether it's read.
196
+ */
197
+ update?: boolean;
158
198
  /** Called with a short progress message after each stage (e.g. for CLI stderr output). */
159
199
  onProgress?: (message: string) => void;
160
200
  }
@@ -186,7 +226,18 @@ interface NodeMatch {
186
226
  label: string;
187
227
  score: number;
188
228
  }
189
- /** Score every node by how many query tokens appear in its label or id. */
229
+ /**
230
+ * Score every node against the query's tokens. Identifier-aware and
231
+ * typo-tolerant, in three tiers per token (best tier wins):
232
+ *
233
+ * 1. exact subtoken match (camelCase/snake_case-split word of the label
234
+ * or id) — "storage" matches `fileStorage.js`;
235
+ * 2. plain substring of the label/id (the original v1 behavior);
236
+ * 3. fuzzy (Damerau-Levenshtein) match against a subtoken — "sorce"
237
+ * still finds `scoreNodes`.
238
+ *
239
+ * Still fully lexical and deterministic — no LLM, no randomness.
240
+ */
190
241
  declare function scoreNodes(graph: Graph, query: string): NodeMatch[];
191
242
  /**
192
243
  * Best-effort single-node resolution: the highest-scoring lexical match,
@@ -216,8 +267,10 @@ interface QueryResult {
216
267
  }
217
268
  interface QueryOptions {
218
269
  dfs?: boolean;
219
- /** Approximate cap on how many nodes to include (a stand-in for a true token budget see docs). */
270
+ /** Hard cap on how many nodes to include. Defaults to a generous ceiling derived from `tokenBudget`. */
220
271
  budget?: number;
272
+ /** Approximate cap, in tokens (~4 chars each), on the serialized size of the result. Default 2000. */
273
+ tokenBudget?: number;
221
274
  maxDepth?: number;
222
275
  maxSeeds?: number;
223
276
  }
@@ -251,6 +304,291 @@ interface ExplainResult {
251
304
  /** Plain-language-ready explanation of the node that best matches `query`. */
252
305
  declare function explainNode(graph: Graph, query: string): ExplainResult | null;
253
306
 
307
+ interface AffectedNode {
308
+ id: string;
309
+ label: string;
310
+ sourceFile: string;
311
+ sourceLocation: string;
312
+ /** 1 = depends on the target directly, 2 = one step removed, ... */
313
+ depth: number;
314
+ /** The dependency edge that pulled this node in (this node -> something already affected). */
315
+ via: {
316
+ relation: Relation;
317
+ confidence: Confidence;
318
+ /** The already-affected node this one depends on. */
319
+ dependsOn: string;
320
+ };
321
+ }
322
+ interface ImpactResult {
323
+ target: NodeMatch;
324
+ affected: AffectedNode[];
325
+ /** How many affected nodes there are per confidence tier of the edge that pulled each one in. */
326
+ byConfidence: Record<Confidence, number>;
327
+ maxDepth: number;
328
+ truncated: boolean;
329
+ }
330
+ interface ImpactOptions {
331
+ /** How many reverse hops to follow (default 3). */
332
+ maxDepth?: number;
333
+ /** Hard cap on affected nodes reported (default 200). */
334
+ limit?: number;
335
+ }
336
+ /**
337
+ * Resolve `nodeQuery` to its best-matching node, then reverse-BFS over
338
+ * incoming dependency edges. Returns null when nothing in the graph matches
339
+ * the query at all (same contract as explainNode()).
340
+ */
341
+ declare function affectedBy(graph: Graph, nodeQuery: string, options?: ImpactOptions): ImpactResult | null;
342
+
343
+ /**
344
+ * Cross-project graph merging — combine several already-built graphs (one
345
+ * per repo/project) into a single queryable global graph.
346
+ *
347
+ * Node ids inside one project are relative-path-based (`src/a.ts::foo`), so
348
+ * two projects' ids would collide. Every node id and sourceFile is therefore
349
+ * prefixed with `<project>/` — EXCEPT `module:*` (bare package imports) and
350
+ * `external:*` (unresolved reference targets) nodes, which deliberately stay
351
+ * unprefixed and shared: two repos importing the same package meet at the
352
+ * same node, so cross-repo bridges emerge in the merged graph instead of
353
+ * each repo floating as a disconnected island.
354
+ */
355
+ interface MergeEntry {
356
+ /** Project name used as the id/sourceFile namespace prefix. */
357
+ name: string;
358
+ graph: Graph;
359
+ }
360
+ /**
361
+ * Merge the entries into one directed graph, applying the same
362
+ * deterministic-ordering and duplicate-edge rules as buildGraph(): entries
363
+ * are processed in name order, duplicate edges (possible via shared
364
+ * external nodes) keep the strongest confidence, and every node carries a
365
+ * `project` attribute (shared external nodes get `project: '(shared)'`
366
+ * once more than one project touches them).
367
+ */
368
+ declare function mergeGraphs(entries: MergeEntry[]): Graph;
369
+
370
+ /**
371
+ * MySQL schema extraction — introspect information_schema over a
372
+ * user-supplied DSN and map the schema onto the same ExtractionResult shape
373
+ * every code extractor produces, so tables/columns/foreign keys land in the
374
+ * same graph as the code that talks to them.
375
+ *
376
+ * Only ever surfaces the credential-free DSN rendering (see
377
+ * security.validateDsn()) in node attributes; the password never leaves the
378
+ * connection config.
379
+ */
380
+ /** One `SELECT` against the live connection. Injectable so tests run on canned rows, mirroring security.ts's LookupFn pattern. */
381
+ type DsnQueryFn = (sql: string, params: ReadonlyArray<string>) => Promise<Array<Record<string, unknown>>>;
382
+ /**
383
+ * Extract the schema graph for the database named in `dsn`.
384
+ *
385
+ * Nodes: the schema, every table/view, every column.
386
+ * Edges: schema `contains` table, table `contains` column, FK column
387
+ * `references` the referenced table (EXTRACTED), view `references` its base
388
+ * tables (EXTRACTED via VIEW_TABLE_USAGE on MySQL >= 8.0.13, INFERRED via a
389
+ * VIEW_DEFINITION text match on older servers).
390
+ */
391
+ declare function extractMysql(dsn: string, queryFn?: DsnQueryFn): Promise<ExtractionResult>;
392
+
393
+ /**
394
+ * The graph feedback loop — `graphify save-result` logs which graph nodes
395
+ * actually helped answer a question (or led nowhere), and `graphify
396
+ * reflect` aggregates those logs into a recency-weighted LESSONS.md an
397
+ * agent can read at session start. This is what turns the graph from a
398
+ * static map into one that learns which landmarks matter.
399
+ */
400
+ type ResultOutcome = 'useful' | 'dead_end' | 'corrected';
401
+ type ResultType = 'query' | 'path' | 'explain' | 'affected';
402
+ interface SavedResult {
403
+ question: string;
404
+ answer: string;
405
+ type: ResultType;
406
+ outcome: ResultOutcome;
407
+ /** Node ids/labels cited in the answer. */
408
+ nodes: string[];
409
+ /** What the right answer was — pairs with outcome 'corrected'. */
410
+ correction?: string;
411
+ savedAt: string;
412
+ }
413
+ /** Append one result to `<memoryDir>/result-<stamp>-<hash>.json`. Returns the file path. */
414
+ declare function saveResult(entry: Omit<SavedResult, 'savedAt'>, memoryDir: string, now?: Date): Promise<string>;
415
+ interface ReflectOptions {
416
+ /** A result's weight halves every this many days (default 30). */
417
+ halfLifeDays?: number;
418
+ /** Injectable clock for deterministic tests. */
419
+ now?: Date;
420
+ /** Only list nodes cited usefully at least this many distinct times (default 2). */
421
+ minCorroboration?: number;
422
+ }
423
+ /** Read every result JSON under `memoryDir` (missing dir = no results). */
424
+ declare function loadResults(memoryDir: string): Promise<SavedResult[]>;
425
+ /** Aggregate saved results into a LESSONS.md string. Deterministic given results + options.now. */
426
+ declare function renderLessons(results: SavedResult[], options?: ReflectOptions): string;
427
+
428
+ /**
429
+ * `graphify context` — the working-set pack. Instead of returning node
430
+ * names for the agent to chase (query -> then read whole files anyway),
431
+ * this fuses the two steps: the graph knows every entity's file and line,
432
+ * so rank the nodes relevant to the task, read just the relevant line
433
+ * ranges, and pack the actual code into a token budget. One call returns
434
+ * the code an agent needs to start a task.
435
+ */
436
+ interface ContextSnippet {
437
+ nodeId: string;
438
+ label: string;
439
+ file: string;
440
+ startLine: number;
441
+ endLine: number;
442
+ code: string;
443
+ /** Why this snippet is in the pack (lexical match / neighbor-of relation). */
444
+ reason: string;
445
+ score: number;
446
+ }
447
+ interface ContextPack {
448
+ task: string;
449
+ snippets: ContextSnippet[];
450
+ /** Approximate tokens used out of the budget. */
451
+ tokens: number;
452
+ tokenBudget: number;
453
+ /** Ranked nodes that didn't fit the budget — the agent can pull them explicitly. */
454
+ overflow: Array<{
455
+ nodeId: string;
456
+ file: string;
457
+ }>;
458
+ }
459
+ interface ContextOptions {
460
+ /** Approximate token cap for the pack (default 4000). */
461
+ tokenBudget?: number;
462
+ maxSeeds?: number;
463
+ /** Cap on snippet length in lines (default 60). */
464
+ maxSnippetLines?: number;
465
+ }
466
+ type FileReader = (path: string) => Promise<string>;
467
+ interface RankedNode {
468
+ id: string;
469
+ score: number;
470
+ reason: string;
471
+ }
472
+ /**
473
+ * Rank nodes for the task: lexical seeds first (their match score), then
474
+ * their graph neighbors at a decayed score, labeled with the relation that
475
+ * connects them. Deterministic (score desc, id asc).
476
+ */
477
+ declare function rankForTask(graph: Graph, task: string, maxSeeds?: number): RankedNode[];
478
+ declare function buildContextPack(graph: Graph, task: string, readFile: FileReader, options?: ContextOptions): Promise<ContextPack>;
479
+ /** Render a pack as agent-ready markdown. */
480
+ declare function renderContextPack(pack: ContextPack): string;
481
+
482
+ declare function isTestFile(path: string): boolean;
483
+ interface TestSelection {
484
+ /** What the selection was computed for (resolved node id, or the changed files). */
485
+ target: string;
486
+ /** Unique test files, most directly affected first. */
487
+ testFiles: Array<{
488
+ file: string;
489
+ depth: number;
490
+ via: string;
491
+ }>;
492
+ /** How many non-test nodes were in the blast radius (context for confidence). */
493
+ affectedNodeCount: number;
494
+ }
495
+ /** Test files reachable (reverse) from the node best matching `nodeQuery`. Null if nothing matches. */
496
+ declare function testsForNode(graph: Graph, nodeQuery: string): TestSelection | null;
497
+ /**
498
+ * Test files reachable from any of the given changed files (as reported by
499
+ * `git diff --name-only`). Changed test files select themselves. Unknown
500
+ * files (not in the graph) are skipped.
501
+ */
502
+ declare function testsForChangedFiles(graph: Graph, changedFiles: string[]): TestSelection;
503
+
504
+ /**
505
+ * `graphify review` — semantic graph diff between two git revisions.
506
+ * Full structural extraction is sub-second, so instead of parsing text
507
+ * diffs we build the graph at both revs and diff the graphs: which symbols
508
+ * appeared, disappeared, or got rewired — each with its blast radius and
509
+ * the test files worth running. One command answers both "what changed
510
+ * since I was last here" and "review this PR structurally".
511
+ */
512
+ interface ChangedSymbol {
513
+ id: string;
514
+ label: string;
515
+ sourceFile: string;
516
+ /** Everything that depends on this symbol (computed in the graph where it exists). */
517
+ blastRadius: number;
518
+ /** Test files reachable from it. */
519
+ tests: string[];
520
+ }
521
+ interface RewiredSymbol extends ChangedSymbol {
522
+ gainedEdges: string[];
523
+ lostEdges: string[];
524
+ }
525
+ interface GraphDiff {
526
+ base: string;
527
+ head: string;
528
+ added: ChangedSymbol[];
529
+ removed: ChangedSymbol[];
530
+ rewired: RewiredSymbol[];
531
+ }
532
+ /** Extract a graph from a directory without exporting anything. */
533
+ declare function graphForDirectory(root: string): Promise<Graph>;
534
+ declare function diffGraphs(baseGraph: Graph, headGraph: Graph, base: string, head: string): GraphDiff;
535
+ /** Diff two revisions of the repo at `repoRoot`. `head` defaults to the working head via a second archive of HEAD. */
536
+ declare function reviewRevisions(repoRoot: string, base: string, head?: string): Promise<GraphDiff>;
537
+ declare function renderReview(diff: GraphDiff): string;
538
+
539
+ /**
540
+ * `graphify check` — architecture guardrails. Declarative dependency rules
541
+ * checked against the graph's edges, with CI-friendly results: an agent (or
542
+ * a pipeline) can verify an edit didn't violate the architecture before it
543
+ * lands. Cross-language, because the graph already is.
544
+ */
545
+ interface DependencyRule {
546
+ /** Short rule name shown in violations. */
547
+ name: string;
548
+ /** Glob over the depending file (edge source's sourceFile). */
549
+ from: string;
550
+ /** Globs the dependency target's file must NOT match. */
551
+ disallow: string[];
552
+ /** Optional human explanation echoed with violations. */
553
+ reason?: string;
554
+ }
555
+ interface RulesConfig {
556
+ rules: DependencyRule[];
557
+ }
558
+ interface Violation {
559
+ rule: string;
560
+ reason?: string;
561
+ fromFile: string;
562
+ fromNode: string;
563
+ toFile: string;
564
+ toNode: string;
565
+ relation: string;
566
+ }
567
+ /**
568
+ * Minimal glob: `**` crosses directories, `*` stays within one segment.
569
+ * Anchored on both ends. Enough for path rules without a dependency.
570
+ */
571
+ declare function globToRegExp(glob: string): RegExp;
572
+ declare function validateRules(value: unknown): RulesConfig;
573
+ /** Check every dependency edge in the graph against the rules. Deterministic ordering. */
574
+ declare function checkRules(graph: Graph, config: RulesConfig): Violation[];
575
+
576
+ /**
577
+ * Per-file extraction cache — what makes `--update` (and every rebuild
578
+ * behind a git hook or --watch) incremental. Entries are keyed by a hash of
579
+ * the file's CONTENT plus its root-relative path: content because that's
580
+ * what extraction depends on, path because node ids embed it. A stale entry
581
+ * for content that changed simply never matches again; the whole directory
582
+ * can always be deleted safely.
583
+ */
584
+ declare class ExtractionCache {
585
+ private readonly dir;
586
+ constructor(outDir: string);
587
+ key(relFile: string, content: Buffer | string): string;
588
+ get(key: string): Promise<ExtractionResult | null>;
589
+ put(key: string, extraction: ExtractionResult): Promise<void>;
590
+ }
591
+
254
592
  declare const ExtractionResultSchema: z.ZodObject<{
255
593
  nodes: z.ZodArray<z.ZodObject<{
256
594
  id: z.ZodString;
@@ -298,4 +636,4 @@ declare class ExtractionValidationError extends Error {
298
636
  */
299
637
  declare function validateExtraction(value: unknown, context?: string): ExtractionResult;
300
638
 
301
- export { type Analysis, type ClusterAlgorithm, type ClusterOptions, type Confidence, EXTRACTOR_REGISTRY, type ExplainResult, type ExportOptions, type ExtractionResult, ExtractionResultSchema, ExtractionValidationError, type ExtractionValidationIssue, type FileCategory, type FileManifest, type GraphEdge, type GraphNode, type NodeMatch, type PathResult, type PipelineOptions, type PipelineResult, type QueryOptions, type QueryResult, type Relation, type TraversalEdge, type VisitedNode, analyze, buildGraph, cluster, collectFiles, explainNode, exportGraph, extract, loadGraph, queryGraph, renderReport, resolveNode, runPipeline, scoreNodes, shortestPath, validateExtraction };
639
+ export { type AffectedNode, type Analysis, type ChangedSymbol, type ClusterAlgorithm, type ClusterOptions, type Confidence, type ContextOptions, type ContextPack, type ContextSnippet, type DependencyRule, type DsnQueryFn, EXTRACTOR_REGISTRY, type ExplainResult, type ExportOptions, ExtractionCache, type ExtractionResult, ExtractionResultSchema, ExtractionValidationError, type ExtractionValidationIssue, type FileCategory, type FileManifest, type GraphDiff, type GraphEdge, type GraphNode, type ImpactOptions, type ImpactResult, type MergeEntry, type NodeMatch, type PathResult, type PipelineOptions, type PipelineResult, type QueryOptions, type QueryResult, type ReflectOptions, type Relation, type ResolveStats, type ResultOutcome, type ResultType, type RewiredSymbol, type RulesConfig, type SavedResult, type TestSelection, type TraversalEdge, type Violation, type VisitedNode, affectedBy, analyze, buildContextPack, buildGraph, checkRules, cluster, collectFiles, diffGraphs, explainNode, exportGraph, extract, extractMysql, globToRegExp, graphForDirectory, isTestFile, loadGraph, loadResults, mergeGraphs, queryGraph, rankForTask, renderContextPack, renderLessons, renderReport, renderReview, resolveCrossFileReferences, resolveNode, reviewRevisions, runPipeline, saveResult, scoreNodes, shortestPath, testsForChangedFiles, testsForNode, validateExtraction, validateRules };