dsh-gitnexus-plus 0.0.0-stage → 0.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/LICENSE +38 -0
- package/README.md +631 -2
- package/cordis.patch.yml +18 -0
- package/lib/bin/gitnexus-coverage.d.ts +14 -0
- package/lib/bin/gitnexus-coverage.d.ts.map +1 -0
- package/lib/bin/gitnexus-coverage.js +171 -0
- package/lib/bin/gitnexus-coverage.js.map +1 -0
- package/lib/bin/search-helper.d.ts +22 -0
- package/lib/bin/search-helper.d.ts.map +1 -0
- package/lib/bin/search-helper.js +57 -0
- package/lib/bin/search-helper.js.map +1 -0
- package/lib/client.js +2131 -0
- package/lib/gitnexus/analyze.d.ts +173 -0
- package/lib/gitnexus/analyze.d.ts.map +1 -0
- package/lib/gitnexus/analyze.js +308 -0
- package/lib/gitnexus/analyze.js.map +1 -0
- package/lib/gitnexus/commands.d.ts +129 -0
- package/lib/gitnexus/commands.d.ts.map +1 -0
- package/lib/gitnexus/commands.js +185 -0
- package/lib/gitnexus/commands.js.map +1 -0
- package/lib/gitnexus/coverage.d.ts +340 -0
- package/lib/gitnexus/coverage.d.ts.map +1 -0
- package/lib/gitnexus/coverage.js +554 -0
- package/lib/gitnexus/coverage.js.map +1 -0
- package/lib/gitnexus/format.d.ts +16 -0
- package/lib/gitnexus/format.d.ts.map +1 -0
- package/lib/gitnexus/format.js +178 -0
- package/lib/gitnexus/format.js.map +1 -0
- package/lib/gitnexus/git-activity.d.ts +136 -0
- package/lib/gitnexus/git-activity.d.ts.map +1 -0
- package/lib/gitnexus/git-activity.js +345 -0
- package/lib/gitnexus/git-activity.js.map +1 -0
- package/lib/gitnexus/git.d.ts +70 -0
- package/lib/gitnexus/git.d.ts.map +1 -0
- package/lib/gitnexus/git.js +121 -0
- package/lib/gitnexus/git.js.map +1 -0
- package/lib/gitnexus/index-meta.d.ts +160 -0
- package/lib/gitnexus/index-meta.d.ts.map +1 -0
- package/lib/gitnexus/index-meta.js +176 -0
- package/lib/gitnexus/index-meta.js.map +1 -0
- package/lib/gitnexus/install.d.ts +97 -0
- package/lib/gitnexus/install.d.ts.map +1 -0
- package/lib/gitnexus/install.js +228 -0
- package/lib/gitnexus/install.js.map +1 -0
- package/lib/gitnexus/languages.d.ts +80 -0
- package/lib/gitnexus/languages.d.ts.map +1 -0
- package/lib/gitnexus/languages.js +297 -0
- package/lib/gitnexus/languages.js.map +1 -0
- package/lib/gitnexus/root.d.ts +74 -0
- package/lib/gitnexus/root.d.ts.map +1 -0
- package/lib/gitnexus/root.js +168 -0
- package/lib/gitnexus/root.js.map +1 -0
- package/lib/gitnexus/scan.d.ts +68 -0
- package/lib/gitnexus/scan.d.ts.map +1 -0
- package/lib/gitnexus/scan.js +249 -0
- package/lib/gitnexus/scan.js.map +1 -0
- package/lib/gitnexus/search.d.ts +167 -0
- package/lib/gitnexus/search.d.ts.map +1 -0
- package/lib/gitnexus/search.js +517 -0
- package/lib/gitnexus/search.js.map +1 -0
- package/lib/gitnexus/toolchain.d.ts +136 -0
- package/lib/gitnexus/toolchain.d.ts.map +1 -0
- package/lib/gitnexus/toolchain.js +251 -0
- package/lib/gitnexus/toolchain.js.map +1 -0
- package/lib/index.d.ts +98 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +252 -0
- package/lib/index.js.map +1 -0
- package/lib/routes/analyze.d.ts +41 -0
- package/lib/routes/analyze.d.ts.map +1 -0
- package/lib/routes/analyze.js +75 -0
- package/lib/routes/analyze.js.map +1 -0
- package/lib/routes/coverage.d.ts +52 -0
- package/lib/routes/coverage.d.ts.map +1 -0
- package/lib/routes/coverage.js +81 -0
- package/lib/routes/coverage.js.map +1 -0
- package/lib/routes/guard.d.ts +92 -0
- package/lib/routes/guard.d.ts.map +1 -0
- package/lib/routes/guard.js +187 -0
- package/lib/routes/guard.js.map +1 -0
- package/lib/routes/install.d.ts +27 -0
- package/lib/routes/install.d.ts.map +1 -0
- package/lib/routes/install.js +46 -0
- package/lib/routes/install.js.map +1 -0
- package/lib/routes/search.d.ts +27 -0
- package/lib/routes/search.d.ts.map +1 -0
- package/lib/routes/search.js +77 -0
- package/lib/routes/search.js.map +1 -0
- package/package.json +87 -4
- package/scripts/link-runtime.mjs +146 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyword search over the graph GitNexus already indexed.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS DOES NOT USE FTS
|
|
5
|
+
* -------------------------
|
|
6
|
+
* GitNexus's keyword surface is its BM25/FTS extension. On this machine that
|
|
7
|
+
* extension fails to load (`libfts.lbug_extension` is missing), and the existing
|
|
8
|
+
* index was built with `--skip-fts`, so there are no FTS tables to query even if the
|
|
9
|
+
* extension were present. Reporting "search unavailable" would be honest, but it
|
|
10
|
+
* would also be the end of the feature for the state most users are in.
|
|
11
|
+
*
|
|
12
|
+
* The graph itself still contains every symbol's name and file path, and a Cypher
|
|
13
|
+
* `CONTAINS` over those columns answers the question the user is actually asking
|
|
14
|
+
* when they type a symbol name. This module therefore searches **names and paths**,
|
|
15
|
+
* not source text or descriptions. That distinction is deliberate and surfaced to
|
|
16
|
+
* the panel: an FTS repair can add content search later without this module
|
|
17
|
+
* pretending it already has it.
|
|
18
|
+
*
|
|
19
|
+
* READ-ONLY, AND REUSING GITNEXUS'S OWN ADAPTER
|
|
20
|
+
* ---------------------------------------------
|
|
21
|
+
* The database is opened through GitNexus's `withLbugDb(..., { readOnly: true,
|
|
22
|
+
* skipFts: true })`, so it takes no write lock, cannot mutate the index, and does not
|
|
23
|
+
* attempt the FTS load that is known to fail here. The adapter is deep-imported from
|
|
24
|
+
* the installed package, matching the plugin's existing "reuse, don't reimplement"
|
|
25
|
+
* stance; if the package or its native engine is unavailable, search reports that
|
|
26
|
+
* state rather than falling back to a filesystem scan with different semantics.
|
|
27
|
+
*/
|
|
28
|
+
import type { ShellLike } from './analyze.js';
|
|
29
|
+
/** One keyword hit, as the panel renders it. */
|
|
30
|
+
export interface SearchHit {
|
|
31
|
+
/** Graph node label: Function, Class, Interface, Method, File, … */
|
|
32
|
+
kind: string;
|
|
33
|
+
name: string;
|
|
34
|
+
filePath: string;
|
|
35
|
+
startLine: number | null;
|
|
36
|
+
endLine: number | null;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Where the results came from.
|
|
40
|
+
*
|
|
41
|
+
* `graph` is the normal LadybugDB name/path query. `text` is the bounded fallback
|
|
42
|
+
* over the indexed source files, used when the native connection cannot be started
|
|
43
|
+
* at all (for example, no usable host shell sandbox). The panel must say which one
|
|
44
|
+
* it got: "found in source" and "found in the graph" are different promises.
|
|
45
|
+
*/
|
|
46
|
+
export type SearchMode = 'graph' | 'text';
|
|
47
|
+
/** The result of one keyword search. */
|
|
48
|
+
export interface SearchReport {
|
|
49
|
+
/** The normalized query that was searched. */
|
|
50
|
+
query: string;
|
|
51
|
+
/** `graph` for the indexed graph; `text` for the source-file fallback. */
|
|
52
|
+
mode: SearchMode;
|
|
53
|
+
/** Whether the manifest says content-level FTS is available (informational). */
|
|
54
|
+
ftsAvailable: boolean;
|
|
55
|
+
results: SearchHit[];
|
|
56
|
+
/** Number of rows returned, after the limit. */
|
|
57
|
+
total: number;
|
|
58
|
+
/** True when the limit may have cut the result set. */
|
|
59
|
+
truncated: boolean;
|
|
60
|
+
/** A user-facing caveat, or null. */
|
|
61
|
+
warning: string | null;
|
|
62
|
+
}
|
|
63
|
+
export interface SearchOptions {
|
|
64
|
+
/** 1–50, default 20. */
|
|
65
|
+
limit?: number;
|
|
66
|
+
/** The plugin-owned GitNexus prefix, when one was installed from the panel. */
|
|
67
|
+
managedPrefix?: string;
|
|
68
|
+
env?: NodeJS.ProcessEnv;
|
|
69
|
+
}
|
|
70
|
+
/** A failure the route can map to a status code without guessing. */
|
|
71
|
+
export type SearchErrorCode = 'invalid-query' | 'not-a-project' | 'no-index' | 'no-index-store' | 'search-unavailable' | 'index-busy' | 'search-failed';
|
|
72
|
+
export declare class SearchError extends Error {
|
|
73
|
+
readonly code: SearchErrorCode;
|
|
74
|
+
readonly status: number;
|
|
75
|
+
constructor(code: SearchErrorCode, status: number, message: string);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Normalize a user keyword into a safe Cypher string literal body.
|
|
79
|
+
*
|
|
80
|
+
* The query is interpolated as a single-quoted literal because LadybugDB's prepare
|
|
81
|
+
* path does not accept a bound parameter in this `CONTAINS` position (measured:
|
|
82
|
+
* `lower(n.name) CONTAINS $q` fails at parse time, while the same query with a
|
|
83
|
+
* literal works). Safety therefore comes from **removing** every character that
|
|
84
|
+
* could terminate or escape the literal — quotes, backslashes and control
|
|
85
|
+
* characters — rather than from escaping them. The allowed set is what a code
|
|
86
|
+
* identifier or path search needs; everything else becomes a space.
|
|
87
|
+
*
|
|
88
|
+
* Case is lowered here to match `lower(n.name)` on the other side. The length cap
|
|
89
|
+
* keeps a pathological paste from turning into an expensive graph scan.
|
|
90
|
+
*/
|
|
91
|
+
export declare function sanitizeSearchQuery(raw: string): string;
|
|
92
|
+
/**
|
|
93
|
+
* Search symbol names and file paths in one repository's index.
|
|
94
|
+
*
|
|
95
|
+
* @throws {SearchError} for states the route should report as status + code.
|
|
96
|
+
*/
|
|
97
|
+
export declare function searchSymbols(repoPath: string, rawQuery: string, options?: SearchOptions): Promise<SearchReport>;
|
|
98
|
+
/** Options for the subprocess search. */
|
|
99
|
+
export interface HelperSearchOptions {
|
|
100
|
+
/** The host's shell executor; `null` when the deployment has none. */
|
|
101
|
+
shell: ShellLike | null;
|
|
102
|
+
/** Absolute path to `lib/bin/search-helper.js`. */
|
|
103
|
+
helperPath: string;
|
|
104
|
+
/** The plugin-owned GitNexus prefix, when one was installed from the panel. */
|
|
105
|
+
managedPrefix?: string;
|
|
106
|
+
limit?: number;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Run the search in a child Node process.
|
|
110
|
+
*
|
|
111
|
+
* WHY A SUBPROCESS IS NOT OPTIONAL
|
|
112
|
+
* --------------------------------
|
|
113
|
+
* The DSH host runs on Electron's Node, and GitNexus's LadybugDB addon is built for
|
|
114
|
+
* the system Node ABI. Loading it in the host fails with
|
|
115
|
+
*
|
|
116
|
+
* A dynamic link library (DLL) initialization routine failed. …\lbugjs.node
|
|
117
|
+
*
|
|
118
|
+
* while the very same addon loads under the system Node 24 that the CLI uses. The
|
|
119
|
+
* host therefore cannot own the native connection; it starts `node <helper>` and
|
|
120
|
+
* parses one JSON object. That is the same execution boundary the analyze runner
|
|
121
|
+
* already uses, and it keeps the panel process free of native state it cannot load.
|
|
122
|
+
*
|
|
123
|
+
* The command has no environment prefix: a short-lived helper does not need the
|
|
124
|
+
* heap respawn guard, and avoiding `$env:` syntax keeps the command portable across
|
|
125
|
+
* PowerShell, Git Bash and POSIX shells.
|
|
126
|
+
*/
|
|
127
|
+
export declare function searchViaHelper(repoPath: string, rawQuery: string, options: HelperSearchOptions): Promise<SearchReport>;
|
|
128
|
+
/** Bounds for the source-text fallback. */
|
|
129
|
+
export interface TextSearchOptions {
|
|
130
|
+
limit?: number;
|
|
131
|
+
/** Files considered, newest order unspecified. Default 2000. */
|
|
132
|
+
maxFiles?: number;
|
|
133
|
+
/** Total bytes read across files. Default 16 MiB. */
|
|
134
|
+
maxBytes?: number;
|
|
135
|
+
/** One file larger than this is skipped. Default 1 MiB. */
|
|
136
|
+
maxFileBytes?: number;
|
|
137
|
+
/** The graph failure that caused this fallback, for the warning. */
|
|
138
|
+
reason?: string;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Bounded keyword search over the source files listed by the index.
|
|
142
|
+
*
|
|
143
|
+
* This exists for one concrete state: the host cannot start the native query path
|
|
144
|
+
* (Electron ABI, no usable shell sandbox, busy database). In that state the index
|
|
145
|
+
* manifest still names every file the index covers, and reading those files is
|
|
146
|
+
* ordinary filesystem access the host already has. It is deliberately **not** a
|
|
147
|
+
* hidden replacement for the graph search:
|
|
148
|
+
*
|
|
149
|
+
* - it searches line text, so `mode` is `text` and the panel says so;
|
|
150
|
+
* - it only looks at files the index lists, so a brand-new untracked file is
|
|
151
|
+
* still absent until the index is refreshed;
|
|
152
|
+
* - every dimension is capped and `truncated` reports when a cap bit, because a
|
|
153
|
+
* silent partial result is the failure mode this whole project exists to avoid.
|
|
154
|
+
*
|
|
155
|
+
* It performs no spawn, no native load and no write.
|
|
156
|
+
*/
|
|
157
|
+
export declare function searchText(repoPath: string, rawQuery: string, options?: TextSearchOptions): Promise<SearchReport>;
|
|
158
|
+
/**
|
|
159
|
+
* Graph first, then the bounded source fallback when the native path cannot run.
|
|
160
|
+
*
|
|
161
|
+
* The fallback is not attempted for an empty query, an unidentified project, or a
|
|
162
|
+
* project with no index: in all three there is either nothing to search or no honest
|
|
163
|
+
* denominator. Every other failure gets the text search, and its report carries
|
|
164
|
+
* `mode: "text"` plus the graph failure in `warning`.
|
|
165
|
+
*/
|
|
166
|
+
export declare function searchWithFallback(repoPath: string, rawQuery: string, options: HelperSearchOptions): Promise<SearchReport>;
|
|
167
|
+
//# sourceMappingURL=search.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/gitnexus/search.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyword search over the graph GitNexus already indexed.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS DOES NOT USE FTS
|
|
5
|
+
* -------------------------
|
|
6
|
+
* GitNexus's keyword surface is its BM25/FTS extension. On this machine that
|
|
7
|
+
* extension fails to load (`libfts.lbug_extension` is missing), and the existing
|
|
8
|
+
* index was built with `--skip-fts`, so there are no FTS tables to query even if the
|
|
9
|
+
* extension were present. Reporting "search unavailable" would be honest, but it
|
|
10
|
+
* would also be the end of the feature for the state most users are in.
|
|
11
|
+
*
|
|
12
|
+
* The graph itself still contains every symbol's name and file path, and a Cypher
|
|
13
|
+
* `CONTAINS` over those columns answers the question the user is actually asking
|
|
14
|
+
* when they type a symbol name. This module therefore searches **names and paths**,
|
|
15
|
+
* not source text or descriptions. That distinction is deliberate and surfaced to
|
|
16
|
+
* the panel: an FTS repair can add content search later without this module
|
|
17
|
+
* pretending it already has it.
|
|
18
|
+
*
|
|
19
|
+
* READ-ONLY, AND REUSING GITNEXUS'S OWN ADAPTER
|
|
20
|
+
* ---------------------------------------------
|
|
21
|
+
* The database is opened through GitNexus's `withLbugDb(..., { readOnly: true,
|
|
22
|
+
* skipFts: true })`, so it takes no write lock, cannot mutate the index, and does not
|
|
23
|
+
* attempt the FTS load that is known to fail here. The adapter is deep-imported from
|
|
24
|
+
* the installed package, matching the plugin's existing "reuse, don't reimplement"
|
|
25
|
+
* stance; if the package or its native engine is unavailable, search reports that
|
|
26
|
+
* state rather than falling back to a filesystem scan with different semantics.
|
|
27
|
+
*/
|
|
28
|
+
import { existsSync } from 'node:fs';
|
|
29
|
+
import fs from 'node:fs/promises';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { pathToFileURL } from 'node:url';
|
|
32
|
+
import { findGitnexusRoot } from './root.js';
|
|
33
|
+
import { isProjectRoot, readIndexMeta } from './index-meta.js';
|
|
34
|
+
import { readCapabilities } from './coverage.js';
|
|
35
|
+
export class SearchError extends Error {
|
|
36
|
+
code;
|
|
37
|
+
status;
|
|
38
|
+
constructor(code, status, message) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = 'SearchError';
|
|
41
|
+
this.code = code;
|
|
42
|
+
this.status = status;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const adapters = new Map();
|
|
46
|
+
/**
|
|
47
|
+
* Load GitNexus's LadybugDB adapter from the installed package.
|
|
48
|
+
*
|
|
49
|
+
* A missing package, a missing native engine, or a reshaped adapter all become the
|
|
50
|
+
* same reported state: search is unavailable here, and the reason is carried in the
|
|
51
|
+
* message rather than hidden behind an empty result list.
|
|
52
|
+
*/
|
|
53
|
+
async function loadAdapter(options) {
|
|
54
|
+
const root = findGitnexusRoot({ env: options.env, managedPrefix: options.managedPrefix });
|
|
55
|
+
if (root === null) {
|
|
56
|
+
throw new SearchError('search-unavailable', 503, '找不到 GitNexus 包,无法打开图数据库。安装或指向 GitNexus 后重试。');
|
|
57
|
+
}
|
|
58
|
+
const cached = adapters.get(root);
|
|
59
|
+
if (cached !== undefined)
|
|
60
|
+
return cached;
|
|
61
|
+
try {
|
|
62
|
+
const mod = (await import(pathToFileURL(path.join(root, 'dist', 'core', 'lbug', 'lbug-adapter.js')).href));
|
|
63
|
+
if (typeof mod.withLbugDb !== 'function' || typeof mod.executePrepared !== 'function') {
|
|
64
|
+
throw new Error('adapter does not export withLbugDb/executePrepared');
|
|
65
|
+
}
|
|
66
|
+
const adapter = {
|
|
67
|
+
withLbugDb: mod.withLbugDb,
|
|
68
|
+
executePrepared: mod.executePrepared,
|
|
69
|
+
};
|
|
70
|
+
adapters.set(root, adapter);
|
|
71
|
+
return adapter;
|
|
72
|
+
}
|
|
73
|
+
catch (cause) {
|
|
74
|
+
throw new SearchError('search-unavailable', 503, `GitNexus 的图数据库适配器不可用:${cause instanceof Error ? cause.message : String(cause)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Normalize a user keyword into a safe Cypher string literal body.
|
|
79
|
+
*
|
|
80
|
+
* The query is interpolated as a single-quoted literal because LadybugDB's prepare
|
|
81
|
+
* path does not accept a bound parameter in this `CONTAINS` position (measured:
|
|
82
|
+
* `lower(n.name) CONTAINS $q` fails at parse time, while the same query with a
|
|
83
|
+
* literal works). Safety therefore comes from **removing** every character that
|
|
84
|
+
* could terminate or escape the literal — quotes, backslashes and control
|
|
85
|
+
* characters — rather than from escaping them. The allowed set is what a code
|
|
86
|
+
* identifier or path search needs; everything else becomes a space.
|
|
87
|
+
*
|
|
88
|
+
* Case is lowered here to match `lower(n.name)` on the other side. The length cap
|
|
89
|
+
* keeps a pathological paste from turning into an expensive graph scan.
|
|
90
|
+
*/
|
|
91
|
+
export function sanitizeSearchQuery(raw) {
|
|
92
|
+
return raw
|
|
93
|
+
.normalize('NFC')
|
|
94
|
+
.toLowerCase()
|
|
95
|
+
.replace(/[^\p{L}\p{N}\s_./:@#+-]/gu, ' ')
|
|
96
|
+
.replace(/\s+/g, ' ')
|
|
97
|
+
.trim()
|
|
98
|
+
.slice(0, 80);
|
|
99
|
+
}
|
|
100
|
+
function clampLimit(value) {
|
|
101
|
+
const n = value === undefined || !Number.isFinite(value) ? 20 : Math.floor(value);
|
|
102
|
+
return Math.max(1, Math.min(50, n));
|
|
103
|
+
}
|
|
104
|
+
function toLine(value) {
|
|
105
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
106
|
+
return Math.trunc(value);
|
|
107
|
+
if (typeof value === 'bigint')
|
|
108
|
+
return Number(value);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
function buildCypher(query, limit) {
|
|
112
|
+
// `query` has already been reduced to the safe literal alphabet by
|
|
113
|
+
// sanitizeSearchQuery; the limit is a clamped integer. Neither is user-arbitrary
|
|
114
|
+
// syntax at this point.
|
|
115
|
+
return `
|
|
116
|
+
MATCH (n)
|
|
117
|
+
WHERE (lower(n.name) CONTAINS '${query}'
|
|
118
|
+
OR (label(n) = 'File' AND lower(n.filePath) CONTAINS '${query}'))
|
|
119
|
+
RETURN label(n) AS kind, n.name AS name, n.filePath AS filePath,
|
|
120
|
+
n.startLine AS startLine, n.endLine AS endLine,
|
|
121
|
+
CASE WHEN lower(n.name) = '${query}' THEN 0
|
|
122
|
+
WHEN lower(n.name) STARTS WITH '${query}' THEN 1
|
|
123
|
+
WHEN label(n) = 'File' THEN 2
|
|
124
|
+
ELSE 3 END AS rank
|
|
125
|
+
ORDER BY rank, name
|
|
126
|
+
LIMIT ${limit}
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
function mapRows(rows) {
|
|
130
|
+
const results = [];
|
|
131
|
+
for (const value of rows) {
|
|
132
|
+
if (value === null || typeof value !== 'object')
|
|
133
|
+
continue;
|
|
134
|
+
const row = value;
|
|
135
|
+
const name = typeof row.name === 'string' ? row.name : '';
|
|
136
|
+
const filePath = typeof row.filePath === 'string' ? row.filePath : '';
|
|
137
|
+
if (name === '' || filePath === '')
|
|
138
|
+
continue;
|
|
139
|
+
results.push({
|
|
140
|
+
kind: typeof row.kind === 'string' && row.kind !== '' ? row.kind : 'Unknown',
|
|
141
|
+
name,
|
|
142
|
+
filePath,
|
|
143
|
+
startLine: toLine(row.startLine),
|
|
144
|
+
endLine: toLine(row.endLine),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return results;
|
|
148
|
+
}
|
|
149
|
+
function isBusyMessage(message) {
|
|
150
|
+
return /busy|locked|lock held|another process|timeout/i.test(message);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Search symbol names and file paths in one repository's index.
|
|
154
|
+
*
|
|
155
|
+
* @throws {SearchError} for states the route should report as status + code.
|
|
156
|
+
*/
|
|
157
|
+
export async function searchSymbols(repoPath, rawQuery, options = {}) {
|
|
158
|
+
const query = sanitizeSearchQuery(rawQuery);
|
|
159
|
+
if (query === '') {
|
|
160
|
+
throw new SearchError('invalid-query', 400, '搜索词不能为空,且至少包含一个字母或数字。');
|
|
161
|
+
}
|
|
162
|
+
const absRepo = path.resolve(repoPath);
|
|
163
|
+
if (!(await isProjectRoot(absRepo))) {
|
|
164
|
+
throw new SearchError('not-a-project', 404, '目录不是 GitNexus 索引,也不是 git 项目根。');
|
|
165
|
+
}
|
|
166
|
+
const meta = await readIndexMeta(absRepo);
|
|
167
|
+
if (meta === null) {
|
|
168
|
+
throw new SearchError('no-index', 409, '该项目还没有 GitNexus 索引;初始化后才能搜索。');
|
|
169
|
+
}
|
|
170
|
+
const lbugPath = path.join(absRepo, '.gitnexus', 'lbug');
|
|
171
|
+
if (!existsSync(lbugPath)) {
|
|
172
|
+
throw new SearchError('no-index-store', 409, '索引缺少 .gitnexus/lbug 图存储,无法搜索。');
|
|
173
|
+
}
|
|
174
|
+
const adapter = await loadAdapter(options);
|
|
175
|
+
const limit = clampLimit(options.limit);
|
|
176
|
+
const cypher = buildCypher(query, limit);
|
|
177
|
+
let rows;
|
|
178
|
+
try {
|
|
179
|
+
// `skipFts` avoids the extension load that is known to fail on this machine;
|
|
180
|
+
// `readOnly` keeps the search from taking a writer lock or creating anything.
|
|
181
|
+
rows = (await adapter.withLbugDb(lbugPath, async () => adapter.executePrepared(cypher, {}), { readOnly: true, skipFts: true }));
|
|
182
|
+
}
|
|
183
|
+
catch (cause) {
|
|
184
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
185
|
+
if (isBusyMessage(message)) {
|
|
186
|
+
throw new SearchError('index-busy', 503, '索引正被另一个 GitNexus 操作占用,稍后重试。');
|
|
187
|
+
}
|
|
188
|
+
throw new SearchError('search-failed', 500, `图查询失败:${message}`);
|
|
189
|
+
}
|
|
190
|
+
const results = mapRows(rows);
|
|
191
|
+
const capabilities = readCapabilities(meta.raw ?? null);
|
|
192
|
+
const ftsAvailable = capabilities.fts?.status === 'available';
|
|
193
|
+
return {
|
|
194
|
+
query,
|
|
195
|
+
mode: 'graph',
|
|
196
|
+
ftsAvailable,
|
|
197
|
+
results,
|
|
198
|
+
total: results.length,
|
|
199
|
+
truncated: results.length >= limit,
|
|
200
|
+
warning: ftsAvailable
|
|
201
|
+
? null
|
|
202
|
+
: 'FTS 内容检索不可用;本次按符号名与文件路径匹配,不搜索正文。',
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function quoteFor(value) {
|
|
206
|
+
return process.platform === 'win32'
|
|
207
|
+
? `'${value.replace(/'/g, "''")}'`
|
|
208
|
+
: `'${value.replace(/'/g, "'\\''")}'`;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Parse the helper's one-object stdout without assuming it is the very first byte.
|
|
212
|
+
*
|
|
213
|
+
* GitNexus or Node can write a warning to stdout before our payload. Taking the
|
|
214
|
+
* first `{` to the last `}` is deliberately forgiving of that, while a missing
|
|
215
|
+
* object still becomes a typed failure rather than an empty result.
|
|
216
|
+
*/
|
|
217
|
+
function parseHelperOutput(text) {
|
|
218
|
+
const start = text.indexOf('{');
|
|
219
|
+
const end = text.lastIndexOf('}');
|
|
220
|
+
if (start < 0 || end <= start)
|
|
221
|
+
return null;
|
|
222
|
+
try {
|
|
223
|
+
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
224
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : null;
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function searchErrorCode(value) {
|
|
231
|
+
switch (value) {
|
|
232
|
+
case 'invalid-query':
|
|
233
|
+
case 'not-a-project':
|
|
234
|
+
case 'no-index':
|
|
235
|
+
case 'no-index-store':
|
|
236
|
+
case 'search-unavailable':
|
|
237
|
+
case 'index-busy':
|
|
238
|
+
case 'search-failed':
|
|
239
|
+
return value;
|
|
240
|
+
default:
|
|
241
|
+
return 'search-failed';
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function asReport(value) {
|
|
245
|
+
if (value === null || typeof value !== 'object')
|
|
246
|
+
return null;
|
|
247
|
+
const raw = value;
|
|
248
|
+
if (!Array.isArray(raw.results))
|
|
249
|
+
return null;
|
|
250
|
+
const results = [];
|
|
251
|
+
for (const item of raw.results) {
|
|
252
|
+
if (item === null || typeof item !== 'object')
|
|
253
|
+
continue;
|
|
254
|
+
const row = item;
|
|
255
|
+
if (typeof row.name !== 'string' || typeof row.filePath !== 'string')
|
|
256
|
+
continue;
|
|
257
|
+
results.push({
|
|
258
|
+
kind: typeof row.kind === 'string' ? row.kind : 'Unknown',
|
|
259
|
+
name: row.name,
|
|
260
|
+
filePath: row.filePath,
|
|
261
|
+
startLine: toLine(row.startLine),
|
|
262
|
+
endLine: toLine(row.endLine),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
query: typeof raw.query === 'string' ? raw.query : '',
|
|
267
|
+
mode: raw.mode === 'text' ? 'text' : 'graph',
|
|
268
|
+
ftsAvailable: raw.ftsAvailable === true,
|
|
269
|
+
results,
|
|
270
|
+
total: typeof raw.total === 'number' ? raw.total : results.length,
|
|
271
|
+
truncated: raw.truncated === true,
|
|
272
|
+
warning: typeof raw.warning === 'string' ? raw.warning : null,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Run the search in a child Node process.
|
|
277
|
+
*
|
|
278
|
+
* WHY A SUBPROCESS IS NOT OPTIONAL
|
|
279
|
+
* --------------------------------
|
|
280
|
+
* The DSH host runs on Electron's Node, and GitNexus's LadybugDB addon is built for
|
|
281
|
+
* the system Node ABI. Loading it in the host fails with
|
|
282
|
+
*
|
|
283
|
+
* A dynamic link library (DLL) initialization routine failed. …\lbugjs.node
|
|
284
|
+
*
|
|
285
|
+
* while the very same addon loads under the system Node 24 that the CLI uses. The
|
|
286
|
+
* host therefore cannot own the native connection; it starts `node <helper>` and
|
|
287
|
+
* parses one JSON object. That is the same execution boundary the analyze runner
|
|
288
|
+
* already uses, and it keeps the panel process free of native state it cannot load.
|
|
289
|
+
*
|
|
290
|
+
* The command has no environment prefix: a short-lived helper does not need the
|
|
291
|
+
* heap respawn guard, and avoiding `$env:` syntax keeps the command portable across
|
|
292
|
+
* PowerShell, Git Bash and POSIX shells.
|
|
293
|
+
*/
|
|
294
|
+
export async function searchViaHelper(repoPath, rawQuery, options) {
|
|
295
|
+
const query = sanitizeSearchQuery(rawQuery);
|
|
296
|
+
if (query === '') {
|
|
297
|
+
throw new SearchError('invalid-query', 400, '搜索词不能为空,且至少包含一个字母或数字。');
|
|
298
|
+
}
|
|
299
|
+
const absRepo = path.resolve(repoPath);
|
|
300
|
+
if (!(await isProjectRoot(absRepo))) {
|
|
301
|
+
throw new SearchError('not-a-project', 404, '目录不是 GitNexus 索引,也不是 git 项目根。');
|
|
302
|
+
}
|
|
303
|
+
const meta = await readIndexMeta(absRepo);
|
|
304
|
+
if (meta === null) {
|
|
305
|
+
throw new SearchError('no-index', 409, '该项目还没有 GitNexus 索引;初始化后才能搜索。');
|
|
306
|
+
}
|
|
307
|
+
if (!existsSync(path.join(absRepo, '.gitnexus', 'lbug'))) {
|
|
308
|
+
throw new SearchError('no-index-store', 409, '索引缺少 .gitnexus/lbug 图存储,无法搜索。');
|
|
309
|
+
}
|
|
310
|
+
if (options.shell === null) {
|
|
311
|
+
throw new SearchError('search-unavailable', 503, '当前部署没有 shell 执行器,无法启动搜索子进程;搜索需要系统 Node 24 加载 GitNexus 原生模块。');
|
|
312
|
+
}
|
|
313
|
+
const limit = clampLimit(options.limit);
|
|
314
|
+
const args = [options.helperPath, absRepo, query, String(limit), options.managedPrefix ?? '']
|
|
315
|
+
.map(quoteFor)
|
|
316
|
+
.join(' ');
|
|
317
|
+
const command = `node ${args}`;
|
|
318
|
+
let result;
|
|
319
|
+
try {
|
|
320
|
+
const spec = options.shell.resolve({
|
|
321
|
+
command,
|
|
322
|
+
workdir: absRepo,
|
|
323
|
+
timeoutMs: 20_000,
|
|
324
|
+
onExpiry: 'kill',
|
|
325
|
+
stdoutMaxBytes: 2 * 1024 * 1024,
|
|
326
|
+
});
|
|
327
|
+
const execution = await options.shell.execute(spec);
|
|
328
|
+
result = await execution.result();
|
|
329
|
+
}
|
|
330
|
+
catch (cause) {
|
|
331
|
+
throw new SearchError('search-unavailable', 503, `无法启动搜索子进程:${cause instanceof Error ? cause.message : String(cause)}`);
|
|
332
|
+
}
|
|
333
|
+
if (result.timedOut === true) {
|
|
334
|
+
throw new SearchError('search-failed', 504, '搜索子进程超时(20 秒)。');
|
|
335
|
+
}
|
|
336
|
+
const stdout = typeof result.stdout?.text === 'string' ? result.stdout.text : '';
|
|
337
|
+
const stderr = typeof result.stderr?.text === 'string' ? result.stderr.text : '';
|
|
338
|
+
const parsed = parseHelperOutput(stdout);
|
|
339
|
+
if (parsed === null) {
|
|
340
|
+
const tail = stderr.trim().slice(-600);
|
|
341
|
+
throw new SearchError('search-failed', 500, `搜索子进程没有返回可解析结果${result.exitCode === undefined ? '' : ` (exit ${String(result.exitCode)})`}` + (tail === '' ? '' : `:${tail}`));
|
|
342
|
+
}
|
|
343
|
+
if (parsed.ok !== true) {
|
|
344
|
+
const status = typeof parsed.status === 'number' ? parsed.status : 500;
|
|
345
|
+
const message = typeof parsed.message === 'string' ? parsed.message : '搜索失败。';
|
|
346
|
+
throw new SearchError(searchErrorCode(parsed.code), status, message);
|
|
347
|
+
}
|
|
348
|
+
const report = asReport(parsed.report);
|
|
349
|
+
if (report === null) {
|
|
350
|
+
throw new SearchError('search-failed', 500, '搜索子进程返回了无法识别的结果形状。');
|
|
351
|
+
}
|
|
352
|
+
return report;
|
|
353
|
+
}
|
|
354
|
+
const DEFAULT_TEXT_MAX_FILES = 2_000;
|
|
355
|
+
const DEFAULT_TEXT_MAX_BYTES = 16 * 1024 * 1024;
|
|
356
|
+
const DEFAULT_TEXT_MAX_FILE_BYTES = 1024 * 1024;
|
|
357
|
+
/**
|
|
358
|
+
* Bounded keyword search over the source files listed by the index.
|
|
359
|
+
*
|
|
360
|
+
* This exists for one concrete state: the host cannot start the native query path
|
|
361
|
+
* (Electron ABI, no usable shell sandbox, busy database). In that state the index
|
|
362
|
+
* manifest still names every file the index covers, and reading those files is
|
|
363
|
+
* ordinary filesystem access the host already has. It is deliberately **not** a
|
|
364
|
+
* hidden replacement for the graph search:
|
|
365
|
+
*
|
|
366
|
+
* - it searches line text, so `mode` is `text` and the panel says so;
|
|
367
|
+
* - it only looks at files the index lists, so a brand-new untracked file is
|
|
368
|
+
* still absent until the index is refreshed;
|
|
369
|
+
* - every dimension is capped and `truncated` reports when a cap bit, because a
|
|
370
|
+
* silent partial result is the failure mode this whole project exists to avoid.
|
|
371
|
+
*
|
|
372
|
+
* It performs no spawn, no native load and no write.
|
|
373
|
+
*/
|
|
374
|
+
export async function searchText(repoPath, rawQuery, options = {}) {
|
|
375
|
+
const query = rawQuery.normalize('NFC').trim().slice(0, 200);
|
|
376
|
+
if (query === '') {
|
|
377
|
+
throw new SearchError('invalid-query', 400, '搜索词不能为空。');
|
|
378
|
+
}
|
|
379
|
+
const absRepo = path.resolve(repoPath);
|
|
380
|
+
if (!(await isProjectRoot(absRepo))) {
|
|
381
|
+
throw new SearchError('not-a-project', 404, '目录不是 GitNexus 索引,也不是 git 项目根。');
|
|
382
|
+
}
|
|
383
|
+
const meta = await readIndexMeta(absRepo);
|
|
384
|
+
if (meta === null) {
|
|
385
|
+
throw new SearchError('no-index', 409, '该项目还没有 GitNexus 索引;初始化后才能搜索。');
|
|
386
|
+
}
|
|
387
|
+
const limit = clampLimit(options.limit);
|
|
388
|
+
const maxFiles = Math.max(1, Math.min(5_000, Math.floor(options.maxFiles ?? DEFAULT_TEXT_MAX_FILES)));
|
|
389
|
+
const maxBytes = Math.max(64 * 1024, Math.min(256 * 1024 * 1024, Math.floor(options.maxBytes ?? DEFAULT_TEXT_MAX_BYTES)));
|
|
390
|
+
const maxFileBytes = Math.max(8 * 1024, Math.min(16 * 1024 * 1024, Math.floor(options.maxFileBytes ?? DEFAULT_TEXT_MAX_FILE_BYTES)));
|
|
391
|
+
const requested = Object.keys(meta.fileHashes ?? {});
|
|
392
|
+
const warning = `图查询不可用${options.reason === undefined || options.reason.trim() === ''
|
|
393
|
+
? ''
|
|
394
|
+
: `(${options.reason.trim().slice(0, 120)})`};已回退到索引文件的源码关键词搜索。结果来自文本,不是图谱。`;
|
|
395
|
+
if (requested.length === 0) {
|
|
396
|
+
return {
|
|
397
|
+
query,
|
|
398
|
+
mode: 'text',
|
|
399
|
+
ftsAvailable: false,
|
|
400
|
+
results: [],
|
|
401
|
+
total: 0,
|
|
402
|
+
truncated: false,
|
|
403
|
+
warning: `图查询不可用;索引清单没有文件列表,无法回退到源码搜索。`,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
const lower = query.toLowerCase();
|
|
407
|
+
const results = [];
|
|
408
|
+
let bytesRead = 0;
|
|
409
|
+
let truncated = requested.length > maxFiles;
|
|
410
|
+
const prefix = absRepo.endsWith(path.sep) ? absRepo : `${absRepo}${path.sep}`;
|
|
411
|
+
// A keyword in a README should not fill the result limit before the source file
|
|
412
|
+
// that defines it. Code-bearing paths go first; path order breaks the tie.
|
|
413
|
+
const codePath = /\.(?:ts|tsx|js|jsx|mjs|cjs|py|java|go|rs|c|cc|cpp|h|hpp|cs|rb|php|kt|swift|dart|vue|sh|ps1|sql)$/iu;
|
|
414
|
+
const ordered = [...requested].sort((a, b) => {
|
|
415
|
+
const aCode = codePath.test(a) ? 1 : 0;
|
|
416
|
+
const bCode = codePath.test(b) ? 1 : 0;
|
|
417
|
+
return bCode - aCode || a.localeCompare(b);
|
|
418
|
+
});
|
|
419
|
+
for (const relative of ordered.slice(0, maxFiles)) {
|
|
420
|
+
if (results.length >= limit) {
|
|
421
|
+
truncated = true;
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
if (relative.includes('\0'))
|
|
425
|
+
continue;
|
|
426
|
+
const absolute = path.resolve(absRepo, relative);
|
|
427
|
+
// The manifest is written by GitNexus, but it is still data at a boundary: do
|
|
428
|
+
// not let a `../` entry turn the fallback into a read outside the project.
|
|
429
|
+
if (!absolute.startsWith(prefix))
|
|
430
|
+
continue;
|
|
431
|
+
let size;
|
|
432
|
+
try {
|
|
433
|
+
size = (await fs.stat(absolute)).size;
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (size > maxFileBytes) {
|
|
439
|
+
truncated = true;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (bytesRead + size > maxBytes) {
|
|
443
|
+
truncated = true;
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
let buffer;
|
|
447
|
+
try {
|
|
448
|
+
buffer = await fs.readFile(absolute);
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
bytesRead += buffer.length;
|
|
454
|
+
// Binary files have no meaningful line text; git's own NUL-byte rule is enough.
|
|
455
|
+
if (buffer.subarray(0, 8192).includes(0))
|
|
456
|
+
continue;
|
|
457
|
+
const text = buffer.toString('utf8');
|
|
458
|
+
if (!text.toLowerCase().includes(lower))
|
|
459
|
+
continue;
|
|
460
|
+
const lines = text.split(/\r?\n/);
|
|
461
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
462
|
+
if (results.length >= limit) {
|
|
463
|
+
truncated = true;
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
const line = lines[index];
|
|
467
|
+
if (!line.toLowerCase().includes(lower))
|
|
468
|
+
continue;
|
|
469
|
+
results.push({
|
|
470
|
+
kind: 'Text',
|
|
471
|
+
name: line.trim().slice(0, 160) || query,
|
|
472
|
+
filePath: relative.replace(/\\/g, '/'),
|
|
473
|
+
startLine: index + 1,
|
|
474
|
+
endLine: index + 1,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
query,
|
|
480
|
+
mode: 'text',
|
|
481
|
+
ftsAvailable: false,
|
|
482
|
+
results,
|
|
483
|
+
total: results.length,
|
|
484
|
+
truncated,
|
|
485
|
+
warning,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
/** Failures for which the source fallback is more useful than an error card. */
|
|
489
|
+
const FALLBACK_CODES = new Set([
|
|
490
|
+
'search-unavailable',
|
|
491
|
+
'search-failed',
|
|
492
|
+
'index-busy',
|
|
493
|
+
'no-index-store',
|
|
494
|
+
]);
|
|
495
|
+
/**
|
|
496
|
+
* Graph first, then the bounded source fallback when the native path cannot run.
|
|
497
|
+
*
|
|
498
|
+
* The fallback is not attempted for an empty query, an unidentified project, or a
|
|
499
|
+
* project with no index: in all three there is either nothing to search or no honest
|
|
500
|
+
* denominator. Every other failure gets the text search, and its report carries
|
|
501
|
+
* `mode: "text"` plus the graph failure in `warning`.
|
|
502
|
+
*/
|
|
503
|
+
export async function searchWithFallback(repoPath, rawQuery, options) {
|
|
504
|
+
try {
|
|
505
|
+
return await searchViaHelper(repoPath, rawQuery, options);
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
if (error instanceof SearchError && FALLBACK_CODES.has(error.code)) {
|
|
509
|
+
return searchText(repoPath, rawQuery, {
|
|
510
|
+
limit: options.limit,
|
|
511
|
+
reason: error.message,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
throw error;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
//# sourceMappingURL=search.js.map
|