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,481 @@
|
|
|
1
|
+
import type { SiftLightInput } from "./service.js";
|
|
2
|
+
import { MAX_HYBRID_CONCEPT_LIMIT, MAX_CONFIGURABLE_STRUCTURE_FILES } from "./analysis-limits.js";
|
|
3
|
+
import { MAX_CONTEXT_LINES, MAX_PAGE_SIZE } from "./types.js";
|
|
4
|
+
import { containsSensitiveText } from "./redaction.js";
|
|
5
|
+
import {
|
|
6
|
+
MAX_REQUEST_RECOVERY_BYTES,
|
|
7
|
+
RequestContractError,
|
|
8
|
+
type RequestIssue,
|
|
9
|
+
boundedDisplay,
|
|
10
|
+
} from "./request-contract-error.js";
|
|
11
|
+
import {
|
|
12
|
+
MODE_FIELDS_BY_MODE,
|
|
13
|
+
SAFE_DROP_FIELDS,
|
|
14
|
+
SIFT_LIGHT_MODES,
|
|
15
|
+
SUPPORTED_OUTLINE_EXTENSIONS,
|
|
16
|
+
type RequestField,
|
|
17
|
+
type SiftLightMode,
|
|
18
|
+
modeFields,
|
|
19
|
+
outlineExtension,
|
|
20
|
+
} from "./request-contract-catalog.js";
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
MODE_CONTRACT_DESCRIPTION,
|
|
24
|
+
MODE_FIELD_SUMMARY,
|
|
25
|
+
MODEL_USAGE_GUIDANCE,
|
|
26
|
+
REQUEST_USAGE_GUIDANCE,
|
|
27
|
+
SIFT_LIGHT_MODES,
|
|
28
|
+
fieldGuidance,
|
|
29
|
+
} from "./request-contract-catalog.js";
|
|
30
|
+
export type { RequestField, SiftLightMode } from "./request-contract-catalog.js";
|
|
31
|
+
export {
|
|
32
|
+
MAX_REQUEST_RECOVERY_BYTES,
|
|
33
|
+
RequestContractError,
|
|
34
|
+
boundedRequestContractDetails,
|
|
35
|
+
isSiftLightDiagnosticError,
|
|
36
|
+
} from "./request-contract-error.js";
|
|
37
|
+
export type {
|
|
38
|
+
RequestContractDetails,
|
|
39
|
+
RequestIssue,
|
|
40
|
+
RequestRecovery,
|
|
41
|
+
} from "./request-contract-error.js";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The public request contract is intentionally kept separate from the TypeBox
|
|
45
|
+
* property catalog. Hosts receive one flat schema, while this module owns the
|
|
46
|
+
* mode-specific capability and recovery rules used by every runtime entry.
|
|
47
|
+
*/
|
|
48
|
+
const MAX_REQUEST_RECOVERY_ISSUES = 16;
|
|
49
|
+
function boundedField(value: string): string {
|
|
50
|
+
return boundedDisplay(value, 128);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
54
|
+
return typeof value === "object" && value !== null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function modeOf(input: Record<string, unknown>): SiftLightMode | undefined {
|
|
58
|
+
const mode = input.mode;
|
|
59
|
+
if (mode === undefined) return "auto";
|
|
60
|
+
if (typeof mode !== "string") return undefined;
|
|
61
|
+
return SIFT_LIGHT_MODES.find((candidate) => candidate === mode);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function inputModeLabel(input: Record<string, unknown>): string | undefined {
|
|
65
|
+
return typeof input.mode === "string" ? input.mode.slice(0, 128) : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function outlineCapability(
|
|
69
|
+
path: string | undefined,
|
|
70
|
+
resolvedDocument = false,
|
|
71
|
+
): { extension?: string; supported: boolean } {
|
|
72
|
+
const extension = outlineExtension(path);
|
|
73
|
+
// A request can name a directory, so an extensionless input is deferred until
|
|
74
|
+
// the loaded document has a concrete path. A concrete extension is checked
|
|
75
|
+
// here and again by the navigation service through this same catalog.
|
|
76
|
+
return {
|
|
77
|
+
...(extension ? { extension } : {}),
|
|
78
|
+
supported:
|
|
79
|
+
extension !== undefined ? SUPPORTED_OUTLINE_EXTENSIONS.has(extension) : !resolvedDocument,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function capabilityError(
|
|
84
|
+
code: string,
|
|
85
|
+
mode: string | undefined,
|
|
86
|
+
field: string,
|
|
87
|
+
reason: string,
|
|
88
|
+
message: string,
|
|
89
|
+
): RequestContractError {
|
|
90
|
+
return new RequestContractError(
|
|
91
|
+
{
|
|
92
|
+
code,
|
|
93
|
+
...(mode ? { mode } : {}),
|
|
94
|
+
issues: [{ field, reason }],
|
|
95
|
+
recovery: { action: "choose-capability", reason },
|
|
96
|
+
},
|
|
97
|
+
message,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function schemaError(
|
|
102
|
+
input: Record<string, unknown>,
|
|
103
|
+
field: string,
|
|
104
|
+
reason: string,
|
|
105
|
+
): RequestContractError {
|
|
106
|
+
const mode = inputModeLabel(input);
|
|
107
|
+
return new RequestContractError(
|
|
108
|
+
{
|
|
109
|
+
code: `E_${field.replaceAll(/[^a-z0-9]+/giu, "_").toUpperCase()}_RANGE`,
|
|
110
|
+
...(mode ? { mode } : {}),
|
|
111
|
+
issues: [{ field, reason }],
|
|
112
|
+
recovery: { action: "manual", reason },
|
|
113
|
+
},
|
|
114
|
+
`${field} is invalid: ${reason}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function safeNextRequest(
|
|
119
|
+
input: Record<string, unknown>,
|
|
120
|
+
mode: SiftLightMode,
|
|
121
|
+
invalid: readonly string[],
|
|
122
|
+
): Record<string, unknown> | undefined {
|
|
123
|
+
if (input.redact === true && containsSensitiveText(input)) return undefined;
|
|
124
|
+
const safe = new Set<string>(SAFE_DROP_FIELDS[mode] ?? []);
|
|
125
|
+
if (invalid.some((field) => !safe.has(field))) return undefined;
|
|
126
|
+
const next: Record<string, unknown> = { ...input };
|
|
127
|
+
for (const field of invalid) delete next[field];
|
|
128
|
+
if (selectorIssuesFor(next, mode).length > 0) return undefined;
|
|
129
|
+
// A copied request must remain within the transport's bounded line budget.
|
|
130
|
+
try {
|
|
131
|
+
if (Buffer.byteLength(JSON.stringify(next)) > MAX_REQUEST_RECOVERY_BYTES) return undefined;
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
return next;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function fieldsError(
|
|
139
|
+
input: Record<string, unknown>,
|
|
140
|
+
mode: SiftLightMode,
|
|
141
|
+
invalid: readonly string[],
|
|
142
|
+
selectorIssues: readonly RequestIssue[] = [],
|
|
143
|
+
): RequestContractError {
|
|
144
|
+
const nextRequest = safeNextRequest(input, mode, invalid);
|
|
145
|
+
const visibleInvalid = invalid.slice(0, MAX_REQUEST_RECOVERY_ISSUES);
|
|
146
|
+
const omitted = invalid.length - visibleInvalid.length;
|
|
147
|
+
const issues = [
|
|
148
|
+
...visibleInvalid.map((field) => ({
|
|
149
|
+
field: boundedField(field),
|
|
150
|
+
reason: `not accepted by mode=${boundedDisplay(mode, 128)}`,
|
|
151
|
+
})),
|
|
152
|
+
...selectorIssues,
|
|
153
|
+
];
|
|
154
|
+
if (omitted > 0)
|
|
155
|
+
issues.push({
|
|
156
|
+
field: "<additional-fields>",
|
|
157
|
+
reason: `${String(omitted)} additional fields are not accepted`,
|
|
158
|
+
});
|
|
159
|
+
const visibleFields = visibleInvalid.map((field) => boundedField(field)).join(", ");
|
|
160
|
+
const reason =
|
|
161
|
+
omitted > 0
|
|
162
|
+
? `mode=${mode} does not accept ${visibleFields} and ${String(omitted)} additional field(s)`
|
|
163
|
+
: `mode=${mode} does not accept ${visibleFields}`;
|
|
164
|
+
// A field named after its own mode (for example `files` under mode=files) is
|
|
165
|
+
// the signature of arguments shaped as a per-mode object, which the flat
|
|
166
|
+
// schema never advertises. Naming the accepted fields replaces an otherwise
|
|
167
|
+
// puzzling rejection with the rule and the valid names.
|
|
168
|
+
const flatRule = `Fields are flat: pass them at the top level, not inside a per-mode object. mode=${mode} accepts: ${modeFields(mode).join(", ")}.`;
|
|
169
|
+
return new RequestContractError(
|
|
170
|
+
{
|
|
171
|
+
code: "E_MODE_FIELDS",
|
|
172
|
+
mode,
|
|
173
|
+
issues,
|
|
174
|
+
recovery: nextRequest
|
|
175
|
+
? {
|
|
176
|
+
action: "retry",
|
|
177
|
+
reason: `Remove only ${visibleFields} and copy the exact nextRequest; all other fields are preserved.`,
|
|
178
|
+
nextRequest,
|
|
179
|
+
}
|
|
180
|
+
: {
|
|
181
|
+
action: "manual",
|
|
182
|
+
reason: `${reason}; choose the mode explicitly or remove the fields yourself without changing the requested scope. ${flatRule}`,
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
nextRequest
|
|
186
|
+
? `${reason}; retry the exact nextRequest without repeating the original query.`
|
|
187
|
+
: `${reason}; no semantics-preserving automatic request is available. ${flatRule} Preserve valid path, filters, redact and cursor fields when choosing the next request.`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function selectorIssuesFor(input: Record<string, unknown>, mode: SiftLightMode): RequestIssue[] {
|
|
192
|
+
if (
|
|
193
|
+
(mode === "auto" || mode === "summary" || mode === "matches") &&
|
|
194
|
+
typeof input.cursor === "string" &&
|
|
195
|
+
input.cursor.trim().length > 0 &&
|
|
196
|
+
!input.cursor.includes(".analysis")
|
|
197
|
+
) {
|
|
198
|
+
const issues: RequestIssue[] = [];
|
|
199
|
+
if (mode === "summary" && input.path !== undefined)
|
|
200
|
+
issues.push({ field: "path", reason: "summary cursor paging cannot select a direct path" });
|
|
201
|
+
if (mode === "summary" && input.paths !== undefined)
|
|
202
|
+
issues.push({ field: "paths", reason: "summary cursor paging cannot select retained paths" });
|
|
203
|
+
if (mode !== "summary" && input.path !== undefined && input.paths !== undefined)
|
|
204
|
+
issues.push({ field: "paths", reason: "cursor selection accepts path or paths, not both" });
|
|
205
|
+
return issues;
|
|
206
|
+
}
|
|
207
|
+
if (mode === "outline" || mode === "imports" || mode === "tests") {
|
|
208
|
+
if (input.cursor !== undefined) {
|
|
209
|
+
const issues: RequestIssue[] = [];
|
|
210
|
+
if (!Number.isSafeInteger(input.matchIndex))
|
|
211
|
+
issues.push({ field: "matchIndex", reason: "cursor continuation requires matchIndex" });
|
|
212
|
+
for (const field of ["path", "line", "symbol"] as const)
|
|
213
|
+
if (input[field] !== undefined)
|
|
214
|
+
issues.push({ field, reason: "cursor continuation cannot combine a direct selector" });
|
|
215
|
+
return issues;
|
|
216
|
+
}
|
|
217
|
+
if (typeof input.path !== "string" || input.path.length === 0)
|
|
218
|
+
return [
|
|
219
|
+
{ field: "path", reason: `mode=${mode} requires a source path or cursor+matchIndex` },
|
|
220
|
+
];
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
if (mode === "inspect") {
|
|
224
|
+
const issues: RequestIssue[] = [];
|
|
225
|
+
const hasMatchIndices = input.matchIndices !== undefined;
|
|
226
|
+
const hasTargets = input.targets !== undefined;
|
|
227
|
+
if (hasMatchIndices && hasTargets)
|
|
228
|
+
issues.push({ field: "targets", reason: "use either matchIndices or targets, not both" });
|
|
229
|
+
if (hasMatchIndices && typeof input.cursor !== "string")
|
|
230
|
+
issues.push({ field: "cursor", reason: "matchIndices requires a cursor" });
|
|
231
|
+
if (hasTargets && input.cursor !== undefined)
|
|
232
|
+
issues.push({ field: "cursor", reason: "targets cannot be combined with cursor" });
|
|
233
|
+
if (hasMatchIndices || hasTargets) {
|
|
234
|
+
for (const field of ["path", "line", "matchIndex"] as const)
|
|
235
|
+
if (input[field] !== undefined)
|
|
236
|
+
issues.push({
|
|
237
|
+
field,
|
|
238
|
+
reason:
|
|
239
|
+
"batch inspection accepts targets or matchIndices instead of path, line or matchIndex",
|
|
240
|
+
});
|
|
241
|
+
return issues;
|
|
242
|
+
}
|
|
243
|
+
if (typeof input.cursor === "string" && input.cursor.includes(".analysis")) {
|
|
244
|
+
if (!Number.isSafeInteger(input.matchIndex))
|
|
245
|
+
issues.push({
|
|
246
|
+
field: "matchIndex",
|
|
247
|
+
reason: "cursor inspection requires matchIndex",
|
|
248
|
+
});
|
|
249
|
+
if (input.path !== undefined)
|
|
250
|
+
issues.push({ field: "path", reason: "analysis cursor inspection cannot combine path" });
|
|
251
|
+
if (input.line !== undefined)
|
|
252
|
+
issues.push({ field: "line", reason: "analysis cursor inspection cannot combine line" });
|
|
253
|
+
}
|
|
254
|
+
return issues;
|
|
255
|
+
}
|
|
256
|
+
if (mode === "validate" && typeof input.cursor !== "string")
|
|
257
|
+
return [{ field: "cursor", reason: "mode=validate requires a saved evidence cursor" }];
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function selectorError(
|
|
262
|
+
input: Record<string, unknown>,
|
|
263
|
+
mode: SiftLightMode,
|
|
264
|
+
issues: readonly RequestIssue[],
|
|
265
|
+
): RequestContractError {
|
|
266
|
+
const reason = issues.map((issue) => `${issue.field}: ${issue.reason}`).join("; ");
|
|
267
|
+
return new RequestContractError(
|
|
268
|
+
{
|
|
269
|
+
code: "E_MODE_SELECTOR",
|
|
270
|
+
mode,
|
|
271
|
+
issues,
|
|
272
|
+
recovery: {
|
|
273
|
+
action: "manual",
|
|
274
|
+
reason: `${reason}; choose a complete selector variant explicitly. No executable nextRequest can be inferred.`,
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
`mode=${mode} has an incomplete or conflicting selector: ${reason}`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function validateValueRanges(input: Record<string, unknown>): void {
|
|
282
|
+
const integerRanges: readonly [RequestField, number, number][] = [
|
|
283
|
+
["context", 0, MAX_CONTEXT_LINES],
|
|
284
|
+
["limit", 1, MAX_PAGE_SIZE],
|
|
285
|
+
["conceptLimit", 1, MAX_HYBRID_CONCEPT_LIMIT],
|
|
286
|
+
["maxFilesToParse", 1, MAX_CONFIGURABLE_STRUCTURE_FILES],
|
|
287
|
+
];
|
|
288
|
+
for (const [field, minimum, maximum] of integerRanges) {
|
|
289
|
+
const value = input[field];
|
|
290
|
+
if (
|
|
291
|
+
value !== undefined &&
|
|
292
|
+
(typeof value !== "number" ||
|
|
293
|
+
!Number.isSafeInteger(value) ||
|
|
294
|
+
value < minimum ||
|
|
295
|
+
value > maximum)
|
|
296
|
+
)
|
|
297
|
+
throw schemaError(
|
|
298
|
+
input,
|
|
299
|
+
field,
|
|
300
|
+
`must be an integer from ${String(minimum)} through ${String(maximum)}`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function validateRequired(input: Record<string, unknown>, mode: SiftLightMode): void {
|
|
306
|
+
if (mode === "audit" && (!Array.isArray(input.patterns) || input.patterns.length === 0))
|
|
307
|
+
throw capabilityError(
|
|
308
|
+
"E_AUDIT_PATTERNS_REQUIRED",
|
|
309
|
+
mode,
|
|
310
|
+
"patterns",
|
|
311
|
+
"mode=audit requires one or more named literal patterns",
|
|
312
|
+
"Provide patterns as objects with id and literal fields",
|
|
313
|
+
);
|
|
314
|
+
if (
|
|
315
|
+
(mode === "concept" || mode === "hybrid") &&
|
|
316
|
+
(typeof input.query !== "string" || !input.query.trim())
|
|
317
|
+
)
|
|
318
|
+
throw capabilityError(
|
|
319
|
+
"E_MODE_QUERY_REQUIRED",
|
|
320
|
+
mode,
|
|
321
|
+
"query",
|
|
322
|
+
`mode=${mode} requires a nonempty natural-language query`,
|
|
323
|
+
`mode=${mode} requires query; provide the natural-language question and retry`,
|
|
324
|
+
);
|
|
325
|
+
if (mode === "await" || mode === "cancel") {
|
|
326
|
+
if (typeof input.operationId !== "string" || !input.operationId.trim())
|
|
327
|
+
throw capabilityError(
|
|
328
|
+
"E_OPERATION_ID_REQUIRED",
|
|
329
|
+
mode,
|
|
330
|
+
"operationId",
|
|
331
|
+
`${mode} requires the exact operationId returned by a prior waiting result`,
|
|
332
|
+
`${mode} requires operationId; copy the returned nextRequest exactly`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function validateOutlineCapability(input: Record<string, unknown>, mode: SiftLightMode): void {
|
|
338
|
+
if (mode !== "outline") return;
|
|
339
|
+
const capability = outlineCapability(typeof input.path === "string" ? input.path : undefined);
|
|
340
|
+
if (!capability.supported && typeof input.path === "string")
|
|
341
|
+
throw outlineCapabilityError(input.path, mode);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Shared outline capability check for a path resolved from a cursor/document. */
|
|
345
|
+
export function outlineCapabilityError(
|
|
346
|
+
path: string,
|
|
347
|
+
mode: SiftLightMode = "outline",
|
|
348
|
+
resolvedDocument = false,
|
|
349
|
+
): RequestContractError | undefined {
|
|
350
|
+
const capability = outlineCapability(path, resolvedDocument);
|
|
351
|
+
if (capability.supported) return undefined;
|
|
352
|
+
const extension = capability.extension ?? "extensionless source";
|
|
353
|
+
return capabilityError(
|
|
354
|
+
"E_MODE_UNSUPPORTED",
|
|
355
|
+
mode,
|
|
356
|
+
"language",
|
|
357
|
+
`outline requires a declared language outline capability; ${extension} is unsupported`,
|
|
358
|
+
`mode=outline is unavailable for ${extension}; choose a language capability from mode=capabilities or use ordinary content search`,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Validate the mode contract before dispatch. This is the sole field-policy entry point. */
|
|
363
|
+
export function validateRequestContract(input: SiftLightInput): void {
|
|
364
|
+
const value: unknown = input;
|
|
365
|
+
if (!record(value)) throw schemaError({}, "request", "request must be an object");
|
|
366
|
+
const raw = value;
|
|
367
|
+
validateValueRanges(raw);
|
|
368
|
+
const mode = modeOf(raw);
|
|
369
|
+
if (!mode)
|
|
370
|
+
throw capabilityError(
|
|
371
|
+
"E_MODE_UNKNOWN",
|
|
372
|
+
inputModeLabel(raw),
|
|
373
|
+
"mode",
|
|
374
|
+
`mode must be one of ${SIFT_LIGHT_MODES.join(", ")}`,
|
|
375
|
+
"Choose one of the advertised modes and retry",
|
|
376
|
+
);
|
|
377
|
+
if (raw.sourceCursor !== undefined && mode !== "inspect")
|
|
378
|
+
throw capabilityError(
|
|
379
|
+
"E_SOURCE_CURSOR_MODE",
|
|
380
|
+
mode,
|
|
381
|
+
"sourceCursor",
|
|
382
|
+
"sourceCursor is only valid with mode=inspect",
|
|
383
|
+
"Copy the source continuation request with mode=inspect",
|
|
384
|
+
);
|
|
385
|
+
if (raw.operationId !== undefined && mode !== "await" && mode !== "cancel")
|
|
386
|
+
throw capabilityError(
|
|
387
|
+
"E_OPERATION_MODE",
|
|
388
|
+
mode,
|
|
389
|
+
"operationId",
|
|
390
|
+
"operationId is only valid with mode=await or mode=cancel",
|
|
391
|
+
"Copy the returned await or cancel request exactly",
|
|
392
|
+
);
|
|
393
|
+
validateRequired(raw, mode);
|
|
394
|
+
validateOutlineCapability(raw, mode);
|
|
395
|
+
|
|
396
|
+
if (mode === "auto" && raw.query !== undefined)
|
|
397
|
+
throw capabilityError(
|
|
398
|
+
"E_MODE_REQUIRED",
|
|
399
|
+
mode,
|
|
400
|
+
"query",
|
|
401
|
+
"query is ambiguous without an explicit discovery or semantic mode",
|
|
402
|
+
'Use mode="files" for filename discovery or mode="concept" for semantic discovery',
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
if (mode === "files" && raw.scope === "expand")
|
|
406
|
+
throw new RequestContractError(
|
|
407
|
+
{
|
|
408
|
+
code: "E_FILES_SCOPE_EXPAND",
|
|
409
|
+
mode,
|
|
410
|
+
issues: [
|
|
411
|
+
{ field: "scope", reason: "scope=expand has no semantics-preserving files repair" },
|
|
412
|
+
],
|
|
413
|
+
recovery: {
|
|
414
|
+
action: "manual",
|
|
415
|
+
reason:
|
|
416
|
+
"Choose mode=files with the intended discovery root and preserve path/filters explicitly.",
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
"mode=files does not support scope=expand; choose the intended discovery root explicitly and preserve path/glob/exclude/hidden filters",
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const allowed = new Set(MODE_FIELDS_BY_MODE[mode]);
|
|
423
|
+
if (mode === "inspect" && raw.sourceCursor !== undefined) {
|
|
424
|
+
allowed.clear();
|
|
425
|
+
for (const field of ["mode", "sourceCursor", "redact"] as const) allowed.add(field);
|
|
426
|
+
}
|
|
427
|
+
if (
|
|
428
|
+
(mode === "auto" || mode === "summary" || mode === "matches") &&
|
|
429
|
+
typeof raw.cursor === "string" &&
|
|
430
|
+
raw.cursor.includes(".analysis")
|
|
431
|
+
) {
|
|
432
|
+
allowed.clear();
|
|
433
|
+
for (const field of ["mode", "cursor", "redact"] as const) allowed.add(field);
|
|
434
|
+
}
|
|
435
|
+
if (
|
|
436
|
+
(mode === "auto" || mode === "summary" || mode === "matches") &&
|
|
437
|
+
typeof raw.cursor === "string" &&
|
|
438
|
+
raw.cursor.trim().length > 0 &&
|
|
439
|
+
!raw.cursor.includes(".analysis")
|
|
440
|
+
) {
|
|
441
|
+
allowed.clear();
|
|
442
|
+
for (const field of ["mode", "cursor", "path", "paths", "redact"] as const) allowed.add(field);
|
|
443
|
+
}
|
|
444
|
+
const allowedNames = new Set<string>(allowed);
|
|
445
|
+
const invalid = Object.keys(raw).filter((field) => !allowedNames.has(field));
|
|
446
|
+
const selectorIssues = selectorIssuesFor(raw, mode);
|
|
447
|
+
if (invalid.length > 0) {
|
|
448
|
+
throw fieldsError(raw, mode, invalid, selectorIssues);
|
|
449
|
+
}
|
|
450
|
+
if (selectorIssues.length > 0) throw selectorError(raw, mode, selectorIssues);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Build the schema-side typed failure for values rejected before service dispatch. */
|
|
454
|
+
export function schemaContractError(
|
|
455
|
+
value: unknown,
|
|
456
|
+
field: string | undefined,
|
|
457
|
+
message: string,
|
|
458
|
+
): RequestContractError {
|
|
459
|
+
const raw = record(value) ? value : {};
|
|
460
|
+
const normalized = boundedField(field?.replace(/^\//u, "") || "request");
|
|
461
|
+
if (normalized === "context")
|
|
462
|
+
return schemaError(
|
|
463
|
+
raw,
|
|
464
|
+
"context",
|
|
465
|
+
`must be an integer from 0 through ${String(MAX_CONTEXT_LINES)}; choose the desired bounded context explicitly`,
|
|
466
|
+
);
|
|
467
|
+
const mode = inputModeLabel(raw);
|
|
468
|
+
return new RequestContractError(
|
|
469
|
+
{
|
|
470
|
+
code: "E_SCHEMA",
|
|
471
|
+
...(mode ? { mode } : {}),
|
|
472
|
+
issues: [{ field: normalized, reason: message }],
|
|
473
|
+
recovery: {
|
|
474
|
+
action: "manual",
|
|
475
|
+
reason:
|
|
476
|
+
"Correct the value according to the advertised schema; no value is clamped or guessed.",
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
`Invalid sift-light arguments at /${normalized}: ${message}`,
|
|
480
|
+
);
|
|
481
|
+
}
|
package/src/request.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { SiftLightError } from "./errors.js";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_PAGE_SIZE,
|
|
4
|
+
MAX_CONTEXT_LINES,
|
|
5
|
+
MAX_PAGE_SIZE,
|
|
6
|
+
MAX_FILE_FILTER_ITEMS,
|
|
7
|
+
MAX_PATH_CHARACTERS,
|
|
8
|
+
MAX_PATTERN_CHARACTERS,
|
|
9
|
+
type SearchRequest,
|
|
10
|
+
} from "./types.js";
|
|
11
|
+
|
|
12
|
+
export interface RawSearchInput {
|
|
13
|
+
pattern?: string;
|
|
14
|
+
path?: string;
|
|
15
|
+
glob?: string | string[];
|
|
16
|
+
exclude?: string | string[];
|
|
17
|
+
literal?: boolean;
|
|
18
|
+
ignoreCase?: boolean;
|
|
19
|
+
hidden?: boolean;
|
|
20
|
+
ignorePolicy?: "respect" | "include";
|
|
21
|
+
context?: number;
|
|
22
|
+
limit?: number;
|
|
23
|
+
redact?: boolean;
|
|
24
|
+
scope?: "strict" | "expand";
|
|
25
|
+
wholeWord?: boolean;
|
|
26
|
+
modifiedAfter?: number;
|
|
27
|
+
modifiedBefore?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function list(value: string | string[] | undefined): string[] {
|
|
31
|
+
if (value === undefined) return [];
|
|
32
|
+
return (Array.isArray(value) ? value : [value]).filter((item) => item.length > 0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validateText(
|
|
36
|
+
value: string,
|
|
37
|
+
field: string,
|
|
38
|
+
maxCharacters: number,
|
|
39
|
+
singleLine = false,
|
|
40
|
+
): void {
|
|
41
|
+
if (!value.isWellFormed() || /\0/.test(value) || (singleLine && /[\r\n]/.test(value)))
|
|
42
|
+
throw new SiftLightError(`${field} must be well-formed text without NUL or line breaks`);
|
|
43
|
+
if (value.length > maxCharacters)
|
|
44
|
+
throw new SiftLightError(
|
|
45
|
+
`${field} is too long (maximum ${String(maxCharacters)} characters); use a shorter value or a narrower working directory`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function validateSearchPath(value: string, field = "path"): void {
|
|
50
|
+
validateText(value.replace(/^@/, ""), field, MAX_PATH_CHARACTERS, true);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Validate input before any filesystem or subprocess boundary is reached. */
|
|
54
|
+
export function validateRawSearchInput(input: RawSearchInput): void {
|
|
55
|
+
if (input.pattern !== undefined) validateText(input.pattern, "pattern", MAX_PATTERN_CHARACTERS);
|
|
56
|
+
if (input.path !== undefined) {
|
|
57
|
+
validateSearchPath(input.path);
|
|
58
|
+
}
|
|
59
|
+
for (const [field, value] of [
|
|
60
|
+
["glob", input.glob],
|
|
61
|
+
["exclude", input.exclude],
|
|
62
|
+
] as const) {
|
|
63
|
+
const values = list(value);
|
|
64
|
+
if (values.length > MAX_FILE_FILTER_ITEMS)
|
|
65
|
+
throw new SiftLightError(`${field} accepts at most ${String(MAX_FILE_FILTER_ITEMS)} entries`);
|
|
66
|
+
values.forEach((item) => validateText(item, field, MAX_PATH_CHARACTERS, true));
|
|
67
|
+
}
|
|
68
|
+
for (const [field, value] of [
|
|
69
|
+
["modifiedAfter", input.modifiedAfter],
|
|
70
|
+
["modifiedBefore", input.modifiedBefore],
|
|
71
|
+
] as const) {
|
|
72
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < 0))
|
|
73
|
+
throw new SiftLightError(`${field} must be a non-negative Unix timestamp in milliseconds`);
|
|
74
|
+
}
|
|
75
|
+
if (
|
|
76
|
+
input.modifiedAfter !== undefined &&
|
|
77
|
+
input.modifiedBefore !== undefined &&
|
|
78
|
+
input.modifiedAfter > input.modifiedBefore
|
|
79
|
+
)
|
|
80
|
+
throw new SiftLightError("modifiedAfter must be earlier than or equal to modifiedBefore");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function boundedInteger(
|
|
84
|
+
value: number | undefined,
|
|
85
|
+
fallback: number,
|
|
86
|
+
minimum: number,
|
|
87
|
+
maximum: number,
|
|
88
|
+
field: string,
|
|
89
|
+
): number {
|
|
90
|
+
const candidate = value ?? fallback;
|
|
91
|
+
if (!Number.isSafeInteger(candidate) || candidate < minimum || candidate > maximum) {
|
|
92
|
+
throw new SiftLightError(
|
|
93
|
+
`${field} must be an integer from ${String(minimum)} through ${String(maximum)}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return candidate;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function normalizeRequest(input: RawSearchInput): SearchRequest {
|
|
100
|
+
validateRawSearchInput(input);
|
|
101
|
+
if (input.scope !== undefined && input.scope !== "strict" && input.scope !== "expand")
|
|
102
|
+
throw new SiftLightError("scope must be strict or expand");
|
|
103
|
+
if (
|
|
104
|
+
input.ignorePolicy !== undefined &&
|
|
105
|
+
input.ignorePolicy !== "respect" &&
|
|
106
|
+
input.ignorePolicy !== "include"
|
|
107
|
+
)
|
|
108
|
+
throw new SiftLightError("ignorePolicy must be respect or include");
|
|
109
|
+
const pattern = input.pattern;
|
|
110
|
+
if (pattern === undefined) {
|
|
111
|
+
throw new SiftLightError("pattern is required when cursor is not provided");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const path = input.path?.replace(/^@/, "");
|
|
115
|
+
return {
|
|
116
|
+
pattern,
|
|
117
|
+
...(path ? { path } : {}),
|
|
118
|
+
glob: list(input.glob),
|
|
119
|
+
exclude: list(input.exclude),
|
|
120
|
+
literal: input.literal ?? false,
|
|
121
|
+
...(input.ignoreCase === undefined ? {} : { ignoreCase: input.ignoreCase }),
|
|
122
|
+
hidden: input.hidden ?? true,
|
|
123
|
+
ignorePolicy: input.ignorePolicy ?? "respect",
|
|
124
|
+
context: boundedInteger(input.context, 0, 0, MAX_CONTEXT_LINES, "context"),
|
|
125
|
+
pageSize: boundedInteger(input.limit, DEFAULT_PAGE_SIZE, 1, MAX_PAGE_SIZE, "limit"),
|
|
126
|
+
redact: input.redact ?? false,
|
|
127
|
+
...(input.modifiedAfter !== undefined ? { modifiedAfterMs: input.modifiedAfter } : {}),
|
|
128
|
+
...(input.modifiedBefore !== undefined ? { modifiedBeforeMs: input.modifiedBefore } : {}),
|
|
129
|
+
...(input.scope !== undefined ? { scope: input.scope } : {}),
|
|
130
|
+
...(input.wholeWord !== undefined ? { wholeWord: input.wholeWord } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|