@gmickel/gno 1.9.0 → 1.10.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/assets/skill/SKILL.md +28 -16
- package/assets/skill/cli-reference.md +30 -0
- package/assets/skill/examples.md +20 -0
- package/assets/skill/mcp-reference.md +7 -1
- package/package.json +1 -1
- package/src/cli/commands/graph.ts +89 -2
- package/src/cli/commands/links.ts +237 -54
- package/src/cli/commands/query.ts +212 -0
- package/src/cli/commands/ref-parser.ts +8 -103
- package/src/cli/options.ts +4 -0
- package/src/cli/program.ts +176 -9
- package/src/config/content-types.ts +14 -0
- package/src/core/file-lock.ts +38 -3
- package/src/core/graph-query.ts +137 -0
- package/src/core/graph-resolver.ts +117 -0
- package/src/core/ref-parser.ts +145 -0
- package/src/ingestion/frontmatter.ts +95 -2
- package/src/ingestion/sync.ts +281 -1
- package/src/mcp/tools/get.ts +1 -1
- package/src/mcp/tools/index.ts +89 -1
- package/src/mcp/tools/links.ts +83 -1
- package/src/mcp/tools/multi-get.ts +1 -1
- package/src/mcp/tools/query.ts +207 -0
- package/src/pipeline/diagnose.ts +302 -0
- package/src/pipeline/filters.ts +119 -0
- package/src/pipeline/hybrid.ts +90 -17
- package/src/pipeline/types.ts +32 -0
- package/src/publish/export-service.ts +1 -1
- package/src/sdk/documents.ts +2 -2
- package/src/serve/routes/api.ts +194 -0
- package/src/serve/routes/graph.ts +173 -1
- package/src/serve/server.ts +24 -1
- package/src/store/migrations/010-typed-edges.ts +67 -0
- package/src/store/migrations/011-doc-edge-traversal-indexes.ts +30 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +826 -102
- package/src/store/types.ts +141 -0
|
@@ -1,108 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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";
|
package/src/cli/options.ts
CHANGED
|
@@ -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
|
|
package/src/cli/program.ts
CHANGED
|
@@ -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 (
|
|
559
|
+
.action(async (queryParts: string[], cmdOpts: Record<string, unknown>) => {
|
|
559
560
|
const format = getFormat(cmdOpts);
|
|
560
|
-
|
|
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"
|
|
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
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
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:
|
|
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));
|
package/src/core/file-lock.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* @module src/core/file-lock
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
// node:fs/promises for mkdir (no Bun equivalent for
|
|
8
|
-
import { mkdir } from "node:fs/promises";
|
|
7
|
+
// node:fs/promises for mkdir/rm (no Bun equivalent for filesystem structure ops)
|
|
8
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
9
9
|
// node:path for dirname (no Bun path utils)
|
|
10
10
|
import { dirname } from "node:path";
|
|
11
11
|
|
|
@@ -13,6 +13,8 @@ import { MCP_ERRORS } from "./errors";
|
|
|
13
13
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
14
14
|
const HOLD_SECONDS = 60 * 60 * 24 * 365;
|
|
15
15
|
const READY_TOKEN = "READY";
|
|
16
|
+
const DIRECTORY_LOCK_SUFFIX = ".dir";
|
|
17
|
+
const DIRECTORY_LOCK_POLL_MS = 50;
|
|
16
18
|
|
|
17
19
|
export interface WriteLockHandle {
|
|
18
20
|
release: () => Promise<void>;
|
|
@@ -65,6 +67,10 @@ function buildHoldCommand(): string {
|
|
|
65
67
|
return `printf '${READY_TOKEN}\\n'; exec sleep ${HOLD_SECONDS}`;
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
function delay(ms: number): Promise<void> {
|
|
71
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
72
|
+
}
|
|
73
|
+
|
|
68
74
|
async function waitForReady(
|
|
69
75
|
proc: ReturnType<typeof Bun.spawn>
|
|
70
76
|
): Promise<boolean> {
|
|
@@ -90,13 +96,42 @@ async function waitForReady(
|
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
98
|
|
|
99
|
+
async function acquireDirectoryLock(
|
|
100
|
+
lockPath: string,
|
|
101
|
+
timeoutMs: number
|
|
102
|
+
): Promise<WriteLockHandle | null> {
|
|
103
|
+
const directoryLockPath = `${lockPath}${DIRECTORY_LOCK_SUFFIX}`;
|
|
104
|
+
await mkdir(dirname(directoryLockPath), { recursive: true });
|
|
105
|
+
|
|
106
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
107
|
+
while (true) {
|
|
108
|
+
try {
|
|
109
|
+
await mkdir(directoryLockPath);
|
|
110
|
+
return {
|
|
111
|
+
release: async () => {
|
|
112
|
+
await rm(directoryLockPath, { force: true, recursive: true });
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const code = (error as { code?: string }).code;
|
|
117
|
+
if (code !== "EEXIST") {
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
if (Date.now() >= deadline) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
await delay(Math.min(DIRECTORY_LOCK_POLL_MS, deadline - Date.now()));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
93
128
|
export async function acquireWriteLock(
|
|
94
129
|
lockPath: string,
|
|
95
130
|
timeoutMs: number = DEFAULT_TIMEOUT_MS
|
|
96
131
|
): Promise<WriteLockHandle | null> {
|
|
97
132
|
const cmd = resolveLockCommand();
|
|
98
133
|
if (!cmd) {
|
|
99
|
-
|
|
134
|
+
return acquireDirectoryLock(lockPath, timeoutMs);
|
|
100
135
|
}
|
|
101
136
|
|
|
102
137
|
await mkdir(dirname(lockPath), { recursive: true });
|
|
@@ -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
|
+
}
|