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,100 @@
|
|
|
1
|
+
import type { SiftLightLocale } from "./config.js";
|
|
2
|
+
import { SIFT_LIGHT_VERSION } from "./package-version.js";
|
|
3
|
+
import type { SiftLightInput } from "./service.js";
|
|
4
|
+
import type { SiftLightResult } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export const SESSION_STATUS_KEY = "sift-light_session";
|
|
7
|
+
|
|
8
|
+
export interface SessionSummarySnapshot {
|
|
9
|
+
queries: number;
|
|
10
|
+
completeQueries: number;
|
|
11
|
+
partialQueries: number;
|
|
12
|
+
organizedQueries: number;
|
|
13
|
+
failedCalls: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isNewQuery(input: SiftLightInput): boolean {
|
|
17
|
+
return (
|
|
18
|
+
input.cursor === undefined &&
|
|
19
|
+
input.sourceCursor === undefined &&
|
|
20
|
+
input.operationId === undefined
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function wasAutomaticallyOrganized(input: SiftLightInput, result: SiftLightResult): boolean {
|
|
25
|
+
const autoMode = input.mode === undefined || input.mode === "auto";
|
|
26
|
+
return autoMode && input.limit === undefined && result.details.summaryFilesShown !== undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatChineseCompleteness(snapshot: SessionSummarySnapshot): string {
|
|
30
|
+
const { completeQueries, partialQueries, queries } = snapshot;
|
|
31
|
+
if (queries === 0) return "暂无成功查询";
|
|
32
|
+
const unfinishedQueries = queries - completeQueries - partialQueries;
|
|
33
|
+
if (completeQueries > 0 && partialQueries === 0 && unfinishedQueries === 0) return "结果全部完整";
|
|
34
|
+
const parts: string[] = [];
|
|
35
|
+
if (completeQueries > 0) parts.push(`${String(completeQueries)} 次结果完整`);
|
|
36
|
+
if (partialQueries > 0) parts.push(`${String(partialQueries)} 次仅获得部分结果并已明确标注`);
|
|
37
|
+
if (unfinishedQueries > 0) parts.push(`${String(unfinishedQueries)} 次结果尚未完成`);
|
|
38
|
+
return parts.join(";");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatEnglishCompleteness(snapshot: SessionSummarySnapshot): string {
|
|
42
|
+
const { completeQueries, partialQueries, queries } = snapshot;
|
|
43
|
+
if (queries === 0) return "no successful queries";
|
|
44
|
+
const unfinishedQueries = queries - completeQueries - partialQueries;
|
|
45
|
+
if (completeQueries > 0 && partialQueries === 0 && unfinishedQueries === 0)
|
|
46
|
+
return "all results complete";
|
|
47
|
+
const parts: string[] = [];
|
|
48
|
+
if (completeQueries > 0) parts.push(`${String(completeQueries)} complete`);
|
|
49
|
+
if (partialQueries > 0) parts.push(`${String(partialQueries)} partial and clearly marked`);
|
|
50
|
+
if (unfinishedQueries > 0) parts.push(`${String(unfinishedQueries)} not yet complete`);
|
|
51
|
+
return parts.join("; ");
|
|
52
|
+
}
|
|
53
|
+
export class SessionSummary {
|
|
54
|
+
#snapshot: SessionSummarySnapshot = {
|
|
55
|
+
queries: 0,
|
|
56
|
+
completeQueries: 0,
|
|
57
|
+
partialQueries: 0,
|
|
58
|
+
organizedQueries: 0,
|
|
59
|
+
failedCalls: 0,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
record(input: SiftLightInput, result: SiftLightResult): void {
|
|
63
|
+
if (!isNewQuery(input)) return;
|
|
64
|
+
this.#snapshot.queries += 1;
|
|
65
|
+
if (result.details.status === "complete") this.#snapshot.completeQueries += 1;
|
|
66
|
+
if (result.details.status === "partial") this.#snapshot.partialQueries += 1;
|
|
67
|
+
if (wasAutomaticallyOrganized(input, result)) this.#snapshot.organizedQueries += 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
recordFailure(): void {
|
|
71
|
+
this.#snapshot.failedCalls += 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get snapshot(): SessionSummarySnapshot {
|
|
75
|
+
return { ...this.#snapshot };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
format(locale: SiftLightLocale): string | undefined {
|
|
79
|
+
const { failedCalls, organizedQueries, queries } = this.#snapshot;
|
|
80
|
+
if (queries === 0 && failedCalls === 0) return undefined;
|
|
81
|
+
if (locale === "zh-CN") {
|
|
82
|
+
const organized =
|
|
83
|
+
organizedQueries > 0 ? `;${String(organizedQueries)} 次结果已自动按文件整理` : "";
|
|
84
|
+
const failures = failedCalls > 0 ? `;${String(failedCalls)} 次调用失败` : "";
|
|
85
|
+
return `sift-light ${SIFT_LIGHT_VERSION}:已处理 ${String(queries)} 次查询,${formatChineseCompleteness(this.#snapshot)}${organized}${failures}`;
|
|
86
|
+
}
|
|
87
|
+
const queryWord = queries === 1 ? "query" : "queries";
|
|
88
|
+
let organized = "";
|
|
89
|
+
if (organizedQueries > 0) {
|
|
90
|
+
const resultWord = organizedQueries === 1 ? "result" : "results";
|
|
91
|
+
organized = `; ${organizedQueries} ${resultWord} automatically organized by file`;
|
|
92
|
+
}
|
|
93
|
+
let failures = "";
|
|
94
|
+
if (failedCalls > 0) {
|
|
95
|
+
const callWord = failedCalls === 1 ? "call" : "calls";
|
|
96
|
+
failures = `; ${failedCalls} failed ${callWord}`;
|
|
97
|
+
}
|
|
98
|
+
return `sift-light ${SIFT_LIGHT_VERSION}: handled ${String(queries)} ${queryWord}; ${formatEnglishCompleteness(this.#snapshot)}${organized}${failures}`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { CursorError, SiftLightError } from "./errors.js";
|
|
3
|
+
import { MAX_SEARCH_STORAGE_BYTES, MAX_STORED_OCCURRENCES } from "./types.js";
|
|
4
|
+
import type { SearchScan, SearchSnapshot } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export type CursorKind = "matches" | "summary";
|
|
7
|
+
|
|
8
|
+
export interface SnapshotStoreOptions {
|
|
9
|
+
ttlMs?: number;
|
|
10
|
+
maxSnapshots?: number;
|
|
11
|
+
maxTotalStoredMatches?: number;
|
|
12
|
+
maxTotalStoredBytes?: number;
|
|
13
|
+
maxTotalStoredOccurrences?: number;
|
|
14
|
+
now?: () => number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ResolvedCursor {
|
|
18
|
+
snapshot: SearchSnapshot;
|
|
19
|
+
offset: number;
|
|
20
|
+
kind: CursorKind;
|
|
21
|
+
selectionKey: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class SnapshotStore {
|
|
25
|
+
readonly #snapshots = new Map<string, SearchSnapshot>();
|
|
26
|
+
readonly #expired = new Set<string>();
|
|
27
|
+
readonly #ttlMs: number;
|
|
28
|
+
readonly #maxSnapshots: number;
|
|
29
|
+
readonly #maxTotalStoredMatches: number;
|
|
30
|
+
readonly #maxTotalStoredBytes: number;
|
|
31
|
+
readonly #maxTotalStoredOccurrences: number;
|
|
32
|
+
readonly #now: () => number;
|
|
33
|
+
|
|
34
|
+
constructor(options: SnapshotStoreOptions = {}) {
|
|
35
|
+
this.#ttlMs = options.ttlMs ?? 10 * 60 * 1000;
|
|
36
|
+
this.#maxSnapshots = options.maxSnapshots ?? 20;
|
|
37
|
+
this.#maxTotalStoredMatches = options.maxTotalStoredMatches ?? 100_000;
|
|
38
|
+
this.#maxTotalStoredBytes = options.maxTotalStoredBytes ?? MAX_SEARCH_STORAGE_BYTES;
|
|
39
|
+
this.#maxTotalStoredOccurrences = options.maxTotalStoredOccurrences ?? MAX_STORED_OCCURRENCES;
|
|
40
|
+
this.#now = options.now ?? Date.now;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
create(scan: SearchScan): SearchSnapshot {
|
|
44
|
+
this.sweep();
|
|
45
|
+
if (
|
|
46
|
+
this.#scanBytes(scan) > this.#maxTotalStoredBytes ||
|
|
47
|
+
this.#scanOccurrences(scan) > this.#maxTotalStoredOccurrences
|
|
48
|
+
)
|
|
49
|
+
throw new SiftLightError("Search snapshot exceeds the session storage budget");
|
|
50
|
+
const now = this.#now();
|
|
51
|
+
const snapshot: SearchSnapshot = {
|
|
52
|
+
...scan,
|
|
53
|
+
id: randomUUID(),
|
|
54
|
+
createdAt: now,
|
|
55
|
+
lastAccessedAt: now,
|
|
56
|
+
};
|
|
57
|
+
this.#snapshots.set(snapshot.id, snapshot);
|
|
58
|
+
this.#evictToBounds();
|
|
59
|
+
return snapshot;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
cursor(
|
|
63
|
+
snapshot: SearchSnapshot,
|
|
64
|
+
offset: number,
|
|
65
|
+
kind: CursorKind = "matches",
|
|
66
|
+
selectionKey = "all",
|
|
67
|
+
): string {
|
|
68
|
+
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
69
|
+
throw new CursorError(
|
|
70
|
+
"Cannot create a cursor with an invalid offset",
|
|
71
|
+
"E_CURSOR_OFFSET_INVALID",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (!/^(?:all|[0-9a-f]{16})$/.test(selectionKey)) {
|
|
75
|
+
throw new CursorError("Cannot create a cursor with an invalid selection key");
|
|
76
|
+
}
|
|
77
|
+
return `${snapshot.id}.${kind}.${offset.toString(36)}.${selectionKey}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
resolve(cursor: string): ResolvedCursor {
|
|
81
|
+
this.sweep();
|
|
82
|
+
const parts = cursor.match(/^(.+)\.(matches|summary)\.([0-9a-z]+)\.(all|[0-9a-f]{16})$/);
|
|
83
|
+
if (!parts) {
|
|
84
|
+
throw new CursorError(
|
|
85
|
+
"Invalid cursor. Start a new search to obtain a fresh cursor.",
|
|
86
|
+
"E_CURSOR_MALFORMED",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const [, id, rawKind, rawOffset, selectionKey] = parts;
|
|
90
|
+
if (!id || !rawKind || !rawOffset || !selectionKey) {
|
|
91
|
+
throw new CursorError(
|
|
92
|
+
"Invalid cursor. Start a new search to obtain a fresh cursor.",
|
|
93
|
+
"E_CURSOR_MALFORMED",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const kind: CursorKind = rawKind === "summary" ? "summary" : "matches";
|
|
97
|
+
|
|
98
|
+
const offset = Number.parseInt(rawOffset, 36);
|
|
99
|
+
const snapshot = this.#snapshots.get(id);
|
|
100
|
+
if (!snapshot)
|
|
101
|
+
throw new CursorError(
|
|
102
|
+
this.#expired.has(id)
|
|
103
|
+
? "Cursor expired or was evicted. Run the search again."
|
|
104
|
+
: "Cursor was not found. Run the search again.",
|
|
105
|
+
this.#expired.has(id) ? "E_CURSOR_EXPIRED" : "E_CURSOR_NOT_FOUND",
|
|
106
|
+
);
|
|
107
|
+
const maximumOffset = kind === "summary" ? snapshot.fileCounts.size : snapshot.matches.length;
|
|
108
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > maximumOffset) {
|
|
109
|
+
throw new CursorError(
|
|
110
|
+
"Cursor offset is outside the retained search snapshot.",
|
|
111
|
+
"E_CURSOR_OFFSET_INVALID",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
snapshot.lastAccessedAt = this.#now();
|
|
116
|
+
return { snapshot, offset, kind, selectionKey };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
delete(snapshot: SearchSnapshot): boolean {
|
|
120
|
+
return this.#snapshots.delete(snapshot.id);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
clear(): void {
|
|
124
|
+
for (const id of this.#snapshots.keys()) this.#rememberExpired(id);
|
|
125
|
+
this.#snapshots.clear();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
sweep(): void {
|
|
129
|
+
const cutoff = this.#now() - this.#ttlMs;
|
|
130
|
+
for (const [id, snapshot] of this.#snapshots) {
|
|
131
|
+
if (snapshot.lastAccessedAt < cutoff) {
|
|
132
|
+
this.#snapshots.delete(id);
|
|
133
|
+
this.#rememberExpired(id);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get size(): number {
|
|
139
|
+
this.sweep();
|
|
140
|
+
return this.#snapshots.size;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
get storedMatches(): number {
|
|
144
|
+
this.sweep();
|
|
145
|
+
return this.#totalStoredMatches();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#evictToBounds(): void {
|
|
149
|
+
while (
|
|
150
|
+
this.#snapshots.size > this.#maxSnapshots ||
|
|
151
|
+
this.#totalStoredMatches() > this.#maxTotalStoredMatches ||
|
|
152
|
+
[...this.#snapshots.values()].reduce((total, scan) => total + this.#scanBytes(scan), 0) >
|
|
153
|
+
this.#maxTotalStoredBytes ||
|
|
154
|
+
[...this.#snapshots.values()].reduce(
|
|
155
|
+
(total, scan) => total + this.#scanOccurrences(scan),
|
|
156
|
+
0,
|
|
157
|
+
) > this.#maxTotalStoredOccurrences
|
|
158
|
+
) {
|
|
159
|
+
let oldest: SearchSnapshot | undefined;
|
|
160
|
+
for (const snapshot of this.#snapshots.values()) {
|
|
161
|
+
if (!oldest || snapshot.lastAccessedAt < oldest.lastAccessedAt) oldest = snapshot;
|
|
162
|
+
}
|
|
163
|
+
if (!oldest) break;
|
|
164
|
+
this.#snapshots.delete(oldest.id);
|
|
165
|
+
this.#rememberExpired(oldest.id);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#totalStoredMatches(): number {
|
|
170
|
+
let total = 0;
|
|
171
|
+
for (const snapshot of this.#snapshots.values()) total += snapshot.matches.length;
|
|
172
|
+
return total;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#scanBytes(scan: SearchScan): number {
|
|
176
|
+
return (
|
|
177
|
+
scan.retention?.accountedBytes ??
|
|
178
|
+
Buffer.byteLength(
|
|
179
|
+
JSON.stringify({
|
|
180
|
+
matches: scan.matches,
|
|
181
|
+
fileCounts: [...scan.fileCounts],
|
|
182
|
+
sourceRevisions: [...scan.sourceRevisions],
|
|
183
|
+
}),
|
|
184
|
+
)
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
#scanOccurrences(scan: SearchScan): number {
|
|
189
|
+
return (
|
|
190
|
+
scan.retention?.retainedOccurrences ??
|
|
191
|
+
scan.matches.reduce((total, match) => total + match.occurrences.length, 0)
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
#rememberExpired(id: string): void {
|
|
196
|
+
this.#expired.add(id);
|
|
197
|
+
while (this.#expired.size > this.#maxSnapshots * 4) {
|
|
198
|
+
const oldest = this.#expired.values().next().value;
|
|
199
|
+
if (oldest === undefined) break;
|
|
200
|
+
this.#expired.delete(oldest);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { extname, resolve } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
MAX_STRUCTURE_BYTES,
|
|
4
|
+
MAX_STRUCTURE_FILES,
|
|
5
|
+
MAX_SYNTAX_CACHE_ENTRIES,
|
|
6
|
+
MAX_SYNTAX_CACHE_NODES,
|
|
7
|
+
} from "./analysis-limits.js";
|
|
8
|
+
import { abortError, SiftLightError } from "./errors.js";
|
|
9
|
+
import { readGitSource } from "./git-source.js";
|
|
10
|
+
import {
|
|
11
|
+
readWorkspaceDocument,
|
|
12
|
+
SourceDocument,
|
|
13
|
+
SourceDocumentError,
|
|
14
|
+
type SourceReference,
|
|
15
|
+
} from "./source-document.js";
|
|
16
|
+
import { parseSyntax, type SyntaxAnalysis } from "./syntax.js";
|
|
17
|
+
import { getSourceRevision } from "./source.js";
|
|
18
|
+
|
|
19
|
+
function noop(): void {}
|
|
20
|
+
|
|
21
|
+
interface CachedSyntax {
|
|
22
|
+
analysis: SyntaxAnalysis;
|
|
23
|
+
nodes: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SyntaxParseResult {
|
|
27
|
+
analysis: SyntaxAnalysis;
|
|
28
|
+
cacheHit: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface SourceBudgetLedger {
|
|
32
|
+
readBytes: number;
|
|
33
|
+
verificationBytes: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One parser owner across calls, with a bounded content-addressed syntax cache. */
|
|
37
|
+
export class SyntaxQueue {
|
|
38
|
+
#tail: Promise<void> = Promise.resolve();
|
|
39
|
+
#generation = new AbortController();
|
|
40
|
+
readonly #cache = new Map<string, CachedSyntax>();
|
|
41
|
+
#cachedNodes = 0;
|
|
42
|
+
|
|
43
|
+
async parse(document: SourceDocument, signal?: AbortSignal): Promise<SyntaxAnalysis> {
|
|
44
|
+
return (await this.parseWithMetrics(document, signal)).analysis;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async parseWithMetrics(
|
|
48
|
+
document: SourceDocument,
|
|
49
|
+
signal?: AbortSignal,
|
|
50
|
+
pattern?: string,
|
|
51
|
+
): Promise<SyntaxParseResult> {
|
|
52
|
+
const combined = signal
|
|
53
|
+
? AbortSignal.any([signal, this.#generation.signal])
|
|
54
|
+
: this.#generation.signal;
|
|
55
|
+
if (combined.aborted) throw abortError();
|
|
56
|
+
const predecessor = this.#tail;
|
|
57
|
+
let release: () => void = noop;
|
|
58
|
+
this.#tail = new Promise<void>((done) => {
|
|
59
|
+
release = done;
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
await predecessor;
|
|
63
|
+
if (combined.aborted) throw abortError();
|
|
64
|
+
if (!document.utf8)
|
|
65
|
+
throw new SourceDocumentError("encoding", "Syntax requires lossless UTF-8 source");
|
|
66
|
+
const origin = document.reference.origin;
|
|
67
|
+
const revision = origin.kind === "worktree" ? origin.contentHash : origin.blob;
|
|
68
|
+
const key = `${extname(document.path).toLowerCase()}\0${revision}\0${pattern ?? ""}`;
|
|
69
|
+
const cached = this.#cache.get(key);
|
|
70
|
+
if (cached) {
|
|
71
|
+
this.#cache.delete(key);
|
|
72
|
+
this.#cache.set(key, cached);
|
|
73
|
+
return { analysis: cached.analysis, cacheHit: true };
|
|
74
|
+
}
|
|
75
|
+
const analysis = await parseSyntax(document.path, document.text, combined, pattern);
|
|
76
|
+
const entry = { analysis, nodes: analysis.nodes.length };
|
|
77
|
+
this.#cache.set(key, entry);
|
|
78
|
+
this.#cachedNodes += entry.nodes;
|
|
79
|
+
while (
|
|
80
|
+
this.#cache.size > MAX_SYNTAX_CACHE_ENTRIES ||
|
|
81
|
+
this.#cachedNodes > MAX_SYNTAX_CACHE_NODES
|
|
82
|
+
) {
|
|
83
|
+
const oldest = this.#cache.entries().next().value;
|
|
84
|
+
if (!oldest) break;
|
|
85
|
+
this.#cache.delete(oldest[0]);
|
|
86
|
+
this.#cachedNodes -= oldest[1].nodes;
|
|
87
|
+
}
|
|
88
|
+
return { analysis, cacheHit: false };
|
|
89
|
+
} finally {
|
|
90
|
+
release();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
clear(): void {
|
|
95
|
+
this.#generation.abort();
|
|
96
|
+
this.#generation = new AbortController();
|
|
97
|
+
this.#cache.clear();
|
|
98
|
+
this.#cachedNodes = 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async shutdown(): Promise<void> {
|
|
102
|
+
this.clear();
|
|
103
|
+
await this.#tail;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class SourceBudgetError extends SiftLightError {
|
|
108
|
+
readonly reason = "structural-read-budget-exhausted";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Created for a single tool request, then discarded, including its parse results. */
|
|
112
|
+
export class SourceAccess {
|
|
113
|
+
readonly cwd: string;
|
|
114
|
+
readonly signal: AbortSignal | undefined;
|
|
115
|
+
readonly #queue: SyntaxQueue;
|
|
116
|
+
readonly #maxFiles: number;
|
|
117
|
+
readonly #documents = new Map<string, Promise<SourceDocument>>();
|
|
118
|
+
readonly #syntax = new Map<SourceDocument, Promise<SyntaxAnalysis>>();
|
|
119
|
+
readonly #maxVerificationBytes: number;
|
|
120
|
+
readonly #budget: SourceBudgetLedger;
|
|
121
|
+
#bytes = 0;
|
|
122
|
+
#syntaxParses = 0;
|
|
123
|
+
#syntaxCacheHits = 0;
|
|
124
|
+
#readTail: Promise<void> = Promise.resolve();
|
|
125
|
+
|
|
126
|
+
constructor(
|
|
127
|
+
cwd: string,
|
|
128
|
+
queue: SyntaxQueue,
|
|
129
|
+
signal?: AbortSignal,
|
|
130
|
+
options: {
|
|
131
|
+
maxFiles?: number;
|
|
132
|
+
maxVerificationBytes?: number;
|
|
133
|
+
budget?: SourceBudgetLedger;
|
|
134
|
+
} = {},
|
|
135
|
+
) {
|
|
136
|
+
this.cwd = cwd;
|
|
137
|
+
this.#queue = queue;
|
|
138
|
+
this.signal = signal;
|
|
139
|
+
this.#maxFiles = options.maxFiles ?? MAX_STRUCTURE_FILES;
|
|
140
|
+
this.#maxVerificationBytes = options.maxVerificationBytes ?? MAX_STRUCTURE_BYTES;
|
|
141
|
+
this.#budget = options.budget ?? { readBytes: 0, verificationBytes: 0 };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
get filesRead(): number {
|
|
145
|
+
return this.#documents.size;
|
|
146
|
+
}
|
|
147
|
+
get bytesRead(): number {
|
|
148
|
+
return this.#bytes;
|
|
149
|
+
}
|
|
150
|
+
get maxFiles(): number {
|
|
151
|
+
return this.#maxFiles;
|
|
152
|
+
}
|
|
153
|
+
get syntaxParses(): number {
|
|
154
|
+
return this.#syntaxParses;
|
|
155
|
+
}
|
|
156
|
+
get syntaxCacheHits(): number {
|
|
157
|
+
return this.#syntaxCacheHits;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Derive a fresh request-local access scope that shares only the parser owner and limits. */
|
|
161
|
+
withSignal(signal: AbortSignal): SourceAccess {
|
|
162
|
+
return new SourceAccess(this.cwd, this.#queue, signal, {
|
|
163
|
+
maxFiles: this.#maxFiles,
|
|
164
|
+
maxVerificationBytes: this.#maxVerificationBytes,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Create one request-owned batch with a fresh document cache and the shared byte ledger. */
|
|
169
|
+
batch(maxFiles: number): SourceAccess {
|
|
170
|
+
return new SourceAccess(this.cwd, this.#queue, this.signal, {
|
|
171
|
+
maxFiles,
|
|
172
|
+
maxVerificationBytes: this.#maxVerificationBytes,
|
|
173
|
+
budget: this.#budget,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async load(path: string, expected?: SourceReference): Promise<SourceDocument> {
|
|
178
|
+
if (this.signal?.aborted) throw abortError();
|
|
179
|
+
if (expected && resolve(this.cwd, expected.path) !== resolve(this.cwd, path)) {
|
|
180
|
+
throw new SiftLightError("Source reference path does not match the requested file");
|
|
181
|
+
}
|
|
182
|
+
const key = JSON.stringify([resolve(this.cwd, path), expected?.origin]);
|
|
183
|
+
const existing = this.#documents.get(key);
|
|
184
|
+
if (existing) return existing;
|
|
185
|
+
if (this.#documents.size >= this.#maxFiles) {
|
|
186
|
+
throw new SourceBudgetError(
|
|
187
|
+
`Structural scan reached the ${String(this.#maxFiles)}-file limit`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const pending = this.#read(path, expected, false);
|
|
191
|
+
this.#documents.set(key, pending);
|
|
192
|
+
return pending;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async #read(
|
|
196
|
+
path: string,
|
|
197
|
+
expected: SourceReference | undefined,
|
|
198
|
+
verification: boolean,
|
|
199
|
+
): Promise<SourceDocument> {
|
|
200
|
+
const predecessor = this.#readTail;
|
|
201
|
+
let release: () => void = noop;
|
|
202
|
+
this.#readTail = new Promise<void>((done) => {
|
|
203
|
+
release = done;
|
|
204
|
+
});
|
|
205
|
+
try {
|
|
206
|
+
await predecessor;
|
|
207
|
+
return await this.#readOnce(path, expected, verification);
|
|
208
|
+
} finally {
|
|
209
|
+
release();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async #readOnce(
|
|
214
|
+
path: string,
|
|
215
|
+
expected: SourceReference | undefined,
|
|
216
|
+
verification: boolean,
|
|
217
|
+
): Promise<SourceDocument> {
|
|
218
|
+
let document: SourceDocument;
|
|
219
|
+
const consumed = verification ? this.#budget.verificationBytes : this.#budget.readBytes;
|
|
220
|
+
const budget = verification ? this.#maxVerificationBytes : MAX_STRUCTURE_BYTES;
|
|
221
|
+
const remaining = budget - consumed;
|
|
222
|
+
if (remaining <= 0)
|
|
223
|
+
throw new SourceBudgetError("Structural scan reached the 32 MiB read limit");
|
|
224
|
+
if (expected?.origin.kind !== "git") {
|
|
225
|
+
const metadata = await getSourceRevision(resolve(this.cwd, path));
|
|
226
|
+
if (metadata && metadata.size > remaining)
|
|
227
|
+
throw new SourceBudgetError(
|
|
228
|
+
"Next source exceeds the remaining 32 MiB structural read budget",
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (expected?.origin.kind === "git") {
|
|
232
|
+
const origin = expected.origin;
|
|
233
|
+
const raw = await readGitSource(
|
|
234
|
+
this.cwd,
|
|
235
|
+
{ path, commit: origin.commit, blob: origin.blob },
|
|
236
|
+
this.signal,
|
|
237
|
+
{ maxBytes: remaining },
|
|
238
|
+
);
|
|
239
|
+
if (!raw.content || !raw.origin) {
|
|
240
|
+
throw new SourceDocumentError(
|
|
241
|
+
"source-unavailable",
|
|
242
|
+
raw.reason ?? `Git source is ${raw.sourceStatus}`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
document = new SourceDocument({ path, origin: raw.origin }, raw.content);
|
|
246
|
+
} else {
|
|
247
|
+
document = await readWorkspaceDocument(
|
|
248
|
+
path,
|
|
249
|
+
this.cwd,
|
|
250
|
+
this.signal,
|
|
251
|
+
expected?.origin,
|
|
252
|
+
remaining,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
this.#bytes += document.bytes.length;
|
|
256
|
+
if (verification) this.#budget.verificationBytes += document.bytes.length;
|
|
257
|
+
else this.#budget.readBytes += document.bytes.length;
|
|
258
|
+
if (!verification && this.#budget.readBytes > MAX_STRUCTURE_BYTES)
|
|
259
|
+
throw new SourceBudgetError("Structural scan reached the 32 MiB read limit");
|
|
260
|
+
return document;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
syntax(document: SourceDocument): Promise<SyntaxAnalysis> {
|
|
264
|
+
let pending = this.#syntax.get(document);
|
|
265
|
+
if (!pending) {
|
|
266
|
+
pending = this.#queue.parseWithMetrics(document, this.signal).then((parsed) => {
|
|
267
|
+
if (parsed.cacheHit) this.#syntaxCacheHits += 1;
|
|
268
|
+
else this.#syntaxParses += 1;
|
|
269
|
+
return parsed.analysis;
|
|
270
|
+
});
|
|
271
|
+
this.#syntax.set(document, pending);
|
|
272
|
+
}
|
|
273
|
+
return pending;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async pattern(document: SourceDocument, pattern: string): Promise<SyntaxAnalysis> {
|
|
277
|
+
return (await this.#queue.parseWithMetrics(document, this.signal, pattern)).analysis;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
releaseSyntax(document: SourceDocument): void {
|
|
281
|
+
this.#syntax.delete(document);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Saved source validation must reread the expected version, not consult the request cache. */
|
|
285
|
+
refresh(path: string, expected: SourceReference): Promise<SourceDocument> {
|
|
286
|
+
return this.#read(path, expected, true);
|
|
287
|
+
}
|
|
288
|
+
}
|