sift-light 1.0.1 → 1.0.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.
@@ -0,0 +1,247 @@
1
+ import type { SourceDocument } from "./source-document.js";
2
+ import type { SyntaxAnalysis, SyntaxRole } from "./syntax-types.js";
3
+
4
+ /**
5
+ * Bounded lexical role scanner for Python.
6
+ *
7
+ * It separates comments, string literals and code exactly, and marks `def`/`class`
8
+ * names, import statements and call-shaped callees. It does not parse Python:
9
+ * comments, strings and code are lexical facts ("syntax" certainty); a call is a
10
+ * name followed by `(` in code and therefore stays a "candidate". Offsets are
11
+ * UTF-16 indices into `document.text`, like every other role provider.
12
+ */
13
+
14
+ const KEYWORDS = new Set([
15
+ "and",
16
+ "as",
17
+ "assert",
18
+ "async",
19
+ "await",
20
+ "case",
21
+ "del",
22
+ "elif",
23
+ "else",
24
+ "except",
25
+ "for",
26
+ "from",
27
+ "global",
28
+ "if",
29
+ "import",
30
+ "in",
31
+ "is",
32
+ "lambda",
33
+ "match",
34
+ "nonlocal",
35
+ "not",
36
+ "or",
37
+ "raise",
38
+ "return",
39
+ "while",
40
+ "with",
41
+ "yield",
42
+ ]);
43
+ const STRING_PREFIX = /^(?:[rRbBuUfF]|[rR][bBfF]|[bBfF][rR])?$/u;
44
+ const IDENTIFIER_START = /[\p{ID_Start}_]/u;
45
+ const IDENTIFIER_PART = /[\p{ID_Continue}]/u;
46
+
47
+ interface Span {
48
+ start: number;
49
+ end: number;
50
+ }
51
+
52
+ interface Lexed {
53
+ comments: Span[];
54
+ strings: Span[];
55
+ /** `true` at each UTF-16 index that belongs to code (outside comments/strings). */
56
+ code: Uint8Array;
57
+ }
58
+
59
+ function lex(text: string): Lexed {
60
+ const comments: Span[] = [];
61
+ const strings: Span[] = [];
62
+ const code = new Uint8Array(text.length).fill(1);
63
+ const mark = (start: number, end: number): void => {
64
+ code.fill(0, start, end);
65
+ };
66
+ let index = 0;
67
+ while (index < text.length) {
68
+ const character = text[index];
69
+ if (character === "#") {
70
+ const newline = text.indexOf("\n", index);
71
+ const end = newline < 0 ? text.length : newline;
72
+ comments.push({ start: index, end });
73
+ mark(index, end);
74
+ index = end;
75
+ continue;
76
+ }
77
+ if (character !== "'" && character !== '"') {
78
+ index += 1;
79
+ continue;
80
+ }
81
+ // A string prefix (r, b, f, u and their pairs) is part of the literal.
82
+ let prefixStart = index;
83
+ while (prefixStart > 0 && /[rRbBuUfF]/u.test(text[prefixStart - 1] ?? "")) prefixStart -= 1;
84
+ const prefix = text.slice(prefixStart, index);
85
+ const beforePrefix = text[prefixStart - 1] ?? "";
86
+ const start =
87
+ STRING_PREFIX.test(prefix) && !IDENTIFIER_PART.test(beforePrefix) ? prefixStart : index;
88
+ const triple = text.slice(index, index + 3) === character.repeat(3);
89
+ const delimiter = triple ? character.repeat(3) : character;
90
+ let cursor = index + delimiter.length;
91
+ let end = text.length;
92
+ while (cursor < text.length) {
93
+ const current = text[cursor];
94
+ // A backslash always protects the next character from ending the literal,
95
+ // including in raw strings (where the backslash itself is kept).
96
+ if (current === "\\") {
97
+ cursor += 2;
98
+ continue;
99
+ }
100
+ if (!triple && current === "\n") {
101
+ end = cursor;
102
+ break;
103
+ }
104
+ if (text.startsWith(delimiter, cursor)) {
105
+ end = cursor + delimiter.length;
106
+ break;
107
+ }
108
+ cursor += 1;
109
+ }
110
+ strings.push({ start, end });
111
+ mark(start, end);
112
+ index = end;
113
+ }
114
+ return { comments, strings, code };
115
+ }
116
+
117
+ function codeRanges(code: Uint8Array): Span[] {
118
+ const ranges: Span[] = [];
119
+ let start = -1;
120
+ for (let index = 0; index <= code.length; index += 1) {
121
+ const inCode = index < code.length && code[index] === 1;
122
+ if (inCode && start < 0) start = index;
123
+ else if (!inCode && start >= 0) {
124
+ ranges.push({ start, end: index });
125
+ start = -1;
126
+ }
127
+ }
128
+ return ranges;
129
+ }
130
+
131
+ function identifierEnd(text: string, start: number): number {
132
+ let end = start;
133
+ while (end < text.length && IDENTIFIER_PART.test(text[end] ?? "")) end += 1;
134
+ return end;
135
+ }
136
+
137
+ function skipSpaces(text: string, index: number, code: Uint8Array): number {
138
+ let cursor = index;
139
+ while (cursor < text.length && code[cursor] === 1 && /[ \t]/u.test(text[cursor] ?? ""))
140
+ cursor += 1;
141
+ return cursor;
142
+ }
143
+
144
+ /** Index after the statement starting at `start`, following parenthesized continuation lines. */
145
+ function statementEnd(text: string, start: number, code: Uint8Array): number {
146
+ let depth = 0;
147
+ for (let index = start; index < text.length; index += 1) {
148
+ if (code[index] !== 1) continue;
149
+ const character = text[index];
150
+ if (character === "(" || character === "[" || character === "{") depth += 1;
151
+ else if (character === ")" || character === "]" || character === "}")
152
+ depth = Math.max(0, depth - 1);
153
+ else if (character === "\\" && text[index + 1] === "\n") index += 1;
154
+ else if (character === "\n" && depth === 0) return index;
155
+ }
156
+ return text.length;
157
+ }
158
+
159
+ export function pythonRoleAnalysis(document: SourceDocument): SyntaxAnalysis {
160
+ const text = document.text;
161
+ const { comments, strings, code } = lex(text);
162
+ const roles: SyntaxRole[] = [];
163
+ const push = (
164
+ start: number,
165
+ end: number,
166
+ role: SyntaxRole["role"],
167
+ certainty: SyntaxRole["certainty"],
168
+ subkind?: string,
169
+ ): void => {
170
+ if (start < end)
171
+ roles.push({ start, end, role, certainty, node: 0, ...(subkind ? { subkind } : {}) });
172
+ };
173
+ for (const span of comments) push(span.start, span.end, "comment", "syntax");
174
+ for (const span of strings) push(span.start, span.end, "string", "syntax");
175
+ for (const span of codeRanges(code)) push(span.start, span.end, "code", "syntax");
176
+
177
+ let index = 0;
178
+ let lineStart = true;
179
+ while (index < text.length) {
180
+ const character = text[index] ?? "";
181
+ if (character === "\n") {
182
+ lineStart = true;
183
+ index += 1;
184
+ continue;
185
+ }
186
+ if (code[index] !== 1 || !IDENTIFIER_START.test(character)) {
187
+ if (!/[ \t]/u.test(character)) lineStart = false;
188
+ index += 1;
189
+ continue;
190
+ }
191
+ const previous = text[index - 1] ?? "";
192
+ if (IDENTIFIER_PART.test(previous)) {
193
+ index += 1;
194
+ continue;
195
+ }
196
+ const end = identifierEnd(text, index);
197
+ const word = text.slice(index, end);
198
+ const atStatementStart = lineStart;
199
+ lineStart = false;
200
+ if (atStatementStart && (word === "import" || word === "from")) {
201
+ const statement = statementEnd(text, index, code);
202
+ const body = text.slice(index, statement);
203
+ if (word === "import" || /\bimport\b/u.test(body)) {
204
+ push(index, statement, "import", "syntax", word === "from" ? "from-import" : "import");
205
+ index = statement;
206
+ continue;
207
+ }
208
+ }
209
+ if (word === "def" || word === "class") {
210
+ const nameStart = skipSpaces(text, end, code);
211
+ if (IDENTIFIER_START.test(text[nameStart] ?? "")) {
212
+ const nameEnd = identifierEnd(text, nameStart);
213
+ push(nameStart, nameEnd, "declaration", "syntax", word === "def" ? "function" : "class");
214
+ index = nameEnd;
215
+ continue;
216
+ }
217
+ }
218
+ // Attribute chains such as `client.fetch(` are one callee.
219
+ let chainEnd = end;
220
+ while (text[chainEnd] === "." && IDENTIFIER_START.test(text[chainEnd + 1] ?? "")) {
221
+ chainEnd = identifierEnd(text, chainEnd + 1);
222
+ }
223
+ const open = skipSpaces(text, chainEnd, code);
224
+ const lastSegment = text.slice(text.lastIndexOf(".", chainEnd - 1) + 1, chainEnd);
225
+ const before = text.slice(Math.max(0, index - 4), index);
226
+ if (
227
+ text[open] === "(" &&
228
+ code[open] === 1 &&
229
+ !KEYWORDS.has(word) &&
230
+ !KEYWORDS.has(lastSegment) &&
231
+ !/\bdef\s+$|\bclass\s+$/u.test(before)
232
+ ) {
233
+ push(index, open + 1, "call", "candidate", "lexical-call");
234
+ }
235
+ index = chainEnd;
236
+ }
237
+ roles.sort((a, b) => a.start - b.start || a.end - b.end || a.role.localeCompare(b.role));
238
+ return {
239
+ status: "ok",
240
+ nodes: [],
241
+ children: [],
242
+ symbols: [],
243
+ roles,
244
+ diagnostics: [],
245
+ limited: false,
246
+ };
247
+ }
package/src/redaction.ts CHANGED
@@ -7,6 +7,10 @@ const SENSITIVE_ASSIGNMENT = new RegExp(
7
7
  "gi",
8
8
  );
9
9
  const SENSITIVE_TOKEN = /\b(?:sk|ghp|xox[baprs])[-_][A-Za-z0-9][A-Za-z0-9_-]{7,}\b/g;
10
+ // An unquoted value that starts as a call or index expression (`env.get(`,
11
+ // `os.environ[`) is source code that reads a secret, not the secret itself.
12
+ // Masking it hides the evidence a reader needs, such as which key is read.
13
+ const CODE_EXPRESSION = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\s*[([]/u;
10
14
  const TYPE_ONLY_VALUES = new Set([
11
15
  "boolean",
12
16
  "number",
@@ -27,9 +31,12 @@ function redactString(value: string): { value: string; count: number } {
27
31
  SENSITIVE_ASSIGNMENT,
28
32
  (match: string, prefix: string, rawValue: string) => {
29
33
  const unquoted = rawValue.replace(/^["']|["']$/g, "").toLowerCase();
30
- if (TYPE_ONLY_VALUES.has(unquoted)) return match;
34
+ if (TYPE_ONLY_VALUES.has(unquoted) || CODE_EXPRESSION.test(rawValue)) return match;
31
35
  count += 1;
32
- return `${prefix}"[REDACTED]"`;
36
+ // Keep the source's own quoting: adding quotes to an unquoted value would
37
+ // misrepresent the file (for example, a bare `.env` value shown as quoted).
38
+ const quote = rawValue.startsWith('"') || rawValue.startsWith("'") ? rawValue[0] : "";
39
+ return `${prefix}${quote}[REDACTED]${quote}`;
33
40
  },
34
41
  );
35
42
  redacted = redacted.replace(SENSITIVE_TOKEN, () => {
@@ -0,0 +1,48 @@
1
+ import { SiftLightError } from "./errors.js";
2
+ import type { SiftLightInput } from "./service.js";
3
+
4
+ /**
5
+ * Field names that callers commonly send as a mode. They are not modes: the field
6
+ * alone selects the multi-term search. The schema accepts them so the request can
7
+ * be served, and the result discloses the normalization.
8
+ */
9
+ export const MODE_ALIASES = ["anyOf", "allOf"] as const;
10
+ export type ModeAlias = (typeof MODE_ALIASES)[number];
11
+
12
+ /** Public request shape before alias normalization. */
13
+ export type SiftLightRequest = Omit<SiftLightInput, "mode"> & {
14
+ mode?: SiftLightInput["mode"] | ModeAlias;
15
+ };
16
+
17
+ export interface NormalizedRequest {
18
+ input: SiftLightInput;
19
+ notes: string[];
20
+ }
21
+
22
+ /**
23
+ * The single place where unambiguous request shapes are rewritten before the
24
+ * mode contract runs. Every rewrite preserves the requested scope and evidence
25
+ * semantics and is reported back as a note; anything ambiguous still fails.
26
+ */
27
+ export function normalizeRequestAliases(request: SiftLightRequest): NormalizedRequest {
28
+ const notes: string[] = [];
29
+ const { mode, ...rest } = request;
30
+ let input: SiftLightInput = rest;
31
+ if (mode === "anyOf" || mode === "allOf") {
32
+ if (request[mode] === undefined)
33
+ throw new SiftLightError(`mode=${mode} is not a mode; pass ${mode}:[...terms] and omit mode`);
34
+ notes.push(`mode="${mode}" is not a mode; the ${mode} field alone selects this search.`);
35
+ } else if (mode === "summary" && request.anyOf !== undefined) {
36
+ notes.push(
37
+ "anyOf returns one analysis page whose header already carries per-file statistics; mode=summary was served by that page.",
38
+ );
39
+ } else if (mode !== undefined) {
40
+ input = { ...rest, mode };
41
+ }
42
+ if (input.sourceCursor !== undefined && input.line !== undefined) {
43
+ const { line: _line, ...withoutLine } = input;
44
+ input = withoutLine;
45
+ notes.push("line is ignored with sourceCursor; the continuation selects its own source range.");
46
+ }
47
+ return { input, notes };
48
+ }
@@ -62,7 +62,17 @@ export const MODE_FIELDS_BY_MODE: Record<SiftLightMode, readonly RequestField[]>
62
62
  auto: ordinaryFields,
63
63
  summary: ordinaryFields,
64
64
  matches: ordinaryFields,
65
- inspect: [...commonFields, "path", "line", "cursor", "matchIndex", "matchIndices", "targets"],
65
+ inspect: [
66
+ ...commonFields,
67
+ "path",
68
+ "paths",
69
+ "line",
70
+ "cursor",
71
+ "matchIndex",
72
+ "matchIndices",
73
+ "targets",
74
+ "scope",
75
+ ],
66
76
  outline: [...commonFields, "path", "line", "symbol", "cursor", "matchIndex", "maxFilesToParse"],
67
77
  imports: [
68
78
  ...commonFields,
@@ -82,7 +92,16 @@ export const MODE_FIELDS_BY_MODE: Record<SiftLightMode, readonly RequestField[]>
82
92
  "matchIndex",
83
93
  "maxFilesToParse",
84
94
  ],
85
- files: [...commonFields, "query", ...sourceFilters, "modifiedAfter", "modifiedBefore"],
95
+ files: [
96
+ ...commonFields,
97
+ "query",
98
+ ...sourceFilters,
99
+ "ignorePolicy",
100
+ "limit",
101
+ "scope",
102
+ "modifiedAfter",
103
+ "modifiedBefore",
104
+ ],
86
105
  structure: [...commonFields, "pattern", ...sourceFilters, "maxFilesToParse"],
87
106
  concept: [...commonFields, "query", ...sourceFilters, "maxFilesToParse"],
88
107
  hybrid: [...commonFields, "query", ...sourceFilters, "conceptLimit", "maxFilesToParse"],
@@ -92,9 +111,7 @@ export const MODE_FIELDS_BY_MODE: Record<SiftLightMode, readonly RequestField[]>
92
111
  cancel: ["mode", "operationId"],
93
112
  };
94
113
 
95
- export const SAFE_DROP_FIELDS: Partial<Record<SiftLightMode, readonly RequestField[]>> = {
96
- files: ["scope"],
97
- };
114
+ export const SAFE_DROP_FIELDS: Partial<Record<SiftLightMode, readonly RequestField[]>> = {};
98
115
 
99
116
  export const SUPPORTED_OUTLINE_EXTENSIONS = new Set(
100
117
  DEFAULT_LANGUAGE_CAPABILITIES.flatMap((descriptor) =>
@@ -149,9 +166,9 @@ export const REQUEST_FIELD_GUIDANCE: Partial<Record<RequestField, string>> = {
149
166
  context: "context is an output-context budget and is never silently dropped",
150
167
  limit: "limit is an output/page budget and is never silently dropped",
151
168
  scope:
152
- "scope applies to ordinary content search; mode=files rejects this field because its scope is fixed strict, and only redundant strict may be removed",
169
+ "scope applies to ordinary content search; mode=files is always strict, so it accepts only the redundant scope=strict",
153
170
  ignorePolicy:
154
- "respect keeps repository ignore rules; include searches ignored files but always excludes .git internals and protected paths",
171
+ "respect keeps repository ignore rules and discloses ignored files; include also searches or lists ignored files but always excludes .git internals and protected paths",
155
172
  patterns: "audit accepts named exact-literal patterns and returns one closure receipt",
156
173
  maxFilesToParse:
157
174
  "concept/hybrid automatically batch the requested scope; this optional field sets an advanced hard file ceiling",
@@ -132,6 +132,21 @@ function plainFilePatternRecovery(
132
132
  );
133
133
  }
134
134
 
135
+ /** `pattern: ".*"` in files mode means "every file": the exact repair omits it. */
136
+ function matchAllFilePatternRecovery(
137
+ mode: SiftLightMode,
138
+ input: Record<string, unknown>,
139
+ invalid: readonly string[],
140
+ ): boolean {
141
+ return (
142
+ mode === "files" &&
143
+ invalid.includes("pattern") &&
144
+ input.query === undefined &&
145
+ typeof input.pattern === "string" &&
146
+ /^(?:\.\*|\.\+|\*+)$/u.test(input.pattern.trim())
147
+ );
148
+ }
149
+
135
150
  function safeNextRequest(
136
151
  input: Record<string, unknown>,
137
152
  mode: SiftLightMode,
@@ -140,7 +155,7 @@ function safeNextRequest(
140
155
  if (input.redact === true && containsSensitiveText(input)) return undefined;
141
156
  const safe = new Set<string>(SAFE_DROP_FIELDS[mode] ?? []);
142
157
  const plainFilePattern = plainFilePatternRecovery(mode, input, invalid);
143
- if (plainFilePattern) safe.add("pattern");
158
+ if (plainFilePattern || matchAllFilePatternRecovery(mode, input, invalid)) safe.add("pattern");
144
159
  if (invalid.some((field) => !safe.has(field))) return undefined;
145
160
  const next: Record<string, unknown> = { ...input };
146
161
  for (const field of invalid) delete next[field];
@@ -203,7 +218,9 @@ function fieldsError(
203
218
  action: "retry",
204
219
  reason: plainFilePatternRecovery(mode, input, invalid)
205
220
  ? "For filename discovery, move the plain text from pattern to query and copy nextRequest; all filters are preserved."
206
- : `Remove only ${visibleFields} and copy the exact nextRequest; all other fields are preserved.`,
221
+ : matchAllFilePatternRecovery(mode, input, invalid)
222
+ ? "A match-all pattern lists every file; omitting query does that, so copy nextRequest; all filters are preserved."
223
+ : `Remove only ${visibleFields} and copy the exact nextRequest; all other fields are preserved.`,
207
224
  nextRequest,
208
225
  }
209
226
  : {
@@ -458,7 +475,8 @@ export function validateRequestContract(input: SiftLightInput): void {
458
475
  const allowed = new Set(MODE_FIELDS_BY_MODE[mode]);
459
476
  if (mode === "inspect" && raw.sourceCursor !== undefined) {
460
477
  allowed.clear();
461
- for (const field of ["mode", "sourceCursor", "redact"] as const) allowed.add(field);
478
+ // path is checked against the continuation's own source; it cannot redirect it.
479
+ for (const field of ["mode", "sourceCursor", "redact", "path"] as const) allowed.add(field);
462
480
  }
463
481
  if (
464
482
  (mode === "auto" || mode === "summary" || mode === "matches") &&
package/src/request.ts CHANGED
@@ -108,7 +108,9 @@ export function normalizeRequest(input: RawSearchInput): SearchRequest {
108
108
  throw new SiftLightError("ignorePolicy must be respect or include");
109
109
  const pattern = input.pattern;
110
110
  if (pattern === undefined) {
111
- throw new SiftLightError("pattern is required when cursor is not provided");
111
+ throw new SiftLightError(
112
+ 'pattern is required when cursor is not provided; to list files use mode="files" (query optional), to read a known file use mode="inspect" with path',
113
+ );
112
114
  }
113
115
 
114
116
  const path = input.path?.replace(/^@/, "");
package/src/runtime.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { SiftLightLocale } from "./config.js";
2
- import type { SiftLightInput, SiftLightSearchOptions, SiftLightService } from "./service.js";
2
+ import type { SiftLightRequest, SiftLightSearchOptions, SiftLightService } from "./service.js";
3
3
  import { type SessionSummarySnapshot, SessionSummary } from "./session-summary.js";
4
4
  import type { ContextBudget, SiftLightResult } from "./types.js";
5
5
 
@@ -12,7 +12,7 @@ export class SiftLightRuntime {
12
12
  }
13
13
 
14
14
  async search(
15
- input: SiftLightInput,
15
+ input: SiftLightRequest,
16
16
  cwd: string,
17
17
  signal?: AbortSignal,
18
18
  contextBudget?: ContextBudget,
package/src/service.ts CHANGED
@@ -7,6 +7,18 @@ import { resolve } from "node:path";
7
7
  import { CursorError, SiftLightError } from "./errors.js";
8
8
  import { DISCOVERY_MODE_REQUIRED_ERROR } from "./discovery-errors.js";
9
9
  import { validateRequestContract } from "./request-contract.js";
10
+ import { normalizeRequestAliases, type SiftLightRequest } from "./request-aliases.js";
11
+
12
+ export type { SiftLightRequest } from "./request-aliases.js";
13
+
14
+ /** Normalizations are part of the evidence: show them in text and details. */
15
+ function withRequestNotes(result: SiftLightResult, notes: readonly string[]): SiftLightResult {
16
+ if (notes.length === 0) return result;
17
+ return {
18
+ text: `${notes.map((note) => `[Request note: ${note}]`).join("\n")}\n${result.text}`,
19
+ details: { ...result.details, requestNotes: [...notes] },
20
+ };
21
+ }
10
22
  import {
11
23
  formatMatchMetadataPage,
12
24
  formatMatchPage,
@@ -392,10 +404,20 @@ export class SiftLightService {
392
404
  }
393
405
 
394
406
  async search(
395
- input: SiftLightInput,
407
+ request: SiftLightRequest,
396
408
  cwd: string,
397
409
  signal?: AbortSignal,
398
410
  options: SiftLightSearchOptions = {},
411
+ ): Promise<SiftLightResult> {
412
+ const { input, notes } = normalizeRequestAliases(request);
413
+ return withRequestNotes(await this.#searchNormalized(input, cwd, signal, options), notes);
414
+ }
415
+
416
+ async #searchNormalized(
417
+ input: SiftLightInput,
418
+ cwd: string,
419
+ signal: AbortSignal | undefined,
420
+ options: SiftLightSearchOptions,
399
421
  ): Promise<SiftLightResult> {
400
422
  validateRawSearchInput(input);
401
423
  validateRequestContract(input);
@@ -1,6 +1,6 @@
1
1
  import type { SiftLightLocale } from "./config.js";
2
2
  import { SIFT_LIGHT_VERSION } from "./package-version.js";
3
- import type { SiftLightInput } from "./service.js";
3
+ import type { SiftLightRequest } from "./service.js";
4
4
  import type { SiftLightResult } from "./types.js";
5
5
 
6
6
  export const SESSION_STATUS_KEY = "sift-light_session";
@@ -13,7 +13,7 @@ export interface SessionSummarySnapshot {
13
13
  failedCalls: number;
14
14
  }
15
15
 
16
- function isNewQuery(input: SiftLightInput): boolean {
16
+ function isNewQuery(input: SiftLightRequest): boolean {
17
17
  return (
18
18
  input.cursor === undefined &&
19
19
  input.sourceCursor === undefined &&
@@ -21,7 +21,7 @@ function isNewQuery(input: SiftLightInput): boolean {
21
21
  );
22
22
  }
23
23
 
24
- function wasAutomaticallyOrganized(input: SiftLightInput, result: SiftLightResult): boolean {
24
+ function wasAutomaticallyOrganized(input: SiftLightRequest, result: SiftLightResult): boolean {
25
25
  const autoMode = input.mode === undefined || input.mode === "auto";
26
26
  return autoMode && input.limit === undefined && result.details.summaryFilesShown !== undefined;
27
27
  }
@@ -59,7 +59,7 @@ export class SessionSummary {
59
59
  failedCalls: 0,
60
60
  };
61
61
 
62
- record(input: SiftLightInput, result: SiftLightResult): void {
62
+ record(input: SiftLightRequest, result: SiftLightResult): void {
63
63
  if (!isNewQuery(input)) return;
64
64
  this.#snapshot.queries += 1;
65
65
  if (result.details.status === "complete") this.#snapshot.completeQueries += 1;
@@ -33,6 +33,8 @@ export interface SourceInspectionTarget {
33
33
  /** Raw source byte offset; retained-match focus is line-relative. */
34
34
  absoluteFocus?: number;
35
35
  focus?: number;
36
+ /** Structure already proven by the analysis that produced `range`. */
37
+ structure?: StructureDetails;
36
38
  expectedRevision?: SourceRevision;
37
39
  unverified?: boolean;
38
40
  retry?: SiftLightInput;
@@ -121,7 +123,20 @@ async function prepare(
121
123
  let range = target.range;
122
124
  let details: StructureDetails = { status: "no-symbol" };
123
125
  const language = syntaxLanguage(document.path);
124
- if (document.utf8 && language && language !== "go") {
126
+ if (target.range && target.structure) {
127
+ document.checkRange(target.range);
128
+ const lines = {
129
+ startLine: document.lineAt(target.range.start),
130
+ endLine: document.lineAt(Math.max(target.range.start, target.range.end - 1)),
131
+ };
132
+ // Whole lines keep leading modifiers such as `export` or decorators readable.
133
+ range = document.lineRange(lines.startLine, lines.endLine);
134
+ details = {
135
+ ...target.structure,
136
+ range: lines,
137
+ ...(target.structure.symbol ? { symbol: { ...target.structure.symbol, range: lines } } : {}),
138
+ };
139
+ } else if (document.utf8 && language && language !== "go") {
125
140
  const syntax = await access.syntax(document);
126
141
  details = {
127
142
  status:
@@ -205,7 +220,9 @@ async function prepare(
205
220
  Math.min(document.lineStarts.length, target.line + 10),
206
221
  );
207
222
  const boundary: Exclude<SourceBoundary, "mixed"> = target.range
208
- ? "requested-range"
223
+ ? target.structure
224
+ ? "syntax"
225
+ : "requested-range"
209
226
  : details.status === "available" && details.range
210
227
  ? "syntax"
211
228
  : "line-window";
@@ -692,8 +709,16 @@ export async function continueSource(
692
709
  cursor: string,
693
710
  access: SourceAccess,
694
711
  continuations: SourceContinuations,
712
+ expectedPath?: string,
695
713
  ): Promise<SiftLightResult> {
696
714
  const state = continuations.resolve(cursor);
715
+ if (
716
+ expectedPath !== undefined &&
717
+ resolve(access.cwd, expectedPath.replace(/^@/, "")) !== resolve(access.cwd, state.source.path)
718
+ )
719
+ throw new SiftLightError(
720
+ `sourceCursor continues ${JSON.stringify(state.source.path)}, not ${JSON.stringify(expectedPath)}; copy the returned nextRequest exactly`,
721
+ );
697
722
  const document = await access.load(state.source.path, state.source);
698
723
  const page = sourcePage(document, state.remaining, MAX_RESULT_BYTES - 1400);
699
724
  const next = continuations.advance(cursor, page.fragment);