sift-light 1.0.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.
- package/CHANGELOG.md +0 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +661 -0
- package/README.md +177 -0
- package/README.zh-CN.md +177 -0
- package/SECURITY.md +25 -0
- package/package.json +142 -0
- package/plugins/sift-light/.claude-plugin/plugin.json +10 -0
- package/plugins/sift-light/.codex-plugin/plugin.json +20 -0
- package/plugins/sift-light/.mcp.json +24 -0
- package/plugins/sift-light/LICENSE +661 -0
- package/plugins/sift-light/concept-worker.mjs +567 -0
- package/plugins/sift-light/hooks/THIRD_PARTY_NOTICES.txt +70 -0
- package/plugins/sift-light/hooks/hooks.json +16 -0
- package/plugins/sift-light/hooks/search-policy.mjs +4113 -0
- package/plugins/sift-light/hooks/tree-sitter-bash.wasm +0 -0
- package/plugins/sift-light/hooks/tree-sitter-powershell.wasm +0 -0
- package/plugins/sift-light/hooks/tree-sitter.wasm +0 -0
- package/plugins/sift-light/kimi.plugin.json +36 -0
- package/plugins/sift-light/mcp-server.mjs +1 -0
- package/plugins/sift-light/omp-extension.mjs +20914 -0
- package/plugins/sift-light/package.json +11 -0
- package/plugins/sift-light/syntax-worker.mjs +280 -0
- package/plugins/sift-light/syntax-worker.toml +2 -0
- package/src/analysis-evidence.ts +43 -0
- package/src/analysis-limits.ts +27 -0
- package/src/analysis-store.ts +559 -0
- package/src/analysis-term-pages.ts +73 -0
- package/src/analysis-types.ts +162 -0
- package/src/audit-search.ts +391 -0
- package/src/capped-lines.ts +112 -0
- package/src/concept-embedding-cache.ts +186 -0
- package/src/concept-inference.ts +218 -0
- package/src/concept-model.ts +96 -0
- package/src/concept-search.ts +577 -0
- package/src/concept-setup.ts +75 -0
- package/src/concept-source-generation.ts +268 -0
- package/src/concept-worker.mjs +567 -0
- package/src/concept-worker.ts +210 -0
- package/src/config-reader.ts +283 -0
- package/src/config.ts +37 -0
- package/src/context-budget.ts +26 -0
- package/src/discovery-errors.ts +2 -0
- package/src/errors.ts +95 -0
- package/src/evidence-candidates.ts +344 -0
- package/src/evidence-ranking.ts +48 -0
- package/src/evidence-service.ts +1047 -0
- package/src/evidence-validation.ts +466 -0
- package/src/evidence-validity.ts +33 -0
- package/src/excerpt.ts +53 -0
- package/src/file-discovery.ts +150 -0
- package/src/file-metadata-filter.ts +46 -0
- package/src/format.ts +407 -0
- package/src/git-diff.ts +195 -0
- package/src/git-process.ts +189 -0
- package/src/git-repository.ts +367 -0
- package/src/git-source.ts +385 -0
- package/src/historical-paths.ts +205 -0
- package/src/hybrid-search.ts +340 -0
- package/src/import-model.ts +611 -0
- package/src/import-navigation.ts +100 -0
- package/src/import-resolution.ts +240 -0
- package/src/index.ts +97 -0
- package/src/inspect-batch.ts +265 -0
- package/src/inspect.ts +271 -0
- package/src/language-capabilities.ts +157 -0
- package/src/language-capability-definitions.ts +204 -0
- package/src/literal-search.ts +25 -0
- package/src/mcp-cli.ts +21 -0
- package/src/mcp-model-output.ts +120 -0
- package/src/mcp-output.ts +9 -0
- package/src/mcp-semantic-judge.ts +39 -0
- package/src/mcp-server.mjs +14064 -0
- package/src/mcp-server.ts +157 -0
- package/src/mcp-stdio.ts +114 -0
- package/src/mcp.ts +631 -0
- package/src/model-error.ts +120 -0
- package/src/multi-term-search.ts +122 -0
- package/src/omp-index.ts +277 -0
- package/src/omp-marketplace-entry.ts +7 -0
- package/src/operation-lifecycle.ts +413 -0
- package/src/operation-output.ts +100 -0
- package/src/owned-parallel.ts +22 -0
- package/src/owned-process.ts +102 -0
- package/src/owned-task-queue.ts +38 -0
- package/src/package-version.ts +3 -0
- package/src/path-policy.ts +203 -0
- package/src/pi-search-policy.ts +33 -0
- package/src/prompt-guidelines.ts +54 -0
- package/src/python-outline.ts +231 -0
- package/src/record-value.ts +3 -0
- package/src/redaction.ts +95 -0
- package/src/request-contract-catalog.ts +215 -0
- package/src/request-contract-error.ts +96 -0
- package/src/request-contract.ts +481 -0
- package/src/request.ts +132 -0
- package/src/result-statistics.ts +178 -0
- package/src/rg.ts +574 -0
- package/src/ripgrep-diagnostics.ts +122 -0
- package/src/ripgrep-executable.ts +43 -0
- package/src/runtime.ts +53 -0
- package/src/scan-revisions.ts +148 -0
- package/src/script-runtime.ts +11 -0
- package/src/search-policy-commands.ts +170 -0
- package/src/search-policy-hook.ts +61 -0
- package/src/search-policy-recovery.ts +388 -0
- package/src/search-policy-shell.ts +280 -0
- package/src/search-policy.ts +102 -0
- package/src/search-retention.ts +77 -0
- package/src/semantic-judge-batches.ts +135 -0
- package/src/semantic-judge.ts +519 -0
- package/src/service.ts +945 -0
- package/src/session-summary.ts +100 -0
- package/src/snapshot-store.ts +203 -0
- package/src/source-access.ts +288 -0
- package/src/source-continuations.ts +168 -0
- package/src/source-document.ts +277 -0
- package/src/source-inspection.ts +731 -0
- package/src/source-pages.ts +112 -0
- package/src/source.ts +286 -0
- package/src/structural-search.ts +125 -0
- package/src/structure.ts +259 -0
- package/src/summary-previews.ts +96 -0
- package/src/summary.ts +79 -0
- package/src/syntax-facts.ts +464 -0
- package/src/syntax-fields.ts +161 -0
- package/src/syntax-search.ts +361 -0
- package/src/syntax-tree.ts +30 -0
- package/src/syntax-types.ts +72 -0
- package/src/syntax-worker.mjs +280 -0
- package/src/syntax-worker.toml +2 -0
- package/src/syntax-worker.ts +143 -0
- package/src/syntax.ts +259 -0
- package/src/test-navigation-facts.ts +261 -0
- package/src/test-navigation.ts +543 -0
- package/src/tool-schema.ts +321 -0
- package/src/tui/dashboard.ts +107 -0
- package/src/tui/layout.ts +291 -0
- package/src/tui/palette.ts +73 -0
- package/src/tui/presentation.ts +240 -0
- package/src/tui/renderers.ts +106 -0
- package/src/types.ts +368 -0
- package/src/validation-output.ts +148 -0
- package/src/validation-types.ts +48 -0
- package/src/workspace-files.ts +142 -0
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
import {
|
|
2
|
+
conceptSearch,
|
|
3
|
+
type ConceptSearchExecution,
|
|
4
|
+
type ConceptSearchRunner,
|
|
5
|
+
validateConceptQuery,
|
|
6
|
+
} from "./concept-search.js";
|
|
7
|
+
import { structuralSearch } from "./structural-search.js";
|
|
8
|
+
import { dirname, extname, resolve } from "node:path";
|
|
9
|
+
import { AnalysisStore } from "./analysis-store.js";
|
|
10
|
+
import type { AnalysisItem, AnalysisResultSet } from "./analysis-types.js";
|
|
11
|
+
import { abortError, CursorError, SiftLightError } from "./errors.js";
|
|
12
|
+
import { findGitRepository } from "./git-repository.js";
|
|
13
|
+
import { isPathInsideCwd, isPathInsideRoot } from "./path-policy.js";
|
|
14
|
+
import { resolveInspectionTarget } from "./inspect.js";
|
|
15
|
+
import {
|
|
16
|
+
collectEvidenceCandidates,
|
|
17
|
+
type EvidenceCandidateFile,
|
|
18
|
+
type EvidenceCandidates,
|
|
19
|
+
} from "./evidence-candidates.js";
|
|
20
|
+
import { listWorkspaceFiles, workspaceRelativePath } from "./workspace-files.js";
|
|
21
|
+
import { navigateImports } from "./import-navigation.js";
|
|
22
|
+
import { findRelatedTests, isLikelyTestPath, TEST_DISCOVERY_PATTERN } from "./test-navigation.js";
|
|
23
|
+
import { escapeRegexLiteral, literalOccurrences } from "./literal-search.js";
|
|
24
|
+
import {
|
|
25
|
+
expandMultiTermCandidates,
|
|
26
|
+
retainedTermCounts,
|
|
27
|
+
validateAnyOf,
|
|
28
|
+
} from "./multi-term-search.js";
|
|
29
|
+
import { normalizeRequest } from "./request.js";
|
|
30
|
+
import { runOwnedParallel } from "./owned-parallel.js";
|
|
31
|
+
import { discoverFiles } from "./file-discovery.js";
|
|
32
|
+
import type { RipgrepRunner } from "./rg.js";
|
|
33
|
+
import type { SiftLightInput } from "./service.js";
|
|
34
|
+
import type { SnapshotStore } from "./snapshot-store.js";
|
|
35
|
+
import { SourceAccess, SourceBudgetError, SyntaxQueue } from "./source-access.js";
|
|
36
|
+
import { SourceContinuations } from "./source-continuations.js";
|
|
37
|
+
import { type ByteRange, type SourceDocument, type SourceReference } from "./source-document.js";
|
|
38
|
+
import {
|
|
39
|
+
continueSource,
|
|
40
|
+
inspectDocuments,
|
|
41
|
+
matchInspectionTarget,
|
|
42
|
+
inspectDocumentsMetadata,
|
|
43
|
+
type SourceInspectionTarget,
|
|
44
|
+
} from "./source-inspection.js";
|
|
45
|
+
import { validateSavedEvidence } from "./evidence-validation.js";
|
|
46
|
+
import { sameSourceRevision } from "./source.js";
|
|
47
|
+
import type { CodeStructureProvider } from "./structure.js";
|
|
48
|
+
import { filterRoleOccurrences, findFunctionConjunctions } from "./syntax-search.js";
|
|
49
|
+
import { syntaxLanguage } from "./syntax.js";
|
|
50
|
+
import { parsePythonOutline } from "./python-outline.js";
|
|
51
|
+
import {
|
|
52
|
+
combineHybridSearch,
|
|
53
|
+
hybridConceptLimit,
|
|
54
|
+
retainedHybridCounts,
|
|
55
|
+
sameHybridLiteralScan,
|
|
56
|
+
HybridSourceChangedError,
|
|
57
|
+
} from "./hybrid-search.js";
|
|
58
|
+
import { validationResult } from "./validation-output.js";
|
|
59
|
+
import type { OperationProgress } from "./operation-lifecycle.js";
|
|
60
|
+
import { outlineCapabilityError } from "./request-contract.js";
|
|
61
|
+
import { realpath } from "node:fs/promises";
|
|
62
|
+
import type { SemanticJudgeIntegration } from "./semantic-judge.js";
|
|
63
|
+
import {
|
|
64
|
+
MAX_ANY_OF_TERMS,
|
|
65
|
+
MAX_CONFIGURABLE_STRUCTURE_FILES,
|
|
66
|
+
MAX_STRUCTURE_FILES,
|
|
67
|
+
} from "./analysis-limits.js";
|
|
68
|
+
import {
|
|
69
|
+
MAX_INSPECT_TARGETS,
|
|
70
|
+
type SearchRequest,
|
|
71
|
+
type SearchScopeDetails,
|
|
72
|
+
type SiftLightResult,
|
|
73
|
+
} from "./types.js";
|
|
74
|
+
|
|
75
|
+
export function isEvidenceRequest(input: SiftLightInput): boolean {
|
|
76
|
+
return (
|
|
77
|
+
input.mode === "concept" ||
|
|
78
|
+
input.mode === "hybrid" ||
|
|
79
|
+
input.mode === "structure" ||
|
|
80
|
+
input.mode === "files" ||
|
|
81
|
+
input.mode === "inspect" ||
|
|
82
|
+
input.mode === "outline" ||
|
|
83
|
+
input.mode === "imports" ||
|
|
84
|
+
input.mode === "tests" ||
|
|
85
|
+
input.mode === "validate" ||
|
|
86
|
+
input.sourceCursor !== undefined ||
|
|
87
|
+
input.anyOf !== undefined ||
|
|
88
|
+
input.allOf !== undefined ||
|
|
89
|
+
input.within !== undefined ||
|
|
90
|
+
input.roles !== undefined ||
|
|
91
|
+
input.changes !== undefined ||
|
|
92
|
+
input.symbol !== undefined ||
|
|
93
|
+
input.conceptLimit !== undefined ||
|
|
94
|
+
(input.cursor?.includes(".analysis") ?? false)
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface EvidenceSearchOptions {
|
|
99
|
+
modelOutput?: boolean;
|
|
100
|
+
onProgress?: (progress: OperationProgress) => void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function maxFilesToParse(value: number | undefined, defaultValue = MAX_STRUCTURE_FILES): number {
|
|
104
|
+
const candidate = value ?? defaultValue;
|
|
105
|
+
if (
|
|
106
|
+
!Number.isSafeInteger(candidate) ||
|
|
107
|
+
candidate < 1 ||
|
|
108
|
+
candidate > MAX_CONFIGURABLE_STRUCTURE_FILES
|
|
109
|
+
) {
|
|
110
|
+
throw new SiftLightError(
|
|
111
|
+
`maxFilesToParse must be an integer from 1 through ${String(MAX_CONFIGURABLE_STRUCTURE_FILES)}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return candidate;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function validateTerms(input: SiftLightInput): string[] | undefined {
|
|
118
|
+
const terms = input.allOf;
|
|
119
|
+
if (terms === undefined) {
|
|
120
|
+
if (input.within !== undefined) {
|
|
121
|
+
throw new SiftLightError(
|
|
122
|
+
"within is only valid with allOf; omit within for ordinary single-pattern searches",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
!Array.isArray(terms) ||
|
|
129
|
+
terms.length < 2 ||
|
|
130
|
+
terms.length > 3 ||
|
|
131
|
+
terms.some((term) => typeof term !== "string" || !term.trim() || /[\r\n\0]/.test(term)) ||
|
|
132
|
+
new Set(terms).size !== terms.length
|
|
133
|
+
)
|
|
134
|
+
throw new SiftLightError("allOf requires 2–3 distinct, nonempty, single-line literal terms");
|
|
135
|
+
if (
|
|
136
|
+
input.pattern !== undefined ||
|
|
137
|
+
input.roles !== undefined ||
|
|
138
|
+
input.literal !== undefined ||
|
|
139
|
+
input.ignoreCase !== undefined ||
|
|
140
|
+
input.wholeWord !== undefined
|
|
141
|
+
)
|
|
142
|
+
throw new SiftLightError(
|
|
143
|
+
"allOf is an explicit case-sensitive literal conjunction; omit pattern, roles, literal and ignoreCase",
|
|
144
|
+
);
|
|
145
|
+
if (input.within !== undefined && input.within !== "file" && input.within !== "function")
|
|
146
|
+
throw new SiftLightError("within must be file or function");
|
|
147
|
+
return terms;
|
|
148
|
+
}
|
|
149
|
+
function fileConjunction(
|
|
150
|
+
document: SourceDocument,
|
|
151
|
+
terms: string[],
|
|
152
|
+
allowed?: ByteRange[],
|
|
153
|
+
): AnalysisItem | undefined {
|
|
154
|
+
const evidence = terms.map((term) => ({
|
|
155
|
+
term,
|
|
156
|
+
ranges: literalOccurrences(document, term, allowed),
|
|
157
|
+
}));
|
|
158
|
+
if (evidence.some((item) => !item.ranges.length)) return undefined;
|
|
159
|
+
const first = evidence[0]?.ranges[0];
|
|
160
|
+
if (!first) throw new Error("Conjunction evidence unavailable");
|
|
161
|
+
return {
|
|
162
|
+
path: document.path,
|
|
163
|
+
line: document.lineAt(first.start),
|
|
164
|
+
label: "All terms occur in this file; no cross-file or execution-path claim",
|
|
165
|
+
source: document.reference,
|
|
166
|
+
range: first,
|
|
167
|
+
details: {
|
|
168
|
+
terms: evidence.map((item) => ({
|
|
169
|
+
term: item.term,
|
|
170
|
+
occurrences: item.ranges.length,
|
|
171
|
+
evidence: item.ranges.slice(0, 3).map((range) => ({
|
|
172
|
+
start: range.start,
|
|
173
|
+
end: range.end,
|
|
174
|
+
line: document.lineAt(range.start),
|
|
175
|
+
text: document.slice(document.lineRange(document.lineAt(range.start))).slice(0, 500),
|
|
176
|
+
})),
|
|
177
|
+
})),
|
|
178
|
+
scope: allowed ? "changed-lines" : "file",
|
|
179
|
+
unit: "files",
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function searchScope(request: SearchRequest): SearchScopeDetails {
|
|
185
|
+
const path = request.path ?? ".";
|
|
186
|
+
const requestedPath = request.expandedFromPath ?? path;
|
|
187
|
+
return {
|
|
188
|
+
path,
|
|
189
|
+
requestedPath,
|
|
190
|
+
glob: [...request.glob],
|
|
191
|
+
exclude: [...request.exclude],
|
|
192
|
+
hidden: request.hidden,
|
|
193
|
+
ignorePolicy: request.ignorePolicy ?? "respect",
|
|
194
|
+
expandedToProjectRoot: request.expandedFromPath !== undefined,
|
|
195
|
+
assertion: path === "." ? "project-wide" : "requested-scope",
|
|
196
|
+
...(request.modifiedAfterMs !== undefined ? { modifiedAfterMs: request.modifiedAfterMs } : {}),
|
|
197
|
+
...(request.modifiedBeforeMs !== undefined
|
|
198
|
+
? { modifiedBeforeMs: request.modifiedBeforeMs }
|
|
199
|
+
: {}),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function navigationRoot(cwd: string, path: string, signal?: AbortSignal): Promise<string> {
|
|
204
|
+
const absolute = resolve(cwd, path);
|
|
205
|
+
const repository = await findGitRepository(dirname(absolute), signal);
|
|
206
|
+
if (repository) return repository;
|
|
207
|
+
return isPathInsideCwd(absolute, cwd) ? resolve(cwd) : dirname(absolute);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
type NavigationFilters = Pick<SearchRequest, "glob" | "exclude" | "hidden">;
|
|
211
|
+
|
|
212
|
+
function navigationFilters(input: SiftLightInput): NavigationFilters {
|
|
213
|
+
const request = normalizeRequest({
|
|
214
|
+
pattern: "",
|
|
215
|
+
...(input.glob !== undefined ? { glob: input.glob } : {}),
|
|
216
|
+
...(input.exclude !== undefined ? { exclude: input.exclude } : {}),
|
|
217
|
+
...(input.hidden !== undefined ? { hidden: input.hidden } : {}),
|
|
218
|
+
});
|
|
219
|
+
return { glob: request.glob, exclude: request.exclude, hidden: request.hidden };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function navigationScope(
|
|
223
|
+
cwd: string,
|
|
224
|
+
root: string,
|
|
225
|
+
requestedPath: string,
|
|
226
|
+
filters: NavigationFilters,
|
|
227
|
+
): Promise<SearchScopeDetails> {
|
|
228
|
+
const [canonicalCwd, canonicalRoot] = await Promise.all([
|
|
229
|
+
realpath(resolve(cwd)).catch(() => resolve(cwd)),
|
|
230
|
+
realpath(root).catch(() => resolve(root)),
|
|
231
|
+
]);
|
|
232
|
+
const isProjectRoot = canonicalRoot === canonicalCwd;
|
|
233
|
+
return {
|
|
234
|
+
path: isProjectRoot ? "." : canonicalRoot,
|
|
235
|
+
requestedPath,
|
|
236
|
+
glob: [...filters.glob],
|
|
237
|
+
exclude: [...filters.exclude],
|
|
238
|
+
hidden: filters.hidden,
|
|
239
|
+
ignorePolicy: "respect",
|
|
240
|
+
expandedToProjectRoot: false,
|
|
241
|
+
assertion: isProjectRoot ? "project-wide" : "requested-scope",
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function canonicalNavigationPath(path: string): Promise<string> {
|
|
246
|
+
return realpath(path).catch(() => resolve(path));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function canonicalNavigationFiles(
|
|
250
|
+
cwd: string,
|
|
251
|
+
root: string,
|
|
252
|
+
files: { paths: readonly string[] },
|
|
253
|
+
primaryPath: string,
|
|
254
|
+
): Promise<{ allowed: ReadonlySet<string>; primaryPath: string }> {
|
|
255
|
+
const canonicalRoot = await canonicalNavigationPath(root);
|
|
256
|
+
const [enumerated, canonicalPrimary] = await Promise.all([
|
|
257
|
+
Promise.all(files.paths.map((file) => canonicalNavigationPath(resolve(cwd, file)))),
|
|
258
|
+
canonicalNavigationPath(resolve(cwd, primaryPath)),
|
|
259
|
+
]);
|
|
260
|
+
const allowed = new Set(enumerated.filter((path) => isPathInsideRoot(path, canonicalRoot)));
|
|
261
|
+
if (isPathInsideRoot(canonicalPrimary, canonicalRoot)) allowed.add(canonicalPrimary);
|
|
262
|
+
return { allowed, primaryPath: canonicalPrimary };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export class EvidenceService {
|
|
266
|
+
readonly #runner: RipgrepRunner;
|
|
267
|
+
readonly #snapshots: SnapshotStore;
|
|
268
|
+
readonly #structure: CodeStructureProvider | undefined;
|
|
269
|
+
readonly #conceptSearch: ConceptSearchRunner;
|
|
270
|
+
readonly #semanticJudge: SemanticJudgeIntegration | undefined;
|
|
271
|
+
readonly #queue = new SyntaxQueue();
|
|
272
|
+
readonly #analyses = new AnalysisStore();
|
|
273
|
+
readonly #continuations = new SourceContinuations();
|
|
274
|
+
constructor(
|
|
275
|
+
runner: RipgrepRunner,
|
|
276
|
+
snapshots: SnapshotStore,
|
|
277
|
+
structure?: CodeStructureProvider,
|
|
278
|
+
runConceptSearch: ConceptSearchRunner = conceptSearch,
|
|
279
|
+
semanticJudge?: SemanticJudgeIntegration,
|
|
280
|
+
) {
|
|
281
|
+
this.#runner = runner;
|
|
282
|
+
this.#snapshots = snapshots;
|
|
283
|
+
this.#structure = structure;
|
|
284
|
+
this.#conceptSearch = runConceptSearch;
|
|
285
|
+
this.#semanticJudge = semanticJudge;
|
|
286
|
+
}
|
|
287
|
+
clear(): void {
|
|
288
|
+
this.#analyses.clear();
|
|
289
|
+
this.#continuations.clear();
|
|
290
|
+
this.#queue.clear();
|
|
291
|
+
}
|
|
292
|
+
async shutdown(): Promise<void> {
|
|
293
|
+
this.clear();
|
|
294
|
+
await this.#queue.shutdown();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async #validateSavedEvidence(
|
|
298
|
+
input: SiftLightInput,
|
|
299
|
+
cwd: string,
|
|
300
|
+
signal?: AbortSignal,
|
|
301
|
+
): Promise<SiftLightResult> {
|
|
302
|
+
const cursor = input.cursor;
|
|
303
|
+
if (!cursor) throw new SiftLightError("A saved evidence cursor is required");
|
|
304
|
+
const startedAt = Date.now();
|
|
305
|
+
const validated = await validateSavedEvidence({
|
|
306
|
+
cursor,
|
|
307
|
+
cwd,
|
|
308
|
+
...(signal ? { signal } : {}),
|
|
309
|
+
...(input.matchIndex !== undefined ? { matchIndex: input.matchIndex } : {}),
|
|
310
|
+
analyses: this.#analyses,
|
|
311
|
+
snapshots: this.#snapshots,
|
|
312
|
+
queue: this.#queue,
|
|
313
|
+
maxFiles: MAX_STRUCTURE_FILES,
|
|
314
|
+
});
|
|
315
|
+
const finishedAt = Date.now();
|
|
316
|
+
return validationResult(validated, cursor, {
|
|
317
|
+
start: startedAt,
|
|
318
|
+
end: finishedAt,
|
|
319
|
+
...(input.matchIndex !== undefined ? { selected: input.matchIndex } : {}),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async #testEntryPaths(
|
|
324
|
+
root: string,
|
|
325
|
+
files: readonly string[],
|
|
326
|
+
cwd: string,
|
|
327
|
+
filters: NavigationFilters,
|
|
328
|
+
signal?: AbortSignal,
|
|
329
|
+
): Promise<string[]> {
|
|
330
|
+
const sourceGlobs = ["*.js", "*.jsx", "*.mjs", "*.cjs", "*.ts", "*.tsx", "*.mts", "*.cts"];
|
|
331
|
+
const request = normalizeRequest({
|
|
332
|
+
pattern: TEST_DISCOVERY_PATTERN,
|
|
333
|
+
path: root,
|
|
334
|
+
glob: filters.glob.length ? filters.glob : sourceGlobs,
|
|
335
|
+
exclude: filters.exclude,
|
|
336
|
+
hidden: filters.hidden,
|
|
337
|
+
ignoreCase: false,
|
|
338
|
+
});
|
|
339
|
+
const scan = await this.#runner(request, cwd, signal);
|
|
340
|
+
const contentCandidates = new Set(scan.fileCounts.keys());
|
|
341
|
+
return files.filter(
|
|
342
|
+
(path) => isLikelyTestPath(path) || contentCandidates.has(workspaceRelativePath(cwd, path)),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async #candidates(
|
|
347
|
+
request: SearchRequest,
|
|
348
|
+
input: SiftLightInput,
|
|
349
|
+
access: SourceAccess,
|
|
350
|
+
): Promise<{ candidates: EvidenceCandidates; request: SearchRequest }> {
|
|
351
|
+
const collect = (candidateRequest: SearchRequest) =>
|
|
352
|
+
collectEvidenceCandidates({
|
|
353
|
+
request: candidateRequest,
|
|
354
|
+
...(input.changes ? { changes: input.changes } : {}),
|
|
355
|
+
cwd: access.cwd,
|
|
356
|
+
...(access.signal ? { signal: access.signal } : {}),
|
|
357
|
+
access,
|
|
358
|
+
runRipgrep: this.#runner,
|
|
359
|
+
maxFiles: access.maxFiles,
|
|
360
|
+
});
|
|
361
|
+
const candidates = await collect(request);
|
|
362
|
+
if (
|
|
363
|
+
input.changes ||
|
|
364
|
+
request.scope === "strict" ||
|
|
365
|
+
request.path === undefined ||
|
|
366
|
+
candidates.files.length > 0 ||
|
|
367
|
+
candidates.partial
|
|
368
|
+
) {
|
|
369
|
+
return { candidates, request };
|
|
370
|
+
}
|
|
371
|
+
const { path: requestedPath, ...projectRequest } = request;
|
|
372
|
+
const expandedRequest = { ...projectRequest, expandedFromPath: requestedPath };
|
|
373
|
+
return { candidates: await collect(expandedRequest), request: expandedRequest };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async search(
|
|
377
|
+
input: SiftLightInput,
|
|
378
|
+
cwd: string,
|
|
379
|
+
signal?: AbortSignal,
|
|
380
|
+
options: EvidenceSearchOptions = {},
|
|
381
|
+
): Promise<SiftLightResult> {
|
|
382
|
+
if (signal?.aborted) throw abortError();
|
|
383
|
+
if (input.changes && (input.modifiedAfter !== undefined || input.modifiedBefore !== undefined))
|
|
384
|
+
throw new SiftLightError(
|
|
385
|
+
"modifiedAfter and modifiedBefore apply to worktree searches and cannot be combined with changes",
|
|
386
|
+
);
|
|
387
|
+
const analysisStarted = performance.now();
|
|
388
|
+
const automaticConceptLimit =
|
|
389
|
+
input.mode === "concept" || input.mode === "hybrid"
|
|
390
|
+
? MAX_CONFIGURABLE_STRUCTURE_FILES
|
|
391
|
+
: MAX_STRUCTURE_FILES;
|
|
392
|
+
const fileLimit = maxFilesToParse(input.maxFilesToParse, automaticConceptLimit);
|
|
393
|
+
const access = new SourceAccess(cwd, this.#queue, signal, { maxFiles: fileLimit });
|
|
394
|
+
if (input.mode === "validate") return this.#validateSavedEvidence(input, cwd, signal);
|
|
395
|
+
if (input.sourceCursor !== undefined) {
|
|
396
|
+
if (typeof input.sourceCursor !== "string" || !input.sourceCursor.trim())
|
|
397
|
+
throw new CursorError("A nonempty sourceCursor is required");
|
|
398
|
+
if (input.mode !== "inspect") throw new SiftLightError("sourceCursor requires mode=inspect");
|
|
399
|
+
return continueSource(input.sourceCursor, access, this.#continuations);
|
|
400
|
+
}
|
|
401
|
+
if (input.mode === "inspect") {
|
|
402
|
+
const targets = this.#inspectionTargets(input, cwd);
|
|
403
|
+
return targets.some((target) => target.range !== undefined)
|
|
404
|
+
? inspectDocumentsMetadata(targets, access, this.#structure)
|
|
405
|
+
: inspectDocuments(targets, access, this.#continuations, this.#structure);
|
|
406
|
+
}
|
|
407
|
+
if (input.mode === "concept") {
|
|
408
|
+
const execution = await this.#conceptSearch(input, access, options.onProgress);
|
|
409
|
+
return this.#analyses.page(this.#analyses.create(execution.analysis), options.modelOutput);
|
|
410
|
+
}
|
|
411
|
+
if (input.mode === "hybrid") {
|
|
412
|
+
const query = validateConceptQuery(input.query);
|
|
413
|
+
const limit = hybridConceptLimit(input.conceptLimit);
|
|
414
|
+
const literalRequest = normalizeRequest({
|
|
415
|
+
pattern: query,
|
|
416
|
+
...(input.path !== undefined ? { path: input.path } : {}),
|
|
417
|
+
...(input.glob !== undefined ? { glob: input.glob } : {}),
|
|
418
|
+
...(input.exclude !== undefined ? { exclude: input.exclude } : {}),
|
|
419
|
+
...(input.hidden !== undefined ? { hidden: input.hidden } : {}),
|
|
420
|
+
literal: true,
|
|
421
|
+
scope: "strict",
|
|
422
|
+
redact: input.redact ?? false,
|
|
423
|
+
});
|
|
424
|
+
let literalResult: Awaited<ReturnType<RipgrepRunner>> | undefined;
|
|
425
|
+
let conceptResult: ConceptSearchExecution | undefined;
|
|
426
|
+
let conceptAccess: SourceAccess | undefined;
|
|
427
|
+
let conceptFailure: unknown;
|
|
428
|
+
options.onProgress?.({ phase: "literal-search" });
|
|
429
|
+
await runOwnedParallel<void>((groupSignal) => {
|
|
430
|
+
const ownedConceptAccess = new SourceAccess(cwd, this.#queue, groupSignal, {
|
|
431
|
+
maxFiles: fileLimit,
|
|
432
|
+
});
|
|
433
|
+
conceptAccess = ownedConceptAccess;
|
|
434
|
+
const literalOperation = this.#runner(literalRequest, cwd, groupSignal).then((result) => {
|
|
435
|
+
literalResult = result;
|
|
436
|
+
return new Set(result.matches.map((match) => resolve(match.absolutePath)));
|
|
437
|
+
});
|
|
438
|
+
return [
|
|
439
|
+
literalOperation.then(() => undefined),
|
|
440
|
+
this.#conceptSearch(input, ownedConceptAccess, options.onProgress, {
|
|
441
|
+
retainPaths: literalOperation,
|
|
442
|
+
verifySourceGeneration: false,
|
|
443
|
+
})
|
|
444
|
+
.then((result) => {
|
|
445
|
+
conceptResult = result;
|
|
446
|
+
return undefined;
|
|
447
|
+
})
|
|
448
|
+
.catch((error: unknown) => {
|
|
449
|
+
if (signal?.aborted || groupSignal.aborted) throw error;
|
|
450
|
+
conceptFailure = error;
|
|
451
|
+
return undefined;
|
|
452
|
+
}),
|
|
453
|
+
];
|
|
454
|
+
}, signal);
|
|
455
|
+
if (!literalResult || !conceptAccess)
|
|
456
|
+
throw new Error("Hybrid search did not settle its owned literal operation");
|
|
457
|
+
if (!conceptResult) {
|
|
458
|
+
if (conceptFailure instanceof Error) throw conceptFailure;
|
|
459
|
+
throw new SiftLightError("Concept search failed without a diagnostic");
|
|
460
|
+
}
|
|
461
|
+
const firstLiteralResult = literalResult;
|
|
462
|
+
const verifiedLiteralResult = await this.#runner(literalRequest, cwd, signal);
|
|
463
|
+
if (!sameHybridLiteralScan(firstLiteralResult, verifiedLiteralResult)) {
|
|
464
|
+
throw new HybridSourceChangedError(
|
|
465
|
+
"Literal source evidence changed while concept evidence was being computed",
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
const hybrid = await combineHybridSearch(
|
|
469
|
+
verifiedLiteralResult,
|
|
470
|
+
conceptResult,
|
|
471
|
+
conceptAccess,
|
|
472
|
+
limit,
|
|
473
|
+
query,
|
|
474
|
+
this.#semanticJudge,
|
|
475
|
+
signal,
|
|
476
|
+
);
|
|
477
|
+
const originalCounts = hybrid.counts ?? {};
|
|
478
|
+
const cursor = this.#analyses.create(hybrid, (items) => ({
|
|
479
|
+
counts: retainedHybridCounts(originalCounts, items),
|
|
480
|
+
}));
|
|
481
|
+
return this.#analyses.page(cursor, options.modelOutput);
|
|
482
|
+
}
|
|
483
|
+
if (input.mode === "structure") {
|
|
484
|
+
return this.#analyses.page(
|
|
485
|
+
this.#analyses.create(await structuralSearch(input, access)),
|
|
486
|
+
options.modelOutput,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
if (input.mode === "files") {
|
|
490
|
+
return this.#analyses.page(
|
|
491
|
+
this.#analyses.create(await discoverFiles(input, cwd, signal)),
|
|
492
|
+
options.modelOutput,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
if (input.cursor?.includes(".analysis") && !input.mode?.match(/^(outline|imports|tests)$/)) {
|
|
496
|
+
this.#analyses.resolve(input.cursor);
|
|
497
|
+
if (input.mode !== undefined && input.mode !== "matches" && input.mode !== "auto")
|
|
498
|
+
throw new CursorError(
|
|
499
|
+
"Analysis cursor cannot continue in the requested mode",
|
|
500
|
+
"E_CURSOR_WRONG_KIND",
|
|
501
|
+
);
|
|
502
|
+
return this.#analyses.page(input.cursor, options.modelOutput);
|
|
503
|
+
}
|
|
504
|
+
if (input.mode === "outline" || input.mode === "imports" || input.mode === "tests")
|
|
505
|
+
return this.#navigate(input, access, options);
|
|
506
|
+
const anyOf = validateAnyOf(input.anyOf);
|
|
507
|
+
if (anyOf) {
|
|
508
|
+
if (
|
|
509
|
+
input.pattern !== undefined ||
|
|
510
|
+
input.allOf !== undefined ||
|
|
511
|
+
input.within !== undefined ||
|
|
512
|
+
input.roles !== undefined ||
|
|
513
|
+
input.literal !== undefined ||
|
|
514
|
+
input.ignoreCase !== undefined ||
|
|
515
|
+
input.wholeWord !== undefined
|
|
516
|
+
)
|
|
517
|
+
throw new SiftLightError(
|
|
518
|
+
"anyOf is an explicit case-sensitive literal union; omit pattern, allOf, within, roles, literal and ignoreCase",
|
|
519
|
+
);
|
|
520
|
+
if (input.mode !== undefined && input.mode !== "auto" && input.mode !== "matches")
|
|
521
|
+
throw new SiftLightError("anyOf mode must be omitted, auto, or matches");
|
|
522
|
+
const chunks = Array.from(
|
|
523
|
+
{ length: Math.ceil(anyOf.length / MAX_ANY_OF_TERMS) },
|
|
524
|
+
(_, index) => anyOf.slice(index * MAX_ANY_OF_TERMS, (index + 1) * MAX_ANY_OF_TERMS),
|
|
525
|
+
);
|
|
526
|
+
const { path: _inputPath, ...unscopedInput } = input;
|
|
527
|
+
let chunkAccess = access;
|
|
528
|
+
const runChunks = async (expandedFromPath?: string) =>
|
|
529
|
+
runOwnedParallel((groupSignal) => {
|
|
530
|
+
chunkAccess = new SourceAccess(cwd, this.#queue, groupSignal, { maxFiles: fileLimit });
|
|
531
|
+
return chunks.map(async (chunk) => {
|
|
532
|
+
const request = normalizeRequest({
|
|
533
|
+
...(expandedFromPath === undefined ? input : unscopedInput),
|
|
534
|
+
pattern: chunk.map(escapeRegexLiteral).join("|"),
|
|
535
|
+
literal: false,
|
|
536
|
+
ignoreCase: false,
|
|
537
|
+
});
|
|
538
|
+
const effectiveRequest =
|
|
539
|
+
expandedFromPath === undefined ? request : { ...request, expandedFromPath };
|
|
540
|
+
const candidates = await collectEvidenceCandidates({
|
|
541
|
+
request: effectiveRequest,
|
|
542
|
+
...(input.changes ? { changes: input.changes } : {}),
|
|
543
|
+
cwd,
|
|
544
|
+
signal: groupSignal,
|
|
545
|
+
access: chunkAccess,
|
|
546
|
+
runRipgrep: this.#runner,
|
|
547
|
+
maxFiles: fileLimit,
|
|
548
|
+
});
|
|
549
|
+
return { chunk, request: effectiveRequest, candidates };
|
|
550
|
+
});
|
|
551
|
+
}, signal);
|
|
552
|
+
let chunkResults = await runChunks();
|
|
553
|
+
if (
|
|
554
|
+
!input.changes &&
|
|
555
|
+
input.path !== undefined &&
|
|
556
|
+
input.scope !== "strict" &&
|
|
557
|
+
chunkResults.every(({ candidates }) => !candidates.partial && candidates.files.length === 0)
|
|
558
|
+
) {
|
|
559
|
+
chunkResults = await runChunks(input.path.replace(/^@/, ""));
|
|
560
|
+
}
|
|
561
|
+
const reasons = new Set<string>();
|
|
562
|
+
let partial = false;
|
|
563
|
+
let changes: AnalysisResultSet["changes"];
|
|
564
|
+
const candidateFiles = new Map<string, EvidenceCandidateFile>();
|
|
565
|
+
const invalidatedPaths = new Set<string>();
|
|
566
|
+
for (const { candidates } of chunkResults) {
|
|
567
|
+
partial ||= candidates.partial;
|
|
568
|
+
changes ??= candidates.changes;
|
|
569
|
+
for (const reason of candidates.reasons) reasons.add(reason);
|
|
570
|
+
for (const file of candidates.files) {
|
|
571
|
+
if (invalidatedPaths.has(file.document.path)) continue;
|
|
572
|
+
const existing = candidateFiles.get(file.document.path);
|
|
573
|
+
if (
|
|
574
|
+
existing &&
|
|
575
|
+
JSON.stringify(existing.document.reference) !== JSON.stringify(file.document.reference)
|
|
576
|
+
) {
|
|
577
|
+
candidateFiles.delete(file.document.path);
|
|
578
|
+
invalidatedPaths.add(file.document.path);
|
|
579
|
+
partial = true;
|
|
580
|
+
reasons.add(`Source changed across anyOf chunks: ${file.document.path}`);
|
|
581
|
+
} else candidateFiles.set(file.document.path, file);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const expanded = expandMultiTermCandidates(
|
|
585
|
+
[...candidateFiles.values()],
|
|
586
|
+
anyOf,
|
|
587
|
+
input.changes?.scope === "lines",
|
|
588
|
+
);
|
|
589
|
+
partial ||= expanded.partial;
|
|
590
|
+
for (const reason of expanded.reasons) reasons.add(reason);
|
|
591
|
+
const scope = searchScope(
|
|
592
|
+
chunkResults[0]?.request ??
|
|
593
|
+
normalizeRequest({
|
|
594
|
+
...input,
|
|
595
|
+
pattern: chunks[0]?.map(escapeRegexLiteral).join("|") ?? "",
|
|
596
|
+
literal: false,
|
|
597
|
+
ignoreCase: false,
|
|
598
|
+
}),
|
|
599
|
+
);
|
|
600
|
+
const result: AnalysisResultSet = {
|
|
601
|
+
kind: "any-of",
|
|
602
|
+
unit: "occurrences",
|
|
603
|
+
items: expanded.items,
|
|
604
|
+
partial,
|
|
605
|
+
reasons: [...reasons],
|
|
606
|
+
filesRead: chunkAccess.filesRead,
|
|
607
|
+
bytesRead: chunkAccess.bytesRead,
|
|
608
|
+
...(changes ? { changes } : {}),
|
|
609
|
+
scope,
|
|
610
|
+
chunks: {
|
|
611
|
+
chunked: chunks.length > 1,
|
|
612
|
+
count: chunks.length,
|
|
613
|
+
maxTermsPerChunk: MAX_ANY_OF_TERMS,
|
|
614
|
+
execution: chunks.length > 1 ? ("bounded-parallel" as const) : ("single" as const),
|
|
615
|
+
},
|
|
616
|
+
coverage: { exactOccurrences: partial ? "partial" : "complete" },
|
|
617
|
+
redact: input.redact ?? false,
|
|
618
|
+
};
|
|
619
|
+
return this.#analyses.page(
|
|
620
|
+
this.#analyses.create(result, (retainedItems) => ({
|
|
621
|
+
termCounts: retainedTermCounts(anyOf, retainedItems),
|
|
622
|
+
})),
|
|
623
|
+
options.modelOutput,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
const terms = validateTerms(input);
|
|
627
|
+
if (
|
|
628
|
+
input.roles !== undefined &&
|
|
629
|
+
(!input.roles.length ||
|
|
630
|
+
input.roles.some(
|
|
631
|
+
(role) =>
|
|
632
|
+
![
|
|
633
|
+
"declaration",
|
|
634
|
+
"call",
|
|
635
|
+
"import",
|
|
636
|
+
"export",
|
|
637
|
+
"comment",
|
|
638
|
+
"string",
|
|
639
|
+
"jsx-text",
|
|
640
|
+
"code",
|
|
641
|
+
"unknown",
|
|
642
|
+
].includes(role),
|
|
643
|
+
))
|
|
644
|
+
)
|
|
645
|
+
throw new SiftLightError("roles must contain supported syntactic roles");
|
|
646
|
+
const request = normalizeRequest(
|
|
647
|
+
terms
|
|
648
|
+
? {
|
|
649
|
+
...input,
|
|
650
|
+
pattern: terms.map(escapeRegexLiteral).join("|"),
|
|
651
|
+
literal: false,
|
|
652
|
+
ignoreCase: false,
|
|
653
|
+
}
|
|
654
|
+
: input,
|
|
655
|
+
);
|
|
656
|
+
const selected = await this.#candidates(request, input, access);
|
|
657
|
+
const candidates = selected.candidates;
|
|
658
|
+
const kind = terms
|
|
659
|
+
? input.within === "function"
|
|
660
|
+
? "function-and"
|
|
661
|
+
: "file-and"
|
|
662
|
+
: input.roles
|
|
663
|
+
? "roles"
|
|
664
|
+
: "changes";
|
|
665
|
+
const result: AnalysisResultSet = {
|
|
666
|
+
kind,
|
|
667
|
+
unit: kind === "function-and" ? "functions" : kind === "file-and" ? "files" : "occurrences",
|
|
668
|
+
items: [],
|
|
669
|
+
partial: candidates.partial,
|
|
670
|
+
reasons: [...candidates.reasons],
|
|
671
|
+
filesRead: candidates.filesRead,
|
|
672
|
+
bytesRead: candidates.bytesRead,
|
|
673
|
+
...(candidates.changes ? { changes: candidates.changes } : {}),
|
|
674
|
+
scope: searchScope(selected.request),
|
|
675
|
+
coverage: {
|
|
676
|
+
candidateSearch: candidates.partial ? "partial" : "complete",
|
|
677
|
+
...(terms || input.roles ? { syntaxClassification: "complete" as const } : {}),
|
|
678
|
+
},
|
|
679
|
+
redact: input.redact ?? false,
|
|
680
|
+
};
|
|
681
|
+
let syntaxCapableFiles = 0;
|
|
682
|
+
const processFile = async (index: number): Promise<void> => {
|
|
683
|
+
const file = candidates.files[index];
|
|
684
|
+
if (!file) return;
|
|
685
|
+
try {
|
|
686
|
+
if (!file.document.utf8) {
|
|
687
|
+
result.partial = true;
|
|
688
|
+
result.reasons.push(
|
|
689
|
+
`${file.document.path}: non-UTF-8 evidence cannot be reliably classified`,
|
|
690
|
+
);
|
|
691
|
+
} else if (terms && input.within !== "function") {
|
|
692
|
+
const item = fileConjunction(
|
|
693
|
+
file.document,
|
|
694
|
+
terms,
|
|
695
|
+
input.changes?.scope === "lines" ? file.changedRanges : undefined,
|
|
696
|
+
);
|
|
697
|
+
if (item) result.items.push(item);
|
|
698
|
+
} else if (terms || input.roles) {
|
|
699
|
+
if (syntaxLanguage(file.document.path)) syntaxCapableFiles += 1;
|
|
700
|
+
const syntax = await access.syntax(file.document);
|
|
701
|
+
const classified = terms
|
|
702
|
+
? findFunctionConjunctions(
|
|
703
|
+
file.document,
|
|
704
|
+
syntax,
|
|
705
|
+
terms,
|
|
706
|
+
input.changes?.scope === "lines" ? file.changedRanges : undefined,
|
|
707
|
+
)
|
|
708
|
+
: filterRoleOccurrences(file.document, syntax, file.occurrences, input.roles ?? []);
|
|
709
|
+
result.items.push(...classified.items);
|
|
710
|
+
result.partial ||= classified.partial;
|
|
711
|
+
if (classified.partial && result.coverage)
|
|
712
|
+
result.coverage.syntaxClassification = "partial";
|
|
713
|
+
result.reasons.push(...classified.reasons);
|
|
714
|
+
} else {
|
|
715
|
+
for (const range of file.occurrences) {
|
|
716
|
+
const line = file.document.lineAt(range.start);
|
|
717
|
+
result.items.push({
|
|
718
|
+
path: file.document.path,
|
|
719
|
+
line,
|
|
720
|
+
label: `${file.change ?? "changed"} source occurrence`,
|
|
721
|
+
excerpt: file.document.slice(file.document.lineRange(line)).slice(0, 500),
|
|
722
|
+
source: file.document.reference,
|
|
723
|
+
range,
|
|
724
|
+
details: { change: file.change, byteRange: range },
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
} catch (error) {
|
|
729
|
+
if (!(error instanceof SourceBudgetError)) throw error;
|
|
730
|
+
result.partial = true;
|
|
731
|
+
result.reasons.push(error.message);
|
|
732
|
+
return;
|
|
733
|
+
} finally {
|
|
734
|
+
access.releaseSyntax(file.document);
|
|
735
|
+
}
|
|
736
|
+
await processFile(index + 1);
|
|
737
|
+
};
|
|
738
|
+
await processFile(0);
|
|
739
|
+
result.reasons = [...new Set(result.reasons)];
|
|
740
|
+
if ((input.roles || (terms && input.within === "function")) && syntaxCapableFiles === 0) {
|
|
741
|
+
throw new SiftLightError(
|
|
742
|
+
`${input.roles ? "roles" : "within=function"} requires a supported source language; use ordinary search or file-level allOf for non-code content`,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
if (terms || input.roles) {
|
|
746
|
+
result.stats = {
|
|
747
|
+
filesEnumerated: candidates.files.length,
|
|
748
|
+
filesParsed: access.syntaxParses,
|
|
749
|
+
filesSkipped: Math.max(0, candidates.files.length - syntaxCapableFiles),
|
|
750
|
+
cacheHits: access.syntaxCacheHits,
|
|
751
|
+
parseMs: Math.round(performance.now() - analysisStarted),
|
|
752
|
+
budgetExhausted: result.reasons.some(
|
|
753
|
+
(reason) => reason.includes("limit") || reason.includes("budget-exhausted"),
|
|
754
|
+
),
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
return this.#analyses.page(this.#analyses.create(result), options.modelOutput);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
#inspectionTargets(input: SiftLightInput, cwd: string): SourceInspectionTarget[] {
|
|
761
|
+
if (input.targets !== undefined && input.matchIndices !== undefined)
|
|
762
|
+
throw new SiftLightError("Use targets or matchIndices, not both");
|
|
763
|
+
if (input.targets !== undefined || input.matchIndices !== undefined) {
|
|
764
|
+
if (input.path !== undefined || input.line !== undefined || input.matchIndex !== undefined)
|
|
765
|
+
throw new SiftLightError(
|
|
766
|
+
"Batch inspection accepts targets or matchIndices instead of path, line or matchIndex",
|
|
767
|
+
);
|
|
768
|
+
const size = input.targets?.length ?? input.matchIndices?.length ?? 0;
|
|
769
|
+
if (size < 1 || size > MAX_INSPECT_TARGETS)
|
|
770
|
+
throw new SiftLightError("Batch inspection requires 1-5 targets");
|
|
771
|
+
if (input.targets) {
|
|
772
|
+
if (input.cursor !== undefined)
|
|
773
|
+
throw new SiftLightError("targets cannot be combined with cursor");
|
|
774
|
+
return input.targets.map((target) =>
|
|
775
|
+
matchInspectionTarget(resolveInspectionTarget(target, cwd, this.#snapshots)),
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
if (!input.cursor) throw new SiftLightError("matchIndices requires a cursor");
|
|
779
|
+
const cursor = input.cursor;
|
|
780
|
+
return (input.matchIndices ?? []).map((matchIndex) =>
|
|
781
|
+
this.#singleTarget({ cursor, matchIndex }, cwd),
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
return [this.#singleTarget(input, cwd)];
|
|
785
|
+
}
|
|
786
|
+
#singleTarget(input: SiftLightInput, cwd: string): SourceInspectionTarget {
|
|
787
|
+
if (input.cursor?.includes(".analysis.")) {
|
|
788
|
+
if (input.path !== undefined || input.line !== undefined || input.matchIndex === undefined)
|
|
789
|
+
throw new CursorError("Analysis inspection requires only cursor and matchIndex");
|
|
790
|
+
const item = this.#analyses.item(input.cursor, input.matchIndex);
|
|
791
|
+
if (!item.source || !item.range)
|
|
792
|
+
throw new CursorError("This analysis item has no verified source range");
|
|
793
|
+
const metadataOnly = item.details?.kind === "symbol" || item.details?.kind === "function";
|
|
794
|
+
return {
|
|
795
|
+
path: item.path,
|
|
796
|
+
line: item.line,
|
|
797
|
+
reference: item.source,
|
|
798
|
+
...(metadataOnly ? { range: item.range } : { absoluteFocus: item.range.start }),
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
return {
|
|
802
|
+
...matchInspectionTarget(resolveInspectionTarget(input, cwd, this.#snapshots)),
|
|
803
|
+
...(input.matchIndex !== undefined ? { matchIndex: input.matchIndex } : {}),
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
async #navigate(
|
|
808
|
+
input: SiftLightInput,
|
|
809
|
+
access: SourceAccess,
|
|
810
|
+
options: EvidenceSearchOptions = {},
|
|
811
|
+
): Promise<SiftLightResult> {
|
|
812
|
+
const navigationStarted = performance.now();
|
|
813
|
+
let path = input.path;
|
|
814
|
+
let reference: SourceReference | undefined;
|
|
815
|
+
let line = input.line;
|
|
816
|
+
let loaded: SourceDocument | undefined;
|
|
817
|
+
if (input.cursor) {
|
|
818
|
+
if (input.path !== undefined || input.line !== undefined || input.matchIndex === undefined)
|
|
819
|
+
throw new SiftLightError(
|
|
820
|
+
"Snapshot navigation requires cursor+matchIndex instead of path/line",
|
|
821
|
+
);
|
|
822
|
+
const selected = this.#singleTarget(input, access.cwd);
|
|
823
|
+
path = selected.path;
|
|
824
|
+
line = selected.line;
|
|
825
|
+
reference = selected.reference;
|
|
826
|
+
if (selected.unverified)
|
|
827
|
+
throw new SiftLightError("Snapshot source revision is unverified; refresh the search");
|
|
828
|
+
if (selected.expectedRevision) {
|
|
829
|
+
const doc = await access.load(path);
|
|
830
|
+
if (
|
|
831
|
+
doc.reference.origin.kind !== "worktree" ||
|
|
832
|
+
!sameSourceRevision(selected.expectedRevision, doc.reference.origin.revision)
|
|
833
|
+
)
|
|
834
|
+
throw new SiftLightError("Source changed; refresh the search");
|
|
835
|
+
reference = doc.reference;
|
|
836
|
+
loaded = doc;
|
|
837
|
+
}
|
|
838
|
+
} else if (input.matchIndex !== undefined)
|
|
839
|
+
throw new SiftLightError("matchIndex requires a cursor");
|
|
840
|
+
if (!path) throw new SiftLightError(`${input.mode} requires path or cursor+matchIndex`);
|
|
841
|
+
const document = loaded ?? (await access.load(path, reference));
|
|
842
|
+
const language = syntaxLanguage(document.path);
|
|
843
|
+
const isPython = /\.py$/iu.test(document.path);
|
|
844
|
+
if (input.mode === "outline") {
|
|
845
|
+
const capabilityFailure = outlineCapabilityError(document.path, "outline", true);
|
|
846
|
+
if (capabilityFailure) throw capabilityFailure;
|
|
847
|
+
if (!language && !isPython)
|
|
848
|
+
throw new SiftLightError(
|
|
849
|
+
`No outline provider is registered for ${document.path}; use mode=capabilities to inspect available language modes`,
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
if ((!language && !isPython) || language === "go") {
|
|
853
|
+
const extension = extname(document.path) || "extensionless source";
|
|
854
|
+
throw new SiftLightError(
|
|
855
|
+
`${input.mode} is unavailable for ${extension}; choose a language capability from mode=capabilities or use ordinary content search`,
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
if (input.mode === "outline") {
|
|
859
|
+
const syntax = isPython ? undefined : await access.syntax(document);
|
|
860
|
+
const supported = isPython || (syntax?.status === "ok" && syntax.language !== "go");
|
|
861
|
+
const items: AnalysisItem[] = supported
|
|
862
|
+
? isPython
|
|
863
|
+
? parsePythonOutline(document).map((symbol) => ({
|
|
864
|
+
path: document.path,
|
|
865
|
+
line: symbol.startLine,
|
|
866
|
+
label: `${symbol.kind} ${symbol.name}${symbol.hasBody ? "" : " (no implementation body)"}`,
|
|
867
|
+
excerpt: symbol.signature,
|
|
868
|
+
source: document.reference,
|
|
869
|
+
range: symbol.range,
|
|
870
|
+
details: {
|
|
871
|
+
kind: "symbol",
|
|
872
|
+
language: "python",
|
|
873
|
+
name: symbol.name,
|
|
874
|
+
scope: symbol.scope,
|
|
875
|
+
hasBody: symbol.hasBody,
|
|
876
|
+
exported: false,
|
|
877
|
+
syntax: "indentation-based outline; not compiler binding",
|
|
878
|
+
},
|
|
879
|
+
}))
|
|
880
|
+
: (syntax?.symbols ?? []).map((symbol) => {
|
|
881
|
+
const range = {
|
|
882
|
+
start: document.toByteOffset(symbol.start),
|
|
883
|
+
end: document.toByteOffset(symbol.end),
|
|
884
|
+
};
|
|
885
|
+
const firstLine = document.lineAt(range.start);
|
|
886
|
+
const signatureEnd = symbol.bodyStart ?? symbol.end;
|
|
887
|
+
const signature = document.text.slice(
|
|
888
|
+
symbol.start,
|
|
889
|
+
Math.min(signatureEnd, symbol.start + 600),
|
|
890
|
+
);
|
|
891
|
+
return {
|
|
892
|
+
path: document.path,
|
|
893
|
+
line: firstLine,
|
|
894
|
+
label: `${symbol.kind} ${symbol.name}${symbol.hasBody ? "" : " (no implementation body)"}`,
|
|
895
|
+
excerpt: signature,
|
|
896
|
+
source: document.reference,
|
|
897
|
+
range,
|
|
898
|
+
details: {
|
|
899
|
+
kind: "symbol",
|
|
900
|
+
name: symbol.name,
|
|
901
|
+
scope: symbol.scope,
|
|
902
|
+
hasBody: symbol.hasBody,
|
|
903
|
+
exported: symbol.exported,
|
|
904
|
+
signatureTruncated: signatureEnd - symbol.start > 600,
|
|
905
|
+
},
|
|
906
|
+
};
|
|
907
|
+
})
|
|
908
|
+
: [];
|
|
909
|
+
return this.#analyses.page(
|
|
910
|
+
this.#analyses.create({
|
|
911
|
+
kind: "outline",
|
|
912
|
+
unit: "symbols",
|
|
913
|
+
items,
|
|
914
|
+
partial: !supported,
|
|
915
|
+
reasons: supported
|
|
916
|
+
? isPython
|
|
917
|
+
? [
|
|
918
|
+
"Python outline uses indentation boundaries; it does not prove compiler bindings or runtime call relationships",
|
|
919
|
+
]
|
|
920
|
+
: []
|
|
921
|
+
: [
|
|
922
|
+
`Outline requires reliable JS/TS/TSX or Python syntax (${syntax?.language ?? "unsupported"}: ${syntax?.status ?? "unsupported"})`,
|
|
923
|
+
],
|
|
924
|
+
filesRead: access.filesRead,
|
|
925
|
+
bytesRead: access.bytesRead,
|
|
926
|
+
stats: {
|
|
927
|
+
filesEnumerated: 1,
|
|
928
|
+
filesParsed: isPython ? 0 : access.syntaxParses,
|
|
929
|
+
filesSkipped: 0,
|
|
930
|
+
cacheHits: isPython ? 0 : access.syntaxCacheHits,
|
|
931
|
+
parseMs: Math.round(performance.now() - navigationStarted),
|
|
932
|
+
budgetExhausted: false,
|
|
933
|
+
},
|
|
934
|
+
redact: input.redact ?? false,
|
|
935
|
+
}),
|
|
936
|
+
options.modelOutput,
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
if (document.reference.origin.kind !== "worktree")
|
|
940
|
+
return this.#analyses.page(
|
|
941
|
+
this.#analyses.create({
|
|
942
|
+
kind: input.mode === "imports" ? "imports" : "tests",
|
|
943
|
+
unit: "evidence-items",
|
|
944
|
+
items: [],
|
|
945
|
+
partial: true,
|
|
946
|
+
reasons: [
|
|
947
|
+
"Import and related-test navigation currently support worktree sources only; historical sources are not switched to the worktree",
|
|
948
|
+
],
|
|
949
|
+
}),
|
|
950
|
+
options.modelOutput,
|
|
951
|
+
);
|
|
952
|
+
const root = await navigationRoot(access.cwd, document.path, access.signal);
|
|
953
|
+
const filters = navigationFilters(input);
|
|
954
|
+
if (input.mode === "tests" && isPython)
|
|
955
|
+
return this.#analyses.page(
|
|
956
|
+
this.#analyses.create({
|
|
957
|
+
kind: "tests",
|
|
958
|
+
unit: "evidence-items",
|
|
959
|
+
items: [],
|
|
960
|
+
partial: true,
|
|
961
|
+
reasons: [
|
|
962
|
+
'Python related-test navigation is not supported; use mode="outline" for Python source structure',
|
|
963
|
+
],
|
|
964
|
+
filesRead: access.filesRead,
|
|
965
|
+
bytesRead: access.bytesRead,
|
|
966
|
+
stats: {
|
|
967
|
+
filesEnumerated: 0,
|
|
968
|
+
filesParsed: 0,
|
|
969
|
+
filesSkipped: 0,
|
|
970
|
+
cacheHits: 0,
|
|
971
|
+
parseMs: Math.round(performance.now() - navigationStarted),
|
|
972
|
+
budgetExhausted: false,
|
|
973
|
+
},
|
|
974
|
+
coverage: { navigation: "not-applicable" },
|
|
975
|
+
scope: await navigationScope(access.cwd, root, document.path, filters),
|
|
976
|
+
redact: input.redact ?? false,
|
|
977
|
+
}),
|
|
978
|
+
options.modelOutput,
|
|
979
|
+
);
|
|
980
|
+
const files = await listWorkspaceFiles(access.cwd, access.signal, {
|
|
981
|
+
path: root,
|
|
982
|
+
glob: filters.glob,
|
|
983
|
+
exclude: filters.exclude,
|
|
984
|
+
hidden: filters.hidden,
|
|
985
|
+
});
|
|
986
|
+
const { allowed, primaryPath } = await canonicalNavigationFiles(
|
|
987
|
+
access.cwd,
|
|
988
|
+
root,
|
|
989
|
+
files,
|
|
990
|
+
document.path,
|
|
991
|
+
);
|
|
992
|
+
const host = {
|
|
993
|
+
cwd: access.cwd,
|
|
994
|
+
...(access.signal ? { signal: access.signal } : {}),
|
|
995
|
+
normalizePath: (file: string) => workspaceRelativePath(access.cwd, file),
|
|
996
|
+
load: async (file: string, expected?: SourceReference) => {
|
|
997
|
+
const absolutePath = await canonicalNavigationPath(resolve(access.cwd, file));
|
|
998
|
+
if (!allowed.has(absolutePath))
|
|
999
|
+
throw new SiftLightError("Navigation source is excluded by current ignore rules");
|
|
1000
|
+
if (absolutePath === primaryPath && expected === undefined) return document;
|
|
1001
|
+
return expected ? access.refresh(file, expected) : access.load(file);
|
|
1002
|
+
},
|
|
1003
|
+
syntax: (doc: SourceDocument) => access.syntax(doc),
|
|
1004
|
+
releaseSyntax: (doc: SourceDocument) => access.releaseSyntax(doc),
|
|
1005
|
+
listFiles: async () => files,
|
|
1006
|
+
maxFilesToParse: access.maxFiles,
|
|
1007
|
+
};
|
|
1008
|
+
const request = {
|
|
1009
|
+
path: document.path,
|
|
1010
|
+
...(line !== undefined ? { line } : {}),
|
|
1011
|
+
...(input.symbol !== undefined ? { symbol: input.symbol } : {}),
|
|
1012
|
+
};
|
|
1013
|
+
const result =
|
|
1014
|
+
input.mode === "imports"
|
|
1015
|
+
? await navigateImports(host, request)
|
|
1016
|
+
: await findRelatedTests(host, request, {
|
|
1017
|
+
entryPaths: await this.#testEntryPaths(
|
|
1018
|
+
root,
|
|
1019
|
+
files.paths,
|
|
1020
|
+
access.cwd,
|
|
1021
|
+
filters,
|
|
1022
|
+
access.signal,
|
|
1023
|
+
),
|
|
1024
|
+
});
|
|
1025
|
+
return this.#analyses.page(
|
|
1026
|
+
this.#analyses.create({
|
|
1027
|
+
...result,
|
|
1028
|
+
partial: result.partial || files.partial,
|
|
1029
|
+
reasons: [...result.reasons, ...files.reasons],
|
|
1030
|
+
kind: input.mode === "imports" ? "imports" : "tests",
|
|
1031
|
+
unit: "evidence-items",
|
|
1032
|
+
coverage: {
|
|
1033
|
+
navigation: result.partial || files.partial ? "partial" : "complete",
|
|
1034
|
+
},
|
|
1035
|
+
stats: {
|
|
1036
|
+
filesEnumerated: files.paths.length,
|
|
1037
|
+
...result.stats,
|
|
1038
|
+
filesParsed: access.syntaxParses,
|
|
1039
|
+
cacheHits: access.syntaxCacheHits,
|
|
1040
|
+
},
|
|
1041
|
+
scope: await navigationScope(access.cwd, root, document.path, filters),
|
|
1042
|
+
redact: input.redact ?? false,
|
|
1043
|
+
}),
|
|
1044
|
+
options.modelOutput,
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
}
|