@gmickel/gno 1.9.0 → 1.10.0

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.
Files changed (36) hide show
  1. package/assets/skill/SKILL.md +28 -16
  2. package/assets/skill/cli-reference.md +30 -0
  3. package/assets/skill/examples.md +20 -0
  4. package/assets/skill/mcp-reference.md +7 -1
  5. package/package.json +1 -1
  6. package/src/cli/commands/graph.ts +89 -2
  7. package/src/cli/commands/links.ts +237 -54
  8. package/src/cli/commands/query.ts +212 -0
  9. package/src/cli/commands/ref-parser.ts +8 -103
  10. package/src/cli/options.ts +4 -0
  11. package/src/cli/program.ts +176 -9
  12. package/src/config/content-types.ts +14 -0
  13. package/src/core/graph-query.ts +137 -0
  14. package/src/core/graph-resolver.ts +117 -0
  15. package/src/core/ref-parser.ts +145 -0
  16. package/src/ingestion/frontmatter.ts +95 -2
  17. package/src/ingestion/sync.ts +281 -1
  18. package/src/mcp/tools/get.ts +1 -1
  19. package/src/mcp/tools/index.ts +89 -1
  20. package/src/mcp/tools/links.ts +83 -1
  21. package/src/mcp/tools/multi-get.ts +1 -1
  22. package/src/mcp/tools/query.ts +207 -0
  23. package/src/pipeline/diagnose.ts +302 -0
  24. package/src/pipeline/filters.ts +119 -0
  25. package/src/pipeline/hybrid.ts +90 -17
  26. package/src/pipeline/types.ts +32 -0
  27. package/src/publish/export-service.ts +1 -1
  28. package/src/sdk/documents.ts +2 -2
  29. package/src/serve/routes/api.ts +194 -0
  30. package/src/serve/routes/graph.ts +173 -1
  31. package/src/serve/server.ts +24 -1
  32. package/src/store/migrations/010-typed-edges.ts +67 -0
  33. package/src/store/migrations/011-doc-edge-traversal-indexes.ts +30 -0
  34. package/src/store/migrations/index.ts +4 -0
  35. package/src/store/sqlite/adapter.ts +826 -102
  36. package/src/store/types.ts +141 -0
@@ -1,108 +1,13 @@
1
1
  /**
2
- * Reference parser for document refs.
3
- * Pure lexical parsing - NO store/config access.
2
+ * CLI compatibility re-export for document ref parsing.
4
3
  *
5
4
  * @module src/cli/commands/ref-parser
6
5
  */
7
6
 
8
- // ─────────────────────────────────────────────────────────────────────────────
9
- // Types
10
- // ─────────────────────────────────────────────────────────────────────────────
11
-
12
- export type RefType = "docid" | "uri" | "collPath";
13
-
14
- export interface ParsedRef {
15
- type: RefType;
16
- /** Normalized ref (without :line suffix) */
17
- value: string;
18
- /** For collPath type */
19
- collection?: string;
20
- /** For collPath type */
21
- relPath?: string;
22
- /** Parsed :line suffix (1-indexed) */
23
- line?: number;
24
- }
25
-
26
- export type ParseRefResult = ParsedRef | { error: string };
27
-
28
- // ─────────────────────────────────────────────────────────────────────────────
29
- // Top-level regex patterns (perf: avoid recreating in functions)
30
- // ─────────────────────────────────────────────────────────────────────────────
31
-
32
- const DOCID_PATTERN = /^#[a-f0-9]{6,}$/;
33
- const LINE_SUFFIX_PATTERN = /:(\d+)$/;
34
- const GLOB_PATTERN = /[*?[\]]/;
35
-
36
- // ─────────────────────────────────────────────────────────────────────────────
37
- // Parser Functions
38
- // ─────────────────────────────────────────────────────────────────────────────
39
-
40
- /**
41
- * Parse a single ref string.
42
- * - Docid: starts with # (no :line suffix allowed)
43
- * - URI: starts with gno:// (optional :N suffix)
44
- * - Else: collection/path (optional :N suffix)
45
- */
46
- export function parseRef(ref: string): ParseRefResult {
47
- // 1. DocID: starts with #, validate pattern
48
- if (ref.startsWith("#")) {
49
- if (ref.includes(":")) {
50
- return { error: "Docid refs cannot have :line suffix" };
51
- }
52
- if (!DOCID_PATTERN.test(ref)) {
53
- return { error: `Invalid docid format: ${ref}` };
54
- }
55
- return { type: "docid", value: ref };
56
- }
57
-
58
- // 2. Parse optional :line suffix for URI and collPath
59
- let line: number | undefined;
60
- let baseRef = ref;
61
- const lineMatch = ref.match(LINE_SUFFIX_PATTERN);
62
- if (lineMatch?.[1]) {
63
- const parsed = Number.parseInt(lineMatch[1], 10);
64
- if (!Number.isInteger(parsed) || parsed < 1) {
65
- return { error: `Invalid line suffix (must be >= 1): ${ref}` };
66
- }
67
- line = parsed;
68
- baseRef = ref.slice(0, -lineMatch[0].length);
69
- }
70
-
71
- // 3. URI: starts with gno://
72
- if (baseRef.startsWith("gno://")) {
73
- return { type: "uri", value: baseRef, line };
74
- }
75
-
76
- // 4. Collection/path: must contain /
77
- const slashIdx = baseRef.indexOf("/");
78
- if (slashIdx === -1) {
79
- return { error: `Invalid ref format (missing /): ${ref}` };
80
- }
81
- const collection = baseRef.slice(0, slashIdx);
82
- const relPath = baseRef.slice(slashIdx + 1);
83
-
84
- return { type: "collPath", value: baseRef, collection, relPath, line };
85
- }
86
-
87
- /**
88
- * Split comma-separated refs. Does NOT expand globs.
89
- */
90
- export function splitRefs(refs: string[]): string[] {
91
- const result: string[] = [];
92
- for (const r of refs) {
93
- for (const part of r.split(",")) {
94
- const trimmed = part.trim();
95
- if (trimmed) {
96
- result.push(trimmed);
97
- }
98
- }
99
- }
100
- return result;
101
- }
102
-
103
- /**
104
- * Check if a ref contains glob characters.
105
- */
106
- export function isGlobPattern(ref: string): boolean {
107
- return GLOB_PATTERN.test(ref);
108
- }
7
+ export type { ParsedRef, ParseRefResult, RefType } from "../../core/ref-parser";
8
+ export {
9
+ isGlobPattern,
10
+ parseRef,
11
+ resolveDocRef,
12
+ splitRefs,
13
+ } from "../../core/ref-parser";
@@ -22,6 +22,7 @@ export const CMD = {
22
22
  search: "search",
23
23
  vsearch: "vsearch",
24
24
  query: "query",
25
+ queryDiagnose: "query.diagnose",
25
26
  bench: "bench",
26
27
  ask: "ask",
27
28
  get: "get",
@@ -38,6 +39,7 @@ export const CMD = {
38
39
  backlinks: "backlinks",
39
40
  similar: "similar",
40
41
  graph: "graph",
42
+ graphQuery: "graph.query",
41
43
  capture: "capture",
42
44
  } as const;
43
45
 
@@ -47,6 +49,7 @@ const FORMAT_SUPPORT: Record<CommandId, OutputFormat[]> = {
47
49
  [CMD.search]: ["terminal", "json", "files", "csv", "md", "xml"],
48
50
  [CMD.vsearch]: ["terminal", "json", "files", "csv", "md", "xml"],
49
51
  [CMD.query]: ["terminal", "json", "files", "csv", "md", "xml"],
52
+ [CMD.queryDiagnose]: ["terminal", "json"],
50
53
  [CMD.bench]: ["terminal", "json"],
51
54
  [CMD.ask]: ["terminal", "json", "md"],
52
55
  [CMD.get]: ["terminal", "json", "md"],
@@ -64,6 +67,7 @@ const FORMAT_SUPPORT: Record<CommandId, OutputFormat[]> = {
64
67
  [CMD.similar]: ["terminal", "json", "md"],
65
68
  // graph uses custom --dot/--mermaid flags (not OutputFormat) and writes via terminal output
66
69
  [CMD.graph]: ["json", "terminal"],
70
+ [CMD.graphQuery]: ["terminal", "json"],
67
71
  [CMD.capture]: ["terminal", "json"],
68
72
  };
69
73
 
@@ -508,7 +508,7 @@ function wireSearchCommands(program: Command): void {
508
508
 
509
509
  // query - Hybrid search with expansion and reranking
510
510
  program
511
- .command("query <query>")
511
+ .command("query <query...>")
512
512
  .description("Hybrid search with expansion and reranking")
513
513
  .option("-n, --limit <num>", "max results")
514
514
  .option("--min-score <num>", "minimum score threshold")
@@ -542,6 +542,7 @@ function wireSearchCommands(program: Command): void {
542
542
  .option("--no-rerank", "disable reranking")
543
543
  .option("--graph", "enable graph neighbor expansion")
544
544
  .option("--no-graph", "compatibility no-op; graph is off by default")
545
+ .option("--target <doc>", "diagnose target document ref")
545
546
  .option(
546
547
  "--query-mode <mode:text>",
547
548
  "structured mode entry (repeatable): term:<text>, intent:<text>, or hyde:<text>",
@@ -555,10 +556,16 @@ function wireSearchCommands(program: Command): void {
555
556
  .option("--csv", "CSV output")
556
557
  .option("--xml", "XML output")
557
558
  .option("--files", "file paths only")
558
- .action(async (queryText: string, cmdOpts: Record<string, unknown>) => {
559
+ .action(async (queryParts: string[], cmdOpts: Record<string, unknown>) => {
559
560
  const format = getFormat(cmdOpts);
560
- assertFormatSupported(CMD.query, format);
561
+ const isDiagnose =
562
+ queryParts[0] === "diagnose" && Boolean(cmdOpts.target);
563
+ assertFormatSupported(isDiagnose ? CMD.queryDiagnose : CMD.query, format);
564
+ const diagnoseFormat = format === "json" ? "json" : "terminal";
561
565
  const globals = getGlobals();
566
+ let queryText = isDiagnose
567
+ ? queryParts.slice(1).join(" ")
568
+ : queryParts.join(" ");
562
569
 
563
570
  // Validate empty query
564
571
  if (!queryText.trim()) {
@@ -638,6 +645,44 @@ function wireSearchCommands(program: Command): void {
638
645
  candidateLimit,
639
646
  });
640
647
 
648
+ if (isDiagnose) {
649
+ const { queryDiagnose, formatQueryDiagnose } =
650
+ await import("./commands/query");
651
+ const result = await queryDiagnose(queryText, {
652
+ configPath: globals.config,
653
+ indexName: globals.index,
654
+ target: cmdOpts.target as string,
655
+ limit,
656
+ minScore,
657
+ collection: cmdOpts.collection as string | undefined,
658
+ lang: cmdOpts.lang as string | undefined,
659
+ since: cmdOpts.since as string | undefined,
660
+ until: cmdOpts.until as string | undefined,
661
+ categories,
662
+ author: cmdOpts.author as string | undefined,
663
+ intent: cmdOpts.intent as string | undefined,
664
+ exclude,
665
+ tagsAll,
666
+ tagsAny,
667
+ noExpand: depthPolicy.noExpand,
668
+ noRerank: depthPolicy.noRerank,
669
+ graph: Boolean(cmdOpts.graph),
670
+ noGraph: Boolean(cmdOpts.fast) || cmdOpts.graph === false,
671
+ candidateLimit: depthPolicy.candidateLimit,
672
+ queryModes,
673
+ json: diagnoseFormat === "json",
674
+ });
675
+
676
+ if (!result.success) {
677
+ throw new CliError("RUNTIME", result.error);
678
+ }
679
+ await writeOutput(
680
+ formatQueryDiagnose(result, { format: diagnoseFormat }),
681
+ diagnoseFormat
682
+ );
683
+ return;
684
+ }
685
+
641
686
  const { query, formatQuery } = await import("./commands/query");
642
687
  const result = await query(queryText, {
643
688
  configPath: globals.config,
@@ -2153,6 +2198,8 @@ function wireLinksCommands(program: Command): void {
2153
2198
  .command("list <doc>", { isDefault: true })
2154
2199
  .description("List outgoing links from a document")
2155
2200
  .option("--type <type>", "filter by link type (wiki, markdown)")
2201
+ .option("--edge-type <type>", "filter by semantic edge type")
2202
+ .option("--relation <type>", "alias for --edge-type")
2156
2203
  .option("--json", "JSON output")
2157
2204
  .option("--md", "Markdown output")
2158
2205
  .action(async (doc: string, cmdOpts: Record<string, unknown>) => {
@@ -2168,11 +2215,27 @@ function wireLinksCommands(program: Command): void {
2168
2215
  `Invalid link type: ${linkType}. Must be 'wiki' or 'markdown'.`
2169
2216
  );
2170
2217
  }
2218
+ const edgeType = cmdOpts.edgeType as string | undefined;
2219
+ const relation = cmdOpts.relation as string | undefined;
2220
+ if (linkType && (edgeType || relation)) {
2221
+ throw new CliError(
2222
+ "VALIDATION",
2223
+ "--type cannot be combined with --edge-type or --relation"
2224
+ );
2225
+ }
2226
+ if (edgeType && relation && edgeType !== relation) {
2227
+ throw new CliError(
2228
+ "VALIDATION",
2229
+ "--edge-type and --relation are aliases and must match when both are provided"
2230
+ );
2231
+ }
2171
2232
 
2172
2233
  const { linksList, formatLinksList } = await import("./commands/links");
2173
2234
  const result = await linksList(doc, {
2174
2235
  configPath: globals.config,
2175
2236
  type: linkType as "wiki" | "markdown" | undefined,
2237
+ edgeType,
2238
+ relation,
2176
2239
  json: format === "json",
2177
2240
  md: format === "md",
2178
2241
  });
@@ -2196,6 +2259,8 @@ function wireLinksCommands(program: Command): void {
2196
2259
  .command("backlinks <doc>")
2197
2260
  .description("List documents linking to this document")
2198
2261
  .option("-c, --collection <name>", "filter by collection")
2262
+ .option("--edge-type <type>", "filter by semantic edge type")
2263
+ .option("--relation <type>", "alias for --edge-type")
2199
2264
  .option("--json", "JSON output")
2200
2265
  .option("--md", "Markdown output")
2201
2266
  .action(async (doc: string, cmdOpts: Record<string, unknown>) => {
@@ -2204,9 +2269,19 @@ function wireLinksCommands(program: Command): void {
2204
2269
  const globals = getGlobals();
2205
2270
 
2206
2271
  const { backlinks, formatBacklinks } = await import("./commands/links");
2272
+ const edgeType = cmdOpts.edgeType as string | undefined;
2273
+ const relation = cmdOpts.relation as string | undefined;
2274
+ if (edgeType && relation && edgeType !== relation) {
2275
+ throw new CliError(
2276
+ "VALIDATION",
2277
+ "--edge-type and --relation are aliases and must match when both are provided"
2278
+ );
2279
+ }
2207
2280
  const result = await backlinks(doc, {
2208
2281
  configPath: globals.config,
2209
2282
  collection: cmdOpts.collection as string | undefined,
2283
+ edgeType,
2284
+ relation,
2210
2285
  json: format === "json",
2211
2286
  md: format === "md",
2212
2287
  });
@@ -2278,7 +2353,7 @@ function wireLinksCommands(program: Command): void {
2278
2353
  // ─────────────────────────────────────────────────────────────────────────────
2279
2354
 
2280
2355
  function wireGraphCommand(program: Command): void {
2281
- program
2356
+ const graphCmd = program
2282
2357
  .command("graph")
2283
2358
  .description("Output knowledge graph of document links")
2284
2359
  .option("-c, --collection <name>", "filter by collection")
@@ -2289,7 +2364,7 @@ function wireGraphCommand(program: Command): void {
2289
2364
  .option("--include-isolated", "include nodes with no links")
2290
2365
  .option("--similar-top-k <n>", "similar docs per node (default 5)")
2291
2366
  .option("--neighbors <ref>", "show graph neighbors for document/node ref")
2292
- .option("--direction <dir>", "neighbor direction: both, out, in", "both")
2367
+ .option("--direction <dir>", "neighbor direction: both, out, in")
2293
2368
  .option("--from <ref>", "path start document/node ref")
2294
2369
  .option("--to <ref>", "path target document/node ref")
2295
2370
  .option("--max-depth <n>", "max path hops (default 6)")
@@ -2332,10 +2407,11 @@ function wireGraphCommand(program: Command): void {
2332
2407
  const maxDepth = cmdOpts.maxDepth
2333
2408
  ? parsePositiveInt("max-depth", cmdOpts.maxDepth)
2334
2409
  : undefined;
2410
+ const graphDirection = (cmdOpts.direction ?? "both") as string;
2335
2411
  if (
2336
- cmdOpts.direction !== "both" &&
2337
- cmdOpts.direction !== "out" &&
2338
- cmdOpts.direction !== "in"
2412
+ graphDirection !== "both" &&
2413
+ graphDirection !== "out" &&
2414
+ graphDirection !== "in"
2339
2415
  ) {
2340
2416
  throw new CliError(
2341
2417
  "VALIDATION",
@@ -2352,6 +2428,7 @@ function wireGraphCommand(program: Command): void {
2352
2428
  const { graph, formatGraph } = await import("./commands/graph.js");
2353
2429
  const result = await graph({
2354
2430
  configPath: globals.config,
2431
+ indexName: globals.index,
2355
2432
  collection: cmdOpts.collection as string | undefined,
2356
2433
  limitNodes,
2357
2434
  limitEdges,
@@ -2361,7 +2438,7 @@ function wireGraphCommand(program: Command): void {
2361
2438
  similarTopK,
2362
2439
  format,
2363
2440
  neighbors: cmdOpts.neighbors as string | undefined,
2364
- direction: cmdOpts.direction as "both" | "out" | "in",
2441
+ direction: graphDirection as "both" | "out" | "in",
2365
2442
  from: cmdOpts.from as string | undefined,
2366
2443
  to: cmdOpts.to as string | undefined,
2367
2444
  maxDepth,
@@ -2385,6 +2462,96 @@ function wireGraphCommand(program: Command): void {
2385
2462
 
2386
2463
  await writeOutput(output, format === "json" ? "json" : "terminal");
2387
2464
  });
2465
+
2466
+ graphCmd
2467
+ .command("query <doc>")
2468
+ .description("Run bounded typed-edge traversal from a document")
2469
+ .option("--direction <dir>", "direction: both, out, in")
2470
+ .option("--edge-type <type>", "filter by typed edge type")
2471
+ .option("--max-depth <n>", "max traversal depth (default 2)")
2472
+ .option("--max-nodes <n>", "max returned nodes (default 100)")
2473
+ .option(
2474
+ "--frontier-limit <n>",
2475
+ "max frontier width per depth (default 100)"
2476
+ )
2477
+ .option(
2478
+ "--visited-limit <n>",
2479
+ "max visited rows during traversal (default 500)"
2480
+ )
2481
+ .option("--json", "JSON output")
2482
+ .action(
2483
+ async (
2484
+ doc: string,
2485
+ cmdOpts: Record<string, unknown>,
2486
+ command: Command
2487
+ ) => {
2488
+ const globals = getGlobals();
2489
+ const format = getFormat(cmdOpts);
2490
+ assertFormatSupported(CMD.graphQuery, format);
2491
+ if (format !== "terminal" && format !== "json") {
2492
+ throw new CliError(
2493
+ "VALIDATION",
2494
+ "gno graph query supports terminal and json output"
2495
+ );
2496
+ }
2497
+ const argv = resolveCliArgv(command);
2498
+ const directionFlagIndex = argv.lastIndexOf("--direction");
2499
+ const rawDirection =
2500
+ directionFlagIndex >= 0 ? argv[directionFlagIndex + 1] : undefined;
2501
+ const direction = (rawDirection ??
2502
+ cmdOpts.direction ??
2503
+ "both") as string;
2504
+ if (direction !== "both" && direction !== "out" && direction !== "in") {
2505
+ throw new CliError(
2506
+ "VALIDATION",
2507
+ "--direction must be one of: both, out, in"
2508
+ );
2509
+ }
2510
+
2511
+ const maxDepth = cmdOpts.maxDepth
2512
+ ? parsePositiveInt("max-depth", cmdOpts.maxDepth)
2513
+ : undefined;
2514
+ const maxNodes = cmdOpts.maxNodes
2515
+ ? parsePositiveInt("max-nodes", cmdOpts.maxNodes)
2516
+ : undefined;
2517
+ const frontierLimit = cmdOpts.frontierLimit
2518
+ ? parsePositiveInt("frontier-limit", cmdOpts.frontierLimit)
2519
+ : undefined;
2520
+ const visitedLimit = cmdOpts.visitedLimit
2521
+ ? parsePositiveInt("visited-limit", cmdOpts.visitedLimit)
2522
+ : undefined;
2523
+
2524
+ const { graphQuery, formatGraphQuery } =
2525
+ await import("./commands/graph.js");
2526
+ const result = await graphQuery(doc, {
2527
+ configPath: globals.config,
2528
+ indexName: globals.index,
2529
+ direction: direction as "both" | "out" | "in",
2530
+ edgeType: cmdOpts.edgeType as string | undefined,
2531
+ maxDepth,
2532
+ maxNodes,
2533
+ frontierLimit,
2534
+ visitedLimit,
2535
+ format,
2536
+ });
2537
+
2538
+ if (!result.success) {
2539
+ throw new CliError(
2540
+ result.isValidation ? "VALIDATION" : "RUNTIME",
2541
+ result.error
2542
+ );
2543
+ }
2544
+
2545
+ const output = formatGraphQuery(result, { format });
2546
+ if (result.data.meta.truncated) {
2547
+ for (const warning of result.data.meta.warnings) {
2548
+ console.error(`Warning: ${warning}`);
2549
+ }
2550
+ }
2551
+
2552
+ await writeOutput(output, format);
2553
+ }
2554
+ );
2388
2555
  }
2389
2556
 
2390
2557
  // ─────────────────────────────────────────────────────────────────────────────
@@ -37,6 +37,13 @@ const NOTE_PRESET_IDS = new Set<NotePresetId>(
37
37
  NOTE_PRESETS.map((preset) => preset.id)
38
38
  );
39
39
 
40
+ function normalizeGraphHint(value: string): string {
41
+ return value
42
+ .trim()
43
+ .toLowerCase()
44
+ .replace(/[-\s]+/g, "_");
45
+ }
46
+
40
47
  function isNotePresetId(value: string): value is NotePresetId {
41
48
  return NOTE_PRESET_IDS.has(value as NotePresetId);
42
49
  }
@@ -79,6 +86,9 @@ export function normalizeContentTypes(
79
86
  ...contentType,
80
87
  preset: contentType.preset,
81
88
  prefixes,
89
+ graphHints: contentType.graphHints
90
+ ? contentType.graphHints.map(normalizeGraphHint).filter(Boolean)
91
+ : undefined,
82
92
  });
83
93
  continue;
84
94
  }
@@ -87,6 +97,9 @@ export function normalizeContentTypes(
87
97
  ...contentType,
88
98
  preset: contentType.preset,
89
99
  prefixes: [...prefixes].sort((a, b) => b.length - a.length),
100
+ graphHints: contentType.graphHints
101
+ ? contentType.graphHints.map(normalizeGraphHint).filter(Boolean)
102
+ : undefined,
90
103
  });
91
104
  }
92
105
 
@@ -119,6 +132,7 @@ export function fingerprintContentTypeRules(
119
132
  id: rule.id,
120
133
  preset: rule.preset,
121
134
  prefixes: rule.prefixes,
135
+ graphHints: rule.graphHints ?? [],
122
136
  }));
123
137
  const hasher = new Bun.CryptoHasher("sha256");
124
138
  hasher.update(JSON.stringify(canonical));
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Shared bounded typed-edge graph traversal.
3
+ *
4
+ * @module src/core/graph-query
5
+ */
6
+
7
+ import type { NormalizedContentTypeRule } from "../config";
8
+ import type {
9
+ DocumentRow,
10
+ GraphQueryNode,
11
+ GraphQueryOptions,
12
+ GraphQueryResult,
13
+ StorePort,
14
+ } from "../store/types";
15
+
16
+ import { resolveDocRef } from "./ref-parser";
17
+
18
+ export type DiagnoseGraphQueryResult =
19
+ | { success: true; data: GraphQueryResult }
20
+ | { success: false; error: string; isValidation?: boolean };
21
+
22
+ export interface DiagnoseGraphQueryOptions extends GraphQueryOptions {
23
+ contentTypeRules?: NormalizedContentTypeRule[];
24
+ }
25
+
26
+ function graphHintsForDoc(
27
+ doc: DocumentRow,
28
+ rules: NormalizedContentTypeRule[]
29
+ ): string[] {
30
+ if (!doc.contentType) {
31
+ return [];
32
+ }
33
+ return rules.find((rule) => rule.id === doc.contentType)?.graphHints ?? [];
34
+ }
35
+
36
+ function toNode(
37
+ doc: DocumentRow,
38
+ depth: number,
39
+ rules: NormalizedContentTypeRule[]
40
+ ): GraphQueryNode {
41
+ return {
42
+ id: doc.docid,
43
+ uri: doc.uri,
44
+ title: doc.title,
45
+ collection: doc.collection,
46
+ relPath: doc.relPath,
47
+ depth,
48
+ graphHints: graphHintsForDoc(doc, rules),
49
+ };
50
+ }
51
+
52
+ export async function diagnoseGraphQuery(
53
+ store: StorePort,
54
+ rootRef: string,
55
+ options: DiagnoseGraphQueryOptions = {}
56
+ ): Promise<DiagnoseGraphQueryResult> {
57
+ const resolved = await resolveDocRef(store, rootRef);
58
+ if ("error" in resolved) {
59
+ return {
60
+ success: false,
61
+ error: resolved.error,
62
+ isValidation: resolved.isValidation,
63
+ };
64
+ }
65
+
66
+ const rootDoc = resolved.doc;
67
+ if (!rootDoc.active) {
68
+ return {
69
+ success: false,
70
+ error: `Document is inactive: ${rootRef}`,
71
+ isValidation: true,
72
+ };
73
+ }
74
+
75
+ const direction = options.direction ?? "both";
76
+ const traversal = await store.queryGraphTraversal(rootDoc.id, {
77
+ direction,
78
+ edgeType: options.edgeType,
79
+ maxDepth: options.maxDepth,
80
+ maxNodes: options.maxNodes,
81
+ frontierLimit: options.frontierLimit,
82
+ visitedLimit: options.visitedLimit,
83
+ });
84
+ if (!traversal.ok) {
85
+ return { success: false, error: traversal.error.message };
86
+ }
87
+
88
+ const rules = options.contentTypeRules ?? [];
89
+ const nodes = traversal.value.nodes
90
+ .map(({ doc, depth }) => toNode(doc, depth, rules))
91
+ .sort((a, b) => a.depth - b.depth || a.uri.localeCompare(b.uri));
92
+ const root =
93
+ nodes.find((node) => node.id === rootDoc.docid) ??
94
+ toNode(rootDoc, 0, rules);
95
+ const edges = traversal.value.edges
96
+ .map(({ edge, depth }) => ({
97
+ source: edge.sourceDocid,
98
+ target: edge.targetDocid,
99
+ edgeType: edge.edgeType,
100
+ relationType: edge.relationType,
101
+ confidence: edge.confidence,
102
+ edgeSource: edge.edgeSource,
103
+ depth,
104
+ }))
105
+ .sort(
106
+ (a, b) =>
107
+ a.depth - b.depth ||
108
+ a.edgeType.localeCompare(b.edgeType) ||
109
+ a.source.localeCompare(b.source) ||
110
+ a.target.localeCompare(b.target)
111
+ );
112
+
113
+ return {
114
+ success: true,
115
+ data: {
116
+ schemaVersion: "1.0",
117
+ root,
118
+ nodes,
119
+ edges,
120
+ meta: {
121
+ direction,
122
+ edgeType: options.edgeType ?? null,
123
+ maxDepth: Math.max(1, Math.min(options.maxDepth ?? 2, 6)),
124
+ maxNodes: Math.max(1, Math.min(options.maxNodes ?? 100, 1_000)),
125
+ frontierLimit: Math.max(
126
+ 1,
127
+ Math.min(options.frontierLimit ?? 100, 1_000)
128
+ ),
129
+ visitedLimit: Math.max(1, Math.min(options.visitedLimit ?? 500, 5_000)),
130
+ returnedNodes: nodes.length,
131
+ returnedEdges: edges.length,
132
+ truncated: traversal.value.truncated,
133
+ warnings: traversal.value.warnings,
134
+ },
135
+ },
136
+ };
137
+ }