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,168 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
ANALYSIS_TTL_MS,
|
|
4
|
+
MAX_SOURCE_CONTINUATIONS,
|
|
5
|
+
MAX_SOURCE_CONTINUATION_BYTES,
|
|
6
|
+
} from "./analysis-limits.js";
|
|
7
|
+
import { CursorError } from "./errors.js";
|
|
8
|
+
import type { ByteRange, SourceReference } from "./source-document.js";
|
|
9
|
+
import { mergeByteRanges, subtractByteRange } from "./source-pages.js";
|
|
10
|
+
import type { SourceBoundary } from "./types.js";
|
|
11
|
+
|
|
12
|
+
interface Continuation {
|
|
13
|
+
id: string;
|
|
14
|
+
source: SourceReference;
|
|
15
|
+
target: ByteRange[];
|
|
16
|
+
gaps: ByteRange[];
|
|
17
|
+
boundary?: SourceBoundary;
|
|
18
|
+
accessed: number;
|
|
19
|
+
/** Only cursors issued by this chain are valid; offsets are not a public seek API. */
|
|
20
|
+
issued: Set<number>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ResolvedSourceContinuation {
|
|
24
|
+
source: SourceReference;
|
|
25
|
+
target: ByteRange[];
|
|
26
|
+
remaining: ByteRange[];
|
|
27
|
+
boundary?: SourceBoundary;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Cursor offsets encode progress, not mutable per-page state; retries can replay. */
|
|
31
|
+
export class SourceContinuations {
|
|
32
|
+
readonly #items = new Map<string, Continuation>();
|
|
33
|
+
readonly #now: () => number;
|
|
34
|
+
|
|
35
|
+
constructor(now: () => number = Date.now) {
|
|
36
|
+
this.#now = now;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
create(
|
|
40
|
+
source: SourceReference,
|
|
41
|
+
target: readonly ByteRange[],
|
|
42
|
+
gaps: readonly ByteRange[],
|
|
43
|
+
boundary?: SourceBoundary,
|
|
44
|
+
): string {
|
|
45
|
+
this.#sweep();
|
|
46
|
+
const item: Continuation = {
|
|
47
|
+
id: randomUUID(),
|
|
48
|
+
source: structuredClone(source),
|
|
49
|
+
target: mergeByteRanges(target),
|
|
50
|
+
gaps: mergeByteRanges(gaps),
|
|
51
|
+
...(boundary ? { boundary } : {}),
|
|
52
|
+
accessed: this.#now(),
|
|
53
|
+
issued: new Set([0]),
|
|
54
|
+
};
|
|
55
|
+
if (
|
|
56
|
+
item.gaps.length === 0 ||
|
|
57
|
+
item.gaps.some(
|
|
58
|
+
(gap) =>
|
|
59
|
+
gap.start === gap.end ||
|
|
60
|
+
!item.target.some((range) => range.start <= gap.start && gap.end <= range.end),
|
|
61
|
+
)
|
|
62
|
+
) {
|
|
63
|
+
throw new CursorError("Source continuation requires missing ranges inside its target");
|
|
64
|
+
}
|
|
65
|
+
this.#items.set(item.id, item);
|
|
66
|
+
while (
|
|
67
|
+
this.#items.size > MAX_SOURCE_CONTINUATIONS ||
|
|
68
|
+
Buffer.byteLength(
|
|
69
|
+
JSON.stringify(
|
|
70
|
+
[...this.#items.values()].map((continuation) =>
|
|
71
|
+
Object.assign(
|
|
72
|
+
{
|
|
73
|
+
id: continuation.id,
|
|
74
|
+
source: continuation.source,
|
|
75
|
+
target: continuation.target,
|
|
76
|
+
gaps: continuation.gaps,
|
|
77
|
+
accessed: continuation.accessed,
|
|
78
|
+
issued: [...continuation.issued],
|
|
79
|
+
},
|
|
80
|
+
continuation.boundary ? { boundary: continuation.boundary } : {},
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
) > MAX_SOURCE_CONTINUATION_BYTES
|
|
85
|
+
) {
|
|
86
|
+
let oldest: Continuation | undefined;
|
|
87
|
+
for (const candidate of this.#items.values()) {
|
|
88
|
+
if (!oldest || candidate.accessed < oldest.accessed) oldest = candidate;
|
|
89
|
+
}
|
|
90
|
+
if (!oldest) break;
|
|
91
|
+
this.#items.delete(oldest.id);
|
|
92
|
+
}
|
|
93
|
+
if (!this.#items.has(item.id))
|
|
94
|
+
throw new CursorError("Source continuation metadata exceeds its limit");
|
|
95
|
+
return `${item.id}.source.0`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
resolve(cursor: string): ResolvedSourceContinuation {
|
|
99
|
+
const { item, consumed } = this.#resolve(cursor);
|
|
100
|
+
return {
|
|
101
|
+
source: structuredClone(item.source),
|
|
102
|
+
target: item.target.map((range) => ({ ...range })),
|
|
103
|
+
remaining: this.#remaining(item, consumed),
|
|
104
|
+
...(item.boundary ? { boundary: item.boundary } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
advance(cursor: string, returned: ByteRange): string | undefined {
|
|
109
|
+
const { item, consumed } = this.#resolve(cursor);
|
|
110
|
+
const remaining = this.#remaining(item, consumed);
|
|
111
|
+
const first = remaining[0];
|
|
112
|
+
if (
|
|
113
|
+
!first ||
|
|
114
|
+
returned.start !== first.start ||
|
|
115
|
+
returned.end <= returned.start ||
|
|
116
|
+
returned.end > first.end
|
|
117
|
+
) {
|
|
118
|
+
throw new CursorError("Source continuation must advance along its next missing range");
|
|
119
|
+
}
|
|
120
|
+
const next = consumed + returned.end - returned.start;
|
|
121
|
+
item.issued.add(next);
|
|
122
|
+
return this.#remaining(item, next).length > 0
|
|
123
|
+
? `${item.id}.source.${next.toString(36)}`
|
|
124
|
+
: undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
clear(): void {
|
|
128
|
+
this.#items.clear();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#resolve(cursor: string): { item: Continuation; consumed: number } {
|
|
132
|
+
this.#sweep();
|
|
133
|
+
const match = /^([0-9a-f-]+)\.source\.([0-9a-z]+)$/.exec(cursor);
|
|
134
|
+
if (!match?.[1] || !match[2]) throw new CursorError("Invalid source continuation cursor");
|
|
135
|
+
const item = this.#items.get(match[1]);
|
|
136
|
+
if (!item) throw new CursorError("Source continuation expired or was evicted; inspect again");
|
|
137
|
+
const consumed = Number.parseInt(match[2], 36);
|
|
138
|
+
const length = item.gaps.reduce((sum, range) => sum + range.end - range.start, 0);
|
|
139
|
+
if (
|
|
140
|
+
!Number.isSafeInteger(consumed) ||
|
|
141
|
+
consumed < 0 ||
|
|
142
|
+
consumed >= length ||
|
|
143
|
+
!item.issued.has(consumed)
|
|
144
|
+
) {
|
|
145
|
+
throw new CursorError("Source continuation offset is outside its missing ranges");
|
|
146
|
+
}
|
|
147
|
+
item.accessed = this.#now();
|
|
148
|
+
return { item, consumed };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#remaining(item: Continuation, consumed: number): ByteRange[] {
|
|
152
|
+
let left = consumed;
|
|
153
|
+
let ranges = item.gaps.map((range) => ({ ...range }));
|
|
154
|
+
for (const range of item.gaps) {
|
|
155
|
+
if (left === 0) break;
|
|
156
|
+
const take = Math.min(left, range.end - range.start);
|
|
157
|
+
ranges = subtractByteRange(ranges, { start: range.start, end: range.start + take });
|
|
158
|
+
left -= take;
|
|
159
|
+
}
|
|
160
|
+
return ranges;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#sweep(): void {
|
|
164
|
+
const cutoff = this.#now() - ANALYSIS_TTL_MS;
|
|
165
|
+
for (const item of this.#items.values())
|
|
166
|
+
if (item.accessed < cutoff) this.#items.delete(item.id);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { isUtf8 } from "node:buffer";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { open, realpath } from "node:fs/promises";
|
|
4
|
+
import { relative, resolve } from "node:path";
|
|
5
|
+
import { abortError, SiftLightError } from "./errors.js";
|
|
6
|
+
import { isPathInsideCwd, SearchPathPolicy } from "./path-policy.js";
|
|
7
|
+
import { getSourceRevision, sameSourceRevision, sourceRevisionFromStats } from "./source.js";
|
|
8
|
+
import { MAX_SOURCE_FILE_BYTES, type SourceRevision } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export type SourceOrigin =
|
|
11
|
+
| { kind: "worktree"; revision: SourceRevision; contentHash: string }
|
|
12
|
+
| { kind: "git"; commit: string; blob: string };
|
|
13
|
+
|
|
14
|
+
export interface SourceReference {
|
|
15
|
+
path: string;
|
|
16
|
+
origin: SourceOrigin;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ByteRange {
|
|
20
|
+
start: number;
|
|
21
|
+
end: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SourcePosition {
|
|
25
|
+
line: number;
|
|
26
|
+
/** One-based UTF-16 column, with the byte range remaining authoritative. */
|
|
27
|
+
column: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class SourceDocumentError extends SiftLightError {
|
|
31
|
+
readonly reason: "source-changed" | "source-unavailable" | "file-too-large" | "encoding";
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
reason: "source-changed" | "source-unavailable" | "file-too-large" | "encoding",
|
|
35
|
+
message: string,
|
|
36
|
+
) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.reason = reason;
|
|
39
|
+
this.name = "SourceDocumentError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function contentHash(bytes: Uint8Array): string {
|
|
44
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One verified read. Byte offsets refer to the original, unnormalized source. */
|
|
48
|
+
export class SourceDocument {
|
|
49
|
+
readonly reference: SourceReference;
|
|
50
|
+
readonly bytes: Buffer;
|
|
51
|
+
readonly text: string;
|
|
52
|
+
readonly utf8: boolean;
|
|
53
|
+
readonly lineStarts: number[] = [0];
|
|
54
|
+
#byteOffsets?: Uint32Array;
|
|
55
|
+
|
|
56
|
+
constructor(reference: SourceReference, bytes: Buffer) {
|
|
57
|
+
if (
|
|
58
|
+
typeof reference !== "object" ||
|
|
59
|
+
reference === null ||
|
|
60
|
+
typeof reference.path !== "string" ||
|
|
61
|
+
reference.path.length === 0 ||
|
|
62
|
+
typeof reference.origin !== "object" ||
|
|
63
|
+
reference.origin === null ||
|
|
64
|
+
!("kind" in reference.origin) ||
|
|
65
|
+
(reference.origin.kind !== "git" && reference.origin.kind !== "worktree")
|
|
66
|
+
) {
|
|
67
|
+
throw new SourceDocumentError("source-unavailable", "Source reference is invalid");
|
|
68
|
+
}
|
|
69
|
+
if (!Buffer.isBuffer(bytes)) {
|
|
70
|
+
throw new SourceDocumentError("source-unavailable", "Source bytes are invalid");
|
|
71
|
+
}
|
|
72
|
+
this.reference = reference;
|
|
73
|
+
this.bytes = bytes;
|
|
74
|
+
if (bytes.length > MAX_SOURCE_FILE_BYTES) {
|
|
75
|
+
throw new SourceDocumentError("file-too-large", "Source exceeds the 5 MiB file limit");
|
|
76
|
+
}
|
|
77
|
+
this.utf8 = isUtf8(bytes);
|
|
78
|
+
this.text = bytes.toString("utf8");
|
|
79
|
+
for (let index = bytes.indexOf(10); index >= 0; index = bytes.indexOf(10, index + 1)) {
|
|
80
|
+
this.lineStarts.push(index + 1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get path(): string {
|
|
85
|
+
return this.reference.path;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
toByteOffset(character: number): number {
|
|
89
|
+
this.#requireUtf8();
|
|
90
|
+
if (!Number.isSafeInteger(character) || character < 0 || character > this.text.length) {
|
|
91
|
+
throw new SiftLightError("Source character offset is outside the document");
|
|
92
|
+
}
|
|
93
|
+
const code = this.text.charCodeAt(character);
|
|
94
|
+
if (code >= 0xdc00 && code <= 0xdfff) {
|
|
95
|
+
throw new SiftLightError("Source character offset splits a Unicode character");
|
|
96
|
+
}
|
|
97
|
+
const value = this.#offsets()[character];
|
|
98
|
+
if (value === undefined) throw new Error("Missing source offset");
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
toCharacterOffset(byte: number): number {
|
|
103
|
+
this.#requireUtf8();
|
|
104
|
+
this.checkRange({ start: byte, end: byte });
|
|
105
|
+
const offsets = this.#offsets();
|
|
106
|
+
let low = 0;
|
|
107
|
+
let high = offsets.length - 1;
|
|
108
|
+
while (low < high) {
|
|
109
|
+
const middle = Math.floor((low + high) / 2);
|
|
110
|
+
const value = offsets[middle];
|
|
111
|
+
if (value === undefined) throw new Error("Missing source offset");
|
|
112
|
+
if (value < byte) low = middle + 1;
|
|
113
|
+
else high = middle;
|
|
114
|
+
}
|
|
115
|
+
if (offsets[low] !== byte) {
|
|
116
|
+
throw new SiftLightError("Source byte offset splits a Unicode character");
|
|
117
|
+
}
|
|
118
|
+
return low;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
lineAt(byte: number): number {
|
|
122
|
+
this.checkRange({ start: byte, end: byte });
|
|
123
|
+
let low = 0;
|
|
124
|
+
let high = this.lineStarts.length;
|
|
125
|
+
while (low + 1 < high) {
|
|
126
|
+
const middle = Math.floor((low + high) / 2);
|
|
127
|
+
const start = this.lineStarts[middle];
|
|
128
|
+
if (start === undefined) throw new Error("Missing source line");
|
|
129
|
+
if (start <= byte) low = middle;
|
|
130
|
+
else high = middle;
|
|
131
|
+
}
|
|
132
|
+
return low + 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
positionAt(byte: number): SourcePosition {
|
|
136
|
+
const line = this.lineAt(byte);
|
|
137
|
+
const start = this.lineStarts[line - 1];
|
|
138
|
+
if (start === undefined) throw new Error("Missing source line");
|
|
139
|
+
return {
|
|
140
|
+
line,
|
|
141
|
+
column: this.toCharacterOffset(byte) - this.toCharacterOffset(start) + 1,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
lineRange(startLine: number, endLine = startLine): ByteRange {
|
|
146
|
+
if (
|
|
147
|
+
!Number.isSafeInteger(startLine) ||
|
|
148
|
+
!Number.isSafeInteger(endLine) ||
|
|
149
|
+
startLine < 1 ||
|
|
150
|
+
endLine < startLine ||
|
|
151
|
+
startLine > this.lineStarts.length
|
|
152
|
+
) {
|
|
153
|
+
throw new SiftLightError("Source line range is outside the document");
|
|
154
|
+
}
|
|
155
|
+
const start = this.lineStarts[startLine - 1];
|
|
156
|
+
if (start === undefined) throw new Error("Missing source line");
|
|
157
|
+
return { start, end: this.lineStarts[endLine] ?? this.bytes.length };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
slice(range: ByteRange): string {
|
|
161
|
+
this.#requireUtf8();
|
|
162
|
+
this.checkRange(range);
|
|
163
|
+
this.toCharacterOffset(range.start);
|
|
164
|
+
this.toCharacterOffset(range.end);
|
|
165
|
+
return this.bytes.subarray(range.start, range.end).toString("utf8");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
checkRange(range: ByteRange): void {
|
|
169
|
+
if (
|
|
170
|
+
!Number.isSafeInteger(range.start) ||
|
|
171
|
+
!Number.isSafeInteger(range.end) ||
|
|
172
|
+
range.start < 0 ||
|
|
173
|
+
range.end < range.start ||
|
|
174
|
+
range.end > this.bytes.length
|
|
175
|
+
) {
|
|
176
|
+
throw new SiftLightError("Source byte range is outside the document");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#requireUtf8(): void {
|
|
181
|
+
if (!this.utf8) {
|
|
182
|
+
throw new SourceDocumentError("encoding", "Source is not losslessly representable as UTF-8");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#offsets(): Uint32Array {
|
|
187
|
+
if (this.#byteOffsets) return this.#byteOffsets;
|
|
188
|
+
const offsets = new Uint32Array(this.text.length + 1);
|
|
189
|
+
let character = 0;
|
|
190
|
+
let byte = 0;
|
|
191
|
+
for (const point of this.text) {
|
|
192
|
+
offsets[character] = byte;
|
|
193
|
+
if (point.length === 2) offsets[character + 1] = byte;
|
|
194
|
+
character += point.length;
|
|
195
|
+
byte += Buffer.byteLength(point);
|
|
196
|
+
}
|
|
197
|
+
offsets[character] = byte;
|
|
198
|
+
this.#byteOffsets = offsets;
|
|
199
|
+
return offsets;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function readWorkspaceDocument(
|
|
204
|
+
path: string,
|
|
205
|
+
cwd: string,
|
|
206
|
+
signal?: AbortSignal,
|
|
207
|
+
expected?: SourceOrigin,
|
|
208
|
+
readBudget = MAX_SOURCE_FILE_BYTES,
|
|
209
|
+
): Promise<SourceDocument> {
|
|
210
|
+
if (signal?.aborted) throw abortError();
|
|
211
|
+
if (expected?.kind === "git") {
|
|
212
|
+
throw new SiftLightError("A Git source reference cannot be read from the worktree");
|
|
213
|
+
}
|
|
214
|
+
const absolute = resolve(cwd, path);
|
|
215
|
+
const [canonical, canonicalCwd] = await Promise.all([
|
|
216
|
+
new SearchPathPolicy(cwd).resolveExistingPath(absolute),
|
|
217
|
+
realpath(cwd),
|
|
218
|
+
]);
|
|
219
|
+
if (!canonical) throw new SourceDocumentError("source-unavailable", "Source is unavailable");
|
|
220
|
+
const before = await getSourceRevision(absolute);
|
|
221
|
+
if (!before) throw new SourceDocumentError("source-unavailable", "Source is unavailable");
|
|
222
|
+
if (expected && !sameSourceRevision(before, expected.revision)) {
|
|
223
|
+
throw new SourceDocumentError("source-changed", "Source changed; start a new inspection");
|
|
224
|
+
}
|
|
225
|
+
if (before.size > Math.min(MAX_SOURCE_FILE_BYTES, readBudget)) {
|
|
226
|
+
throw new SourceDocumentError("file-too-large", "Source exceeds the 5 MiB file limit");
|
|
227
|
+
}
|
|
228
|
+
if (signal?.aborted) throw abortError();
|
|
229
|
+
const handle = await open(canonical, "r");
|
|
230
|
+
let bytes: Buffer;
|
|
231
|
+
try {
|
|
232
|
+
const metadata = await handle.stat();
|
|
233
|
+
if (!metadata.isFile()) {
|
|
234
|
+
throw new SourceDocumentError("source-unavailable", "Source must be a regular file");
|
|
235
|
+
}
|
|
236
|
+
if (!sameSourceRevision(before, sourceRevisionFromStats(metadata))) {
|
|
237
|
+
throw new SourceDocumentError("source-changed", "Source was replaced before reading");
|
|
238
|
+
}
|
|
239
|
+
const buffer = Buffer.alloc(before.size);
|
|
240
|
+
let used = 0;
|
|
241
|
+
while (used < buffer.length) {
|
|
242
|
+
if (signal?.aborted) throw abortError();
|
|
243
|
+
// oxlint-disable-next-line no-await-in-loop -- each positional read advances the same file buffer.
|
|
244
|
+
const read = await handle.read(buffer, used, buffer.length - used, used);
|
|
245
|
+
if (read.bytesRead === 0) break;
|
|
246
|
+
used += read.bytesRead;
|
|
247
|
+
}
|
|
248
|
+
if (used !== before.size) {
|
|
249
|
+
throw new SourceDocumentError("source-changed", "Source changed during reading");
|
|
250
|
+
}
|
|
251
|
+
bytes = buffer.subarray(0, used);
|
|
252
|
+
} finally {
|
|
253
|
+
await handle.close();
|
|
254
|
+
}
|
|
255
|
+
const [after, finalPath] = await Promise.all([getSourceRevision(absolute), realpath(absolute)]);
|
|
256
|
+
if (!after || canonical !== finalPath || !sameSourceRevision(before, after)) {
|
|
257
|
+
throw new SourceDocumentError("source-changed", "Source changed during reading");
|
|
258
|
+
}
|
|
259
|
+
if (signal?.aborted) throw abortError();
|
|
260
|
+
const hash = contentHash(bytes);
|
|
261
|
+
if (expected && expected.contentHash !== hash) {
|
|
262
|
+
throw new SourceDocumentError(
|
|
263
|
+
"source-changed",
|
|
264
|
+
"Source content changed; start a new inspection",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return new SourceDocument(
|
|
268
|
+
{
|
|
269
|
+
// Source references are protocol paths, so keep them POSIX across host platforms.
|
|
270
|
+
path: isPathInsideCwd(canonical, canonicalCwd)
|
|
271
|
+
? relative(canonicalCwd, canonical).replaceAll("\\", "/")
|
|
272
|
+
: canonical.replaceAll("\\", "/"),
|
|
273
|
+
origin: { kind: "worktree", revision: after, contentHash: hash },
|
|
274
|
+
},
|
|
275
|
+
bytes,
|
|
276
|
+
);
|
|
277
|
+
}
|