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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sift-light",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "omp": {
@@ -13,7 +13,7 @@ export const MAX_ANALYSIS_STORAGE_BYTES = 32 * 1024 * 1024;
13
13
  export const ANALYSIS_METADATA_RESERVE_BYTES = 64 * 1024;
14
14
  export const MAX_ANALYSIS_REASONS = 64;
15
15
  export const MAX_ANALYSIS_REASON_BYTES = 4 * 1024;
16
- export const MIN_ANY_OF_TERMS = 2;
16
+ export const MIN_ANY_OF_TERMS = 1;
17
17
  export const MAX_ANY_OF_TERMS = 8;
18
18
  /** Public union size; execution keeps the proven eight-term scan boundary per chunk. */
19
19
  export const MAX_ANY_OF_TOTAL_TERMS = 64;
@@ -206,7 +206,10 @@ function publicAnalysisItem(
206
206
  return {
207
207
  path: item.path,
208
208
  line: item.line,
209
- label: result.kind === "outline" && modelOutput ? item.label : publicAnalysisLabel(result),
209
+ label:
210
+ (result.kind === "outline" && modelOutput) || result.kind === "files"
211
+ ? item.label
212
+ : publicAnalysisLabel(result),
210
213
  index: index + 1,
211
214
  ...(inspect ? { inspect } : {}),
212
215
  ...(publicDetails ? { details: publicDetails } : {}),
@@ -455,7 +458,8 @@ export class AnalysisStore {
455
458
  if (items.length >= 30 || !appendItem(index)) break;
456
459
  }
457
460
  } else {
458
- for (let index = offset; index < result.items.length && items.length < 30; index += 1) {
461
+ const pageSize = result.pageSize ?? 30;
462
+ for (let index = offset; index < result.items.length && items.length < pageSize; index += 1) {
459
463
  if (!appendItem(index)) break;
460
464
  }
461
465
  }
@@ -4,7 +4,12 @@ import type { SearchScopeDetails, ResultStatistics } from "./types.js";
4
4
  import type { ValidationDetails } from "./validation-types.js";
5
5
  import type { ConceptSourceSummary } from "./concept-source-generation.js";
6
6
 
7
- export type CoverageStatus = "complete" | "partial" | "skipped" | "not-applicable";
7
+ export type CoverageStatus =
8
+ | "complete"
9
+ | "partial"
10
+ | "policy-filtered"
11
+ | "skipped"
12
+ | "not-applicable";
8
13
 
9
14
  export const SEMANTIC_JUDGE_CLASSIFICATIONS = [
10
15
  "implementation-candidate",
@@ -125,6 +130,8 @@ export interface AnalysisDetails {
125
130
  passagesRanked?: number;
126
131
  elapsedMs?: number;
127
132
  filesEnumerated?: number;
133
+ ignoredFiles?: number;
134
+ ignoredMatches?: number;
128
135
  filesAdmitted?: number;
129
136
  filesParsed?: number;
130
137
  filesSkipped?: number;
@@ -159,4 +166,6 @@ export interface AnalysisResultSet {
159
166
  stats?: AnalysisDetails["stats"];
160
167
  sourceGeneration?: ConceptSourceSummary;
161
168
  redact?: boolean;
169
+ /** Explicit output page size (`limit`); pages default to 30 items. */
170
+ pageSize?: number;
162
171
  }
@@ -39,7 +39,6 @@ import {
39
39
  continueSource,
40
40
  inspectDocuments,
41
41
  matchInspectionTarget,
42
- inspectDocumentsMetadata,
43
42
  type SourceInspectionTarget,
44
43
  } from "./source-inspection.js";
45
44
  import { validateSavedEvidence } from "./evidence-validation.js";
@@ -48,6 +47,7 @@ import type { CodeStructureProvider } from "./structure.js";
48
47
  import { filterRoleOccurrences, findFunctionConjunctions } from "./syntax-search.js";
49
48
  import { syntaxLanguage } from "./syntax.js";
50
49
  import { parsePythonOutline } from "./python-outline.js";
50
+ import { pythonRoleAnalysis } from "./python-roles.js";
51
51
  import {
52
52
  combineHybridSearch,
53
53
  hybridConceptLimit,
@@ -66,10 +66,12 @@ import {
66
66
  MAX_STRUCTURE_FILES,
67
67
  } from "./analysis-limits.js";
68
68
  import {
69
+ MAX_INSPECT_REQUEST_TARGETS,
69
70
  MAX_INSPECT_TARGETS,
70
71
  type SearchRequest,
71
72
  type SearchScopeDetails,
72
73
  type SiftLightResult,
74
+ type StructureDetails,
73
75
  } from "./types.js";
74
76
 
75
77
  export function isEvidenceRequest(input: SiftLightInput): boolean {
@@ -95,6 +97,100 @@ export function isEvidenceRequest(input: SiftLightInput): boolean {
95
97
  );
96
98
  }
97
99
 
100
+ /**
101
+ * Splits one inspect request into the page served now and an exact request for
102
+ * the rest. `paths` without a cursor opens each file from line 1.
103
+ */
104
+ function pageInspectRequest(input: SiftLightInput): {
105
+ current: SiftLightInput;
106
+ remaining?: SiftLightInput;
107
+ } {
108
+ if (input.scope === "expand")
109
+ throw new SiftLightError(
110
+ "mode=inspect reads exact locations; scope=expand does not apply (omit scope)",
111
+ );
112
+ const { scope: _scope, paths, ...rest } = input;
113
+ let request: SiftLightInput = rest;
114
+ if (paths !== undefined) {
115
+ if (
116
+ input.cursor !== undefined ||
117
+ input.targets !== undefined ||
118
+ input.matchIndices !== undefined ||
119
+ input.path !== undefined ||
120
+ input.line !== undefined ||
121
+ input.matchIndex !== undefined
122
+ )
123
+ throw new SiftLightError(
124
+ "mode=inspect paths opens files from line 1 and cannot be combined with cursor, targets, matchIndices, path, line or matchIndex",
125
+ );
126
+ request = { ...rest, targets: paths.map((path) => ({ path, line: 1 })) };
127
+ }
128
+ const redact = input.redact ? { redact: true } : {};
129
+ const requested = request.targets?.length ?? request.matchIndices?.length ?? 0;
130
+ if (requested > MAX_INSPECT_REQUEST_TARGETS)
131
+ throw new SiftLightError(
132
+ `mode=inspect accepts at most ${String(MAX_INSPECT_REQUEST_TARGETS)} targets per request; ${String(MAX_INSPECT_TARGETS)} are inspected per response`,
133
+ );
134
+ if (request.targets && request.targets.length > MAX_INSPECT_TARGETS)
135
+ return {
136
+ current: { ...request, targets: request.targets.slice(0, MAX_INSPECT_TARGETS) },
137
+ remaining: {
138
+ mode: "inspect",
139
+ targets: request.targets.slice(MAX_INSPECT_TARGETS),
140
+ ...redact,
141
+ },
142
+ };
143
+ if (request.matchIndices && request.matchIndices.length > MAX_INSPECT_TARGETS)
144
+ return {
145
+ current: { ...request, matchIndices: request.matchIndices.slice(0, MAX_INSPECT_TARGETS) },
146
+ remaining: {
147
+ mode: "inspect",
148
+ ...(request.cursor !== undefined ? { cursor: request.cursor } : {}),
149
+ matchIndices: request.matchIndices.slice(MAX_INSPECT_TARGETS),
150
+ ...redact,
151
+ },
152
+ };
153
+ return { current: request };
154
+ }
155
+
156
+ /** The unserved targets stay explicit: the page is partial and names its exact continuation. */
157
+ function withRemainingInspection(
158
+ result: SiftLightResult,
159
+ remaining: SiftLightInput,
160
+ ): SiftLightResult {
161
+ const count = remaining.targets?.length ?? remaining.matchIndices?.length ?? 0;
162
+ return {
163
+ text: `${result.text}\n\n[${String(count)} more target(s) were not inspected; one response inspects ${String(MAX_INSPECT_TARGETS)}.]\nNext request: ${JSON.stringify(remaining)}`,
164
+ details: {
165
+ ...result.details,
166
+ status: "partial",
167
+ snapshotComplete: false,
168
+ nextRequest: remaining,
169
+ },
170
+ };
171
+ }
172
+
173
+ /** Structure facts already proven by the analysis that produced a bounded item. */
174
+ function analysisItemStructure(item: AnalysisItem): StructureDetails {
175
+ const details = item.details ?? {};
176
+ const language = typeof details.language === "string" ? details.language : undefined;
177
+ const name = typeof details.name === "string" ? details.name : undefined;
178
+ const scope = Array.isArray(details.scope)
179
+ ? details.scope.filter((entry): entry is string => typeof entry === "string")
180
+ : typeof details.scope === "string"
181
+ ? [details.scope]
182
+ : [];
183
+ const kind = details.kind === "function" ? "function" : (item.label.split(" ", 1)[0] ?? "symbol");
184
+ return {
185
+ status: "available",
186
+ provider: language === "python" ? "python-outline" : "tree-sitter",
187
+ ...(language ? { language } : {}),
188
+ ...(name
189
+ ? { symbol: { name, kind, scope, range: { startLine: item.line, endLine: item.line } } }
190
+ : {}),
191
+ };
192
+ }
193
+
98
194
  export interface EvidenceSearchOptions {
99
195
  modelOutput?: boolean;
100
196
  onProgress?: (progress: OperationProgress) => void;
@@ -126,12 +222,12 @@ function validateTerms(input: SiftLightInput): string[] | undefined {
126
222
  }
127
223
  if (
128
224
  !Array.isArray(terms) ||
129
- terms.length < 2 ||
225
+ terms.length < 1 ||
130
226
  terms.length > 3 ||
131
227
  terms.some((term) => typeof term !== "string" || !term.trim() || /[\r\n\0]/.test(term)) ||
132
228
  new Set(terms).size !== terms.length
133
229
  )
134
- throw new SiftLightError("allOf requires 2–3 distinct, nonempty, single-line literal terms");
230
+ throw new SiftLightError("allOf requires 1–3 distinct, nonempty, single-line literal terms");
135
231
  if (
136
232
  input.pattern !== undefined ||
137
233
  input.roles !== undefined ||
@@ -396,13 +492,13 @@ export class EvidenceService {
396
492
  if (typeof input.sourceCursor !== "string" || !input.sourceCursor.trim())
397
493
  throw new CursorError("A nonempty sourceCursor is required");
398
494
  if (input.mode !== "inspect") throw new SiftLightError("sourceCursor requires mode=inspect");
399
- return continueSource(input.sourceCursor, access, this.#continuations);
495
+ return continueSource(input.sourceCursor, access, this.#continuations, input.path);
400
496
  }
401
497
  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);
498
+ const { current, remaining } = pageInspectRequest(input);
499
+ const targets = this.#inspectionTargets(current, cwd);
500
+ const result = await inspectDocuments(targets, access, this.#continuations, this.#structure);
501
+ return remaining ? withRemainingInspection(result, remaining) : result;
406
502
  }
407
503
  if (input.mode === "concept") {
408
504
  const execution = await this.#conceptSearch(input, access, options.onProgress);
@@ -518,7 +614,7 @@ export class EvidenceService {
518
614
  "anyOf is an explicit case-sensitive literal union; omit pattern, allOf, within, roles, literal and ignoreCase",
519
615
  );
520
616
  if (input.mode !== undefined && input.mode !== "auto" && input.mode !== "matches")
521
- throw new SiftLightError("anyOf mode must be omitted, auto, or matches");
617
+ throw new SiftLightError("anyOf mode must be omitted, auto, matches or summary");
522
618
  const chunks = Array.from(
523
619
  { length: Math.ceil(anyOf.length / MAX_ANY_OF_TERMS) },
524
620
  (_, index) => anyOf.slice(index * MAX_ANY_OF_TERMS, (index + 1) * MAX_ANY_OF_TERMS),
@@ -696,8 +792,12 @@ export class EvidenceService {
696
792
  );
697
793
  if (item) result.items.push(item);
698
794
  } else if (terms || input.roles) {
699
- if (syntaxLanguage(file.document.path)) syntaxCapableFiles += 1;
700
- const syntax = await access.syntax(file.document);
795
+ // Python roles come from a bounded lexical scanner; other languages parse.
796
+ const pythonRoles = !terms && /\.py$/iu.test(file.document.path);
797
+ if (pythonRoles || syntaxLanguage(file.document.path)) syntaxCapableFiles += 1;
798
+ const syntax = pythonRoles
799
+ ? pythonRoleAnalysis(file.document)
800
+ : await access.syntax(file.document);
701
801
  const classified = terms
702
802
  ? findFunctionConjunctions(
703
803
  file.document,
@@ -790,12 +890,16 @@ export class EvidenceService {
790
890
  const item = this.#analyses.item(input.cursor, input.matchIndex);
791
891
  if (!item.source || !item.range)
792
892
  throw new CursorError("This analysis item has no verified source range");
793
- const metadataOnly = item.details?.kind === "symbol" || item.details?.kind === "function";
893
+ // Outline symbols and function conjunctions carry their exact syntax range,
894
+ // so inspection returns that version-checked range as source (issue #96).
895
+ const bounded = item.details?.kind === "symbol" || item.details?.kind === "function";
794
896
  return {
795
897
  path: item.path,
796
898
  line: item.line,
797
899
  reference: item.source,
798
- ...(metadataOnly ? { range: item.range } : { absoluteFocus: item.range.start }),
900
+ ...(bounded
901
+ ? { range: item.range, structure: analysisItemStructure(item) }
902
+ : { absoluteFocus: item.range.start }),
799
903
  };
800
904
  }
801
905
  return {
@@ -13,6 +13,42 @@ interface FileScore {
13
13
  const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
14
14
  /** Bracket classes remain valid query text, so dynamic route names stay searchable. */
15
15
  const FILE_QUERY_GLOB_WILDCARD = /[*?]/u;
16
+ /** Queries such as `*`, `**` or `.*` mean "every file" rather than filename text. */
17
+ const FILE_QUERY_MATCH_ALL = /^(?:[*/]+|\.\*|\.\+|\*\.\*)$/u;
18
+ /** Regex syntax that cannot be a simple glob; such a query is rejected with guidance. */
19
+ const FILE_QUERY_REGEX_SYNTAX = /[()|^$\\+]/u;
20
+ const IGNORED_MATCH_SAMPLES = 5;
21
+
22
+ interface NormalizedFileQuery {
23
+ query: string;
24
+ glob?: string;
25
+ note?: string;
26
+ }
27
+
28
+ /**
29
+ * Filename discovery matches text. A wildcard-only query means "all files" and a
30
+ * plain glob such as `*.py` becomes an explicit glob filter; both are disclosed.
31
+ */
32
+ function normalizeFileQuery(query: string, hasGlob: boolean): NormalizedFileQuery {
33
+ const trimmed = query.trim();
34
+ if (FILE_QUERY_MATCH_ALL.test(trimmed))
35
+ return {
36
+ query: "",
37
+ note: `Query ${JSON.stringify(query)} was treated as listing every file under path; omit query for the same result.`,
38
+ };
39
+ if (!FILE_QUERY_GLOB_WILDCARD.test(trimmed)) return { query };
40
+ if (!hasGlob && !FILE_QUERY_REGEX_SYNTAX.test(trimmed) && !/\s/u.test(trimmed)) {
41
+ const glob = trimmed.includes("/") ? trimmed : `**/${trimmed}`;
42
+ return {
43
+ query: "",
44
+ glob,
45
+ note: `Query ${JSON.stringify(query)} contains glob wildcards and was applied as glob ${JSON.stringify(glob)}; mode=files query otherwise matches filename text.`,
46
+ };
47
+ }
48
+ throw new SiftLightError(
49
+ `File query ${JSON.stringify(query)} uses wildcard or regex syntax; mode=files matches filename and path text, not patterns. Omit query to retain every file under path, use glob for one name pattern, or run one files request per name.`,
50
+ );
51
+ }
16
52
 
17
53
  function subsequenceScore(text: string, query: string): number | undefined {
18
54
  const characters = Array.from(graphemes.segment(text), (item) => item.segment);
@@ -70,26 +106,33 @@ export async function discoverFiles(
70
106
  cwd: string,
71
107
  signal?: AbortSignal,
72
108
  ): Promise<AnalysisResultSet> {
73
- const query = input.query ?? "";
74
- if (query.length > 256 || !query.isWellFormed() || /[\r\n\0]/.test(query))
109
+ const rawQuery = input.query ?? "";
110
+ if (rawQuery.length > 256 || !rawQuery.isWellFormed() || /[\r\n\0]/.test(rawQuery))
75
111
  throw new SiftLightError(
76
112
  "File query must be well-formed single-line text of at most 256 characters",
77
113
  );
78
- // A wildcard query can never match filename text, so scoring it would discard
79
- // every enumerated file and report an empty result as complete.
80
- if (FILE_QUERY_GLOB_WILDCARD.test(query))
81
- throw new SiftLightError(
82
- `File query ${JSON.stringify(query)} uses glob wildcards; mode=files matches filename and path text, not glob patterns. Omit query to retain every file under path, or use glob to filter by name pattern.`,
83
- );
84
- const request = normalizeRequest({ ...input, pattern: "" });
114
+ const requestedGlobs =
115
+ input.glob === undefined ? [] : Array.isArray(input.glob) ? input.glob : [input.glob];
116
+ const normalizedQuery = normalizeFileQuery(rawQuery, requestedGlobs.length > 0);
117
+ const query = normalizedQuery.query;
118
+ const request = normalizeRequest({
119
+ ...input,
120
+ pattern: "",
121
+ ...(normalizedQuery.glob ? { glob: [...requestedGlobs, normalizedQuery.glob] } : {}),
122
+ });
85
123
  const policy = new SearchPathPolicy(cwd);
86
124
  const discoveryRoot = request.path ?? ".";
87
125
  const scoringRoot = await policy.resolveSearchTarget(discoveryRoot);
88
- const files = await listWorkspaceFiles(cwd, signal, {
126
+ const includeIgnored = request.ignorePolicy === "include";
127
+ const listOptions = {
89
128
  path: scoringRoot,
90
129
  glob: request.glob,
91
130
  exclude: request.exclude,
92
131
  hidden: request.hidden,
132
+ };
133
+ const files = await listWorkspaceFiles(cwd, signal, {
134
+ ...listOptions,
135
+ ...(includeIgnored ? { ignore: false, ignoreParents: false } : {}),
93
136
  });
94
137
  const filtered = await filterPathsByModificationTime(
95
138
  cwd,
@@ -98,10 +141,12 @@ export async function discoverFiles(
98
141
  request.modifiedBeforeMs,
99
142
  signal,
100
143
  );
144
+ const rank = (path: string) =>
145
+ scoreFilePath(pathRelativeToDiscoveryRoot(cwd, scoringRoot, path), query);
101
146
  const selected = filtered.paths
102
147
  .flatMap((path) => {
103
- const rank = scoreFilePath(pathRelativeToDiscoveryRoot(cwd, scoringRoot, path), query);
104
- return rank ? [{ path, ...rank }] : [];
148
+ const score = rank(path);
149
+ return score ? [{ path, ...score }] : [];
105
150
  })
106
151
  .toSorted((left, right) => right.score - left.score || left.path.localeCompare(right.path));
107
152
  for (let offset = 0; offset < selected.length; offset += 16) {
@@ -111,15 +156,54 @@ export async function discoverFiles(
111
156
  );
112
157
  signal?.throwIfAborted();
113
158
  }
159
+ const reasons = new Set([...files.reasons, ...filtered.reasons]);
160
+ if (normalizedQuery.note) reasons.add(normalizedQuery.note);
161
+ // Issue #97: ignore rules must never turn a filtered absence into a complete one.
162
+ let ignoredFiles = 0;
163
+ let ignoredMatches = 0;
164
+ let ignoredComparisonPartial = false;
165
+ if (!includeIgnored) {
166
+ const everything = await listWorkspaceFiles(cwd, signal, {
167
+ ...listOptions,
168
+ ignore: false,
169
+ ignoreParents: false,
170
+ });
171
+ ignoredComparisonPartial = everything.partial;
172
+ const admitted = new Set(files.paths);
173
+ const ignored = everything.paths.filter((path) => !admitted.has(path));
174
+ ignoredFiles = ignored.length;
175
+ const ignoredCandidates = ignored
176
+ .flatMap((path) => {
177
+ const score = rank(path);
178
+ return score ? [{ path, ...score }] : [];
179
+ })
180
+ .toSorted((left, right) => right.score - left.score || left.path.localeCompare(right.path));
181
+ ignoredMatches = ignoredCandidates.length;
182
+ if (ignoredFiles > 0) {
183
+ const samples = ignoredCandidates
184
+ .slice(0, IGNORED_MATCH_SAMPLES)
185
+ .map((item) => `${JSON.stringify(item.path)} (${item.reason})`);
186
+ reasons.add(
187
+ ignoredMatches > 0
188
+ ? `${String(ignoredFiles)} file(s) were excluded by ignore rules; ${String(ignoredMatches)} of them match this query${samples.length ? `, e.g. ${samples.join(", ")}` : ""}. Retry with ignorePolicy "include" to list them.`
189
+ : `${String(ignoredFiles)} file(s) were excluded by ignore rules; none of them match this query.`,
190
+ );
191
+ }
192
+ if (ignoredComparisonPartial)
193
+ reasons.add(
194
+ "Ignored-file comparison was incomplete; the ignored-file count is a lower bound",
195
+ );
196
+ }
197
+ const enumerationPartial = files.partial || filtered.partial;
114
198
  return {
115
199
  kind: "files",
116
200
  unit: "files",
117
- partial: files.partial || filtered.partial,
118
- reasons: [...new Set([...files.reasons, ...filtered.reasons])],
201
+ partial: enumerationPartial,
202
+ reasons: [...reasons],
119
203
  items: selected.map((item) => ({
120
204
  path: item.path,
121
205
  line: 1,
122
- label: `File candidate (${item.reason})`,
206
+ label: `File candidate (score ${String(item.score)}: ${item.reason})`,
123
207
  details: {
124
208
  kind: "file",
125
209
  score: item.score,
@@ -127,8 +211,17 @@ export async function discoverFiles(
127
211
  inspect: { mode: "inspect", path: item.path, line: 1 },
128
212
  },
129
213
  })),
130
- coverage: { fileEnumeration: files.partial || filtered.partial ? "partial" : "complete" },
131
- stats: { filesEnumerated: files.paths.length },
214
+ coverage: {
215
+ fileEnumeration: enumerationPartial
216
+ ? "partial"
217
+ : ignoredFiles > 0 || ignoredComparisonPartial
218
+ ? "policy-filtered"
219
+ : "complete",
220
+ },
221
+ stats: {
222
+ filesEnumerated: files.paths.length,
223
+ ...(includeIgnored ? {} : { ignoredFiles, ignoredMatches }),
224
+ },
132
225
  scope: {
133
226
  path: request.path ?? ".",
134
227
  requestedPath: request.path ?? ".",
@@ -146,5 +239,6 @@ export async function discoverFiles(
146
239
  : {}),
147
240
  },
148
241
  redact: input.redact ?? false,
242
+ ...(input.limit !== undefined ? { pageSize: request.pageSize } : {}),
149
243
  };
150
244
  }
package/src/inspect.ts CHANGED
@@ -78,6 +78,9 @@ export function resolveInspectionTarget(
78
78
  line = retainedMatch.lineNumber;
79
79
  }
80
80
  if (!path) throw new SiftLightError("path is required when mode=inspect");
81
+ // A direct path without a line opens the file from its first line. Cursor
82
+ // inspection still needs the exact retained line, so it keeps failing closed.
83
+ if (line === undefined && input.cursor === undefined) line = 1;
81
84
  if (line === undefined || !Number.isSafeInteger(line) || line < 1) {
82
85
  throw new SiftLightError("line must be a positive integer when mode=inspect");
83
86
  }
@@ -119,6 +119,15 @@ const PYTHON_LANGUAGE_CAPABILITIES: readonly LanguageCapabilitySpec[] = [
119
119
  availability: "implemented",
120
120
  load: "lazy",
121
121
  },
122
+ {
123
+ id: "python-lexical.roles",
124
+ name: "roles",
125
+ provider: "bounded Python lexical role scanner",
126
+ providerKind: "builtin",
127
+ evidence: "syntax",
128
+ availability: "implemented",
129
+ load: "lazy",
130
+ },
122
131
  ];
123
132
 
124
133
  export const DEFAULT_LANGUAGE_CAPABILITIES: readonly LanguageCapabilityDescriptor[] = [
@@ -87,7 +87,10 @@ function compactRows(analysis: AnalysisDetails): string[] {
87
87
  rows.push(JSON.stringify(item.path));
88
88
  previousPath = item.path;
89
89
  }
90
- const label = analysis.kind === "outline" && analysis.modelOutput ? item.label : "metadata";
90
+ const label =
91
+ (analysis.kind === "outline" && analysis.modelOutput) || analysis.kind === "files"
92
+ ? item.label
93
+ : "metadata";
91
94
  const semanticJudge = item.details?.semanticJudge;
92
95
  const judgment =
93
96
  isRecord(semanticJudge) &&
@@ -149,6 +152,7 @@ export function compactMcpModelText(result: SiftLightResult): string {
149
152
  const inspect = compactInspectInstruction(analysis);
150
153
  const nextRequest = distinctNextRequest(result.details, analysis);
151
154
  const compact = [
155
+ ...(result.details.requestNotes ?? []).map((note) => `[Request note: ${note}]`),
152
156
  header,
153
157
  ...compactMetadata(result.details, analysis),
154
158
  ...compactRows(analysis),
@@ -156,6 +160,7 @@ export function compactMcpModelText(result: SiftLightResult): string {
156
160
  ...(nextRequest ? [`Next request: ${nextRequest}`] : []),
157
161
  ].join("\n");
158
162
  const standard = result.text.replace(" Structured output retains per-item evidence details.", "");
163
+ // Both projections carry request notes, so the byte comparison stays fair.
159
164
  if (analysis.semanticJudge) return compact;
160
165
  return Buffer.byteLength(compact) < Buffer.byteLength(standard) ? compact : standard;
161
166
  }