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.
- package/CHANGELOG.md +20 -0
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/package.json +1 -1
- package/plugins/sift-light/.claude-plugin/plugin.json +1 -1
- package/plugins/sift-light/.codex-plugin/plugin.json +1 -1
- package/plugins/sift-light/.mcp.json +1 -1
- package/plugins/sift-light/kimi.plugin.json +2 -2
- package/plugins/sift-light/omp-extension.mjs +493 -58
- package/plugins/sift-light/package.json +1 -1
- package/src/analysis-limits.ts +1 -1
- package/src/analysis-store.ts +6 -2
- package/src/analysis-types.ts +10 -1
- package/src/evidence-service.ts +117 -13
- package/src/file-discovery.ts +111 -17
- package/src/inspect.ts +3 -0
- package/src/language-capability-definitions.ts +9 -0
- package/src/mcp-model-output.ts +6 -1
- package/src/mcp-server.mjs +495 -59
- package/src/python-roles.ts +247 -0
- package/src/redaction.ts +9 -2
- package/src/request-aliases.ts +48 -0
- package/src/request-contract-catalog.ts +24 -7
- package/src/request-contract.ts +21 -3
- package/src/request.ts +3 -1
- package/src/runtime.ts +2 -2
- package/src/service.ts +23 -1
- package/src/session-summary.ts +4 -4
- package/src/source-inspection.ts +27 -2
- package/src/tool-schema.ts +15 -15
- package/src/tui/layout.ts +2 -2
- package/src/tui/renderers.ts +2 -2
- package/src/types.ts +4 -0
package/src/analysis-limits.ts
CHANGED
|
@@ -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 =
|
|
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;
|
package/src/analysis-store.ts
CHANGED
|
@@ -206,7 +206,10 @@ function publicAnalysisItem(
|
|
|
206
206
|
return {
|
|
207
207
|
path: item.path,
|
|
208
208
|
line: item.line,
|
|
209
|
-
label:
|
|
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
|
-
|
|
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
|
}
|
package/src/analysis-types.ts
CHANGED
|
@@ -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 =
|
|
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
|
}
|
package/src/evidence-service.ts
CHANGED
|
@@ -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 <
|
|
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
|
|
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
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
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
|
-
|
|
700
|
-
const
|
|
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
|
-
|
|
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
|
-
...(
|
|
900
|
+
...(bounded
|
|
901
|
+
? { range: item.range, structure: analysisItemStructure(item) }
|
|
902
|
+
: { absoluteFocus: item.range.start }),
|
|
799
903
|
};
|
|
800
904
|
}
|
|
801
905
|
return {
|
package/src/file-discovery.ts
CHANGED
|
@@ -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
|
|
74
|
-
if (
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
|
104
|
-
return
|
|
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:
|
|
118
|
-
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: {
|
|
131
|
-
|
|
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[] = [
|
package/src/mcp-model-output.ts
CHANGED
|
@@ -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 =
|
|
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
|
}
|