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.
Files changed (145) hide show
  1. package/CHANGELOG.md +0 -0
  2. package/CONTRIBUTING.md +19 -0
  3. package/LICENSE +661 -0
  4. package/README.md +177 -0
  5. package/README.zh-CN.md +177 -0
  6. package/SECURITY.md +25 -0
  7. package/package.json +142 -0
  8. package/plugins/sift-light/.claude-plugin/plugin.json +10 -0
  9. package/plugins/sift-light/.codex-plugin/plugin.json +20 -0
  10. package/plugins/sift-light/.mcp.json +24 -0
  11. package/plugins/sift-light/LICENSE +661 -0
  12. package/plugins/sift-light/concept-worker.mjs +567 -0
  13. package/plugins/sift-light/hooks/THIRD_PARTY_NOTICES.txt +70 -0
  14. package/plugins/sift-light/hooks/hooks.json +16 -0
  15. package/plugins/sift-light/hooks/search-policy.mjs +4113 -0
  16. package/plugins/sift-light/hooks/tree-sitter-bash.wasm +0 -0
  17. package/plugins/sift-light/hooks/tree-sitter-powershell.wasm +0 -0
  18. package/plugins/sift-light/hooks/tree-sitter.wasm +0 -0
  19. package/plugins/sift-light/kimi.plugin.json +36 -0
  20. package/plugins/sift-light/mcp-server.mjs +1 -0
  21. package/plugins/sift-light/omp-extension.mjs +20914 -0
  22. package/plugins/sift-light/package.json +11 -0
  23. package/plugins/sift-light/syntax-worker.mjs +280 -0
  24. package/plugins/sift-light/syntax-worker.toml +2 -0
  25. package/src/analysis-evidence.ts +43 -0
  26. package/src/analysis-limits.ts +27 -0
  27. package/src/analysis-store.ts +559 -0
  28. package/src/analysis-term-pages.ts +73 -0
  29. package/src/analysis-types.ts +162 -0
  30. package/src/audit-search.ts +391 -0
  31. package/src/capped-lines.ts +112 -0
  32. package/src/concept-embedding-cache.ts +186 -0
  33. package/src/concept-inference.ts +218 -0
  34. package/src/concept-model.ts +96 -0
  35. package/src/concept-search.ts +577 -0
  36. package/src/concept-setup.ts +75 -0
  37. package/src/concept-source-generation.ts +268 -0
  38. package/src/concept-worker.mjs +567 -0
  39. package/src/concept-worker.ts +210 -0
  40. package/src/config-reader.ts +283 -0
  41. package/src/config.ts +37 -0
  42. package/src/context-budget.ts +26 -0
  43. package/src/discovery-errors.ts +2 -0
  44. package/src/errors.ts +95 -0
  45. package/src/evidence-candidates.ts +344 -0
  46. package/src/evidence-ranking.ts +48 -0
  47. package/src/evidence-service.ts +1047 -0
  48. package/src/evidence-validation.ts +466 -0
  49. package/src/evidence-validity.ts +33 -0
  50. package/src/excerpt.ts +53 -0
  51. package/src/file-discovery.ts +150 -0
  52. package/src/file-metadata-filter.ts +46 -0
  53. package/src/format.ts +407 -0
  54. package/src/git-diff.ts +195 -0
  55. package/src/git-process.ts +189 -0
  56. package/src/git-repository.ts +367 -0
  57. package/src/git-source.ts +385 -0
  58. package/src/historical-paths.ts +205 -0
  59. package/src/hybrid-search.ts +340 -0
  60. package/src/import-model.ts +611 -0
  61. package/src/import-navigation.ts +100 -0
  62. package/src/import-resolution.ts +240 -0
  63. package/src/index.ts +97 -0
  64. package/src/inspect-batch.ts +265 -0
  65. package/src/inspect.ts +271 -0
  66. package/src/language-capabilities.ts +157 -0
  67. package/src/language-capability-definitions.ts +204 -0
  68. package/src/literal-search.ts +25 -0
  69. package/src/mcp-cli.ts +21 -0
  70. package/src/mcp-model-output.ts +120 -0
  71. package/src/mcp-output.ts +9 -0
  72. package/src/mcp-semantic-judge.ts +39 -0
  73. package/src/mcp-server.mjs +14064 -0
  74. package/src/mcp-server.ts +157 -0
  75. package/src/mcp-stdio.ts +114 -0
  76. package/src/mcp.ts +631 -0
  77. package/src/model-error.ts +120 -0
  78. package/src/multi-term-search.ts +122 -0
  79. package/src/omp-index.ts +277 -0
  80. package/src/omp-marketplace-entry.ts +7 -0
  81. package/src/operation-lifecycle.ts +413 -0
  82. package/src/operation-output.ts +100 -0
  83. package/src/owned-parallel.ts +22 -0
  84. package/src/owned-process.ts +102 -0
  85. package/src/owned-task-queue.ts +38 -0
  86. package/src/package-version.ts +3 -0
  87. package/src/path-policy.ts +203 -0
  88. package/src/pi-search-policy.ts +33 -0
  89. package/src/prompt-guidelines.ts +54 -0
  90. package/src/python-outline.ts +231 -0
  91. package/src/record-value.ts +3 -0
  92. package/src/redaction.ts +95 -0
  93. package/src/request-contract-catalog.ts +215 -0
  94. package/src/request-contract-error.ts +96 -0
  95. package/src/request-contract.ts +481 -0
  96. package/src/request.ts +132 -0
  97. package/src/result-statistics.ts +178 -0
  98. package/src/rg.ts +574 -0
  99. package/src/ripgrep-diagnostics.ts +122 -0
  100. package/src/ripgrep-executable.ts +43 -0
  101. package/src/runtime.ts +53 -0
  102. package/src/scan-revisions.ts +148 -0
  103. package/src/script-runtime.ts +11 -0
  104. package/src/search-policy-commands.ts +170 -0
  105. package/src/search-policy-hook.ts +61 -0
  106. package/src/search-policy-recovery.ts +388 -0
  107. package/src/search-policy-shell.ts +280 -0
  108. package/src/search-policy.ts +102 -0
  109. package/src/search-retention.ts +77 -0
  110. package/src/semantic-judge-batches.ts +135 -0
  111. package/src/semantic-judge.ts +519 -0
  112. package/src/service.ts +945 -0
  113. package/src/session-summary.ts +100 -0
  114. package/src/snapshot-store.ts +203 -0
  115. package/src/source-access.ts +288 -0
  116. package/src/source-continuations.ts +168 -0
  117. package/src/source-document.ts +277 -0
  118. package/src/source-inspection.ts +731 -0
  119. package/src/source-pages.ts +112 -0
  120. package/src/source.ts +286 -0
  121. package/src/structural-search.ts +125 -0
  122. package/src/structure.ts +259 -0
  123. package/src/summary-previews.ts +96 -0
  124. package/src/summary.ts +79 -0
  125. package/src/syntax-facts.ts +464 -0
  126. package/src/syntax-fields.ts +161 -0
  127. package/src/syntax-search.ts +361 -0
  128. package/src/syntax-tree.ts +30 -0
  129. package/src/syntax-types.ts +72 -0
  130. package/src/syntax-worker.mjs +280 -0
  131. package/src/syntax-worker.toml +2 -0
  132. package/src/syntax-worker.ts +143 -0
  133. package/src/syntax.ts +259 -0
  134. package/src/test-navigation-facts.ts +261 -0
  135. package/src/test-navigation.ts +543 -0
  136. package/src/tool-schema.ts +321 -0
  137. package/src/tui/dashboard.ts +107 -0
  138. package/src/tui/layout.ts +291 -0
  139. package/src/tui/palette.ts +73 -0
  140. package/src/tui/presentation.ts +240 -0
  141. package/src/tui/renderers.ts +106 -0
  142. package/src/types.ts +368 -0
  143. package/src/validation-output.ts +148 -0
  144. package/src/validation-types.ts +48 -0
  145. package/src/workspace-files.ts +142 -0
@@ -0,0 +1,189 @@
1
+ import { SiftLightError } from "./errors.js";
2
+ import { runOwnedProcess } from "./owned-process.js";
3
+ import { MAX_PROTOCOL_LINE_BYTES } from "./types.js";
4
+
5
+ const GIT_READ_ARGUMENTS = [
6
+ "--no-pager",
7
+ "--no-replace-objects",
8
+ "--no-optional-locks",
9
+ "-c",
10
+ "core.fsmonitor=false",
11
+ "-c",
12
+ "core.untrackedCache=false",
13
+ "-c",
14
+ "submodule.recurse=false",
15
+ ] as const;
16
+
17
+ const MINIMUM_NO_LAZY_FETCH_VERSION = [2, 45, 0] as const;
18
+ const gitCapabilities = new Map<string, boolean>();
19
+
20
+ type GitReadCommand = "rev-parse" | "ls-tree" | "cat-file" | "ls-files" | "check-ignore";
21
+
22
+ interface GitReadOptions {
23
+ signal?: AbortSignal;
24
+ input?: Uint8Array;
25
+ maxBytes?: number;
26
+ allowedCodes?: readonly number[];
27
+ executable?: string;
28
+ }
29
+
30
+ /** Retain ordinary ignore configuration, but never inherit Git repository or execution overrides. */
31
+ export function gitReadEnvironment(): NodeJS.ProcessEnv {
32
+ return {
33
+ ...Object.fromEntries(
34
+ Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith("GIT_")),
35
+ ),
36
+ GIT_CONFIG_COUNT: "0",
37
+ GIT_NO_LAZY_FETCH: "1",
38
+ GIT_NO_REPLACE_OBJECTS: "1",
39
+ GIT_OPTIONAL_LOCKS: "0",
40
+ GIT_TERMINAL_PROMPT: "0",
41
+ GIT_PROTOCOL_FROM_USER: "0",
42
+ LC_ALL: "C",
43
+ };
44
+ }
45
+
46
+ export function supportsNoLazyFetch(version: string): boolean {
47
+ const match = /^git version (\d+)\.(\d+)(?:\.(\d+))?/.exec(version.trim());
48
+ if (!match) throw new SiftLightError("Git returned an unrecognized version string");
49
+ const actual = [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)];
50
+ for (let index = 0; index < MINIMUM_NO_LAZY_FETCH_VERSION.length; index += 1) {
51
+ const difference = (actual[index] ?? 0) - (MINIMUM_NO_LAZY_FETCH_VERSION[index] ?? 0);
52
+ if (difference !== 0) return difference > 0;
53
+ }
54
+ return true;
55
+ }
56
+
57
+ async function gitReadArguments(
58
+ executable: string,
59
+ cwd: string,
60
+ signal?: AbortSignal,
61
+ ): Promise<string[]> {
62
+ const capabilityKey = `${executable}\0${process.env.PATH ?? ""}`;
63
+ let supports = gitCapabilities.get(capabilityKey);
64
+ if (supports === undefined) {
65
+ const versionChunks: Buffer[] = [];
66
+ const version = await runOwnedProcess(
67
+ {
68
+ executable,
69
+ args: ["--version"],
70
+ cwd,
71
+ env: gitReadEnvironment(),
72
+ ...(signal ? { signal } : {}),
73
+ },
74
+ async (stdout) => {
75
+ for await (const chunk of stdout) versionChunks.push(Buffer.from(chunk));
76
+ },
77
+ );
78
+ if (version.code !== 0) throw new SiftLightError("Unable to determine the Git version");
79
+ supports = supportsNoLazyFetch(Buffer.concat(versionChunks).toString("utf8"));
80
+ gitCapabilities.set(capabilityKey, supports);
81
+ }
82
+ if (supports) {
83
+ return [...GIT_READ_ARGUMENTS, "--no-lazy-fetch"];
84
+ }
85
+
86
+ const partial = await runOwnedProcess(
87
+ {
88
+ executable,
89
+ args: [
90
+ ...GIT_READ_ARGUMENTS,
91
+ "config",
92
+ "--local",
93
+ "--get-regexp",
94
+ "^(extensions\\.partialClone|remote\\..*\\.promisor)$",
95
+ ],
96
+ cwd,
97
+ env: gitReadEnvironment(),
98
+ ...(signal ? { signal } : {}),
99
+ },
100
+ async (stdout) => {
101
+ for await (const chunk of stdout) {
102
+ // Presence is represented by the exit code; content is intentionally discarded.
103
+ void chunk;
104
+ }
105
+ },
106
+ );
107
+ if (partial.code === 0) {
108
+ throw new SiftLightError(
109
+ "Git 2.45 or newer is required for non-fetching reads from a partial/promisor clone",
110
+ );
111
+ }
112
+ if (partial.code !== 1) {
113
+ throw new SiftLightError("Unable to verify whether this older Git repository is partial");
114
+ }
115
+ return [...GIT_READ_ARGUMENTS];
116
+ }
117
+
118
+ /** Only raw-object and names-only builtins are allowed at this boundary. */
119
+ export async function runGitRead(
120
+ cwd: string,
121
+ command: GitReadCommand,
122
+ args: string[],
123
+ options: GitReadOptions = {},
124
+ ): Promise<{ output: Buffer; code: number }> {
125
+ const chunks: Buffer[] = [];
126
+ if (options.input && options.input.byteLength > MAX_PROTOCOL_LINE_BYTES) {
127
+ throw new SiftLightError(
128
+ `Git input exceeds the ${String(MAX_PROTOCOL_LINE_BYTES)} byte protocol limit`,
129
+ );
130
+ }
131
+ let bytes = 0;
132
+ const maxBytes = options.maxBytes ?? MAX_PROTOCOL_LINE_BYTES;
133
+ const result = await runOwnedProcess(
134
+ {
135
+ executable: options.executable ?? "git",
136
+ args: [
137
+ ...(await gitReadArguments(options.executable ?? "git", cwd, options.signal)),
138
+ ...(command === "ls-tree" ? ["--literal-pathspecs"] : []),
139
+ command,
140
+ ...args,
141
+ ],
142
+ cwd,
143
+ env: gitReadEnvironment(),
144
+ ...(options.signal ? { signal: options.signal } : {}),
145
+ ...(options.input ? { input: options.input } : {}),
146
+ },
147
+ async (stdout) => {
148
+ for await (const chunk of stdout) {
149
+ bytes += chunk.byteLength;
150
+ if (bytes > maxBytes) {
151
+ throw new SiftLightError(
152
+ `Git ${command} output exceeds the ${String(maxBytes)} byte limit`,
153
+ );
154
+ }
155
+ chunks.push(Buffer.from(chunk));
156
+ }
157
+ },
158
+ );
159
+ if (result.code === null || !(options.allowedCodes ?? [0]).includes(result.code)) {
160
+ throw new SiftLightError(
161
+ `Git ${command} failed: ${result.stderr.trim() || `exit ${String(result.code)}`}`,
162
+ );
163
+ }
164
+ return { output: Buffer.concat(chunks), code: result.code };
165
+ }
166
+
167
+ export function decodeGitPath(bytes: Buffer): string {
168
+ const value = bytes.toString("utf8");
169
+ if (!Buffer.from(value, "utf8").equals(bytes)) {
170
+ throw new SiftLightError(
171
+ "Git path is not valid UTF-8; path-based source access is unavailable",
172
+ );
173
+ }
174
+ return value;
175
+ }
176
+
177
+ export function splitGitRecords(output: Buffer): Buffer[] {
178
+ if (output.length === 0) return [];
179
+ if (output[output.length - 1] !== 0) {
180
+ throw new SiftLightError("Git names protocol ended without a NUL delimiter");
181
+ }
182
+ const records: Buffer[] = [];
183
+ let offset = 0;
184
+ for (let delimiter = output.indexOf(0); delimiter !== -1; delimiter = output.indexOf(0, offset)) {
185
+ records.push(output.subarray(offset, delimiter));
186
+ offset = delimiter + 1;
187
+ }
188
+ return records;
189
+ }
@@ -0,0 +1,367 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open } from "node:fs/promises";
4
+ import { isAbsolute, relative, resolve, sep } from "node:path";
5
+ import { abortError, SiftLightError } from "./errors.js";
6
+ import { decodeGitPath, runGitRead, splitGitRecords } from "./git-process.js";
7
+ import {
8
+ assertExistingPathInsideCwd,
9
+ isPathInsideCwd,
10
+ sameSourceRevision,
11
+ sourceRevisionFromStats,
12
+ } from "./source.js";
13
+ import { MAX_SOURCE_FILE_BYTES, MAX_SOURCE_REVISION_FILES, type SourceRevision } from "./types.js";
14
+
15
+ export interface GitTreeEntry {
16
+ path: string;
17
+ mode: string;
18
+ blob: string;
19
+ size: number;
20
+ }
21
+
22
+ export type GitSourceOrigin =
23
+ | { kind: "git"; commit: string; blob: string }
24
+ | { kind: "worktree"; revision: SourceRevision; contentHash: string };
25
+
26
+ export interface GitRawSource {
27
+ path: string;
28
+ mode: string;
29
+ sourceStatus: "available" | "absent" | "binary" | "symlink" | "submodule" | "unavailable";
30
+ content?: Buffer;
31
+ contentHash?: string;
32
+ origin?: GitSourceOrigin;
33
+ reason?: string;
34
+ }
35
+
36
+ export interface GitReadBudget {
37
+ bytes: number;
38
+ maxBytes: number;
39
+ }
40
+
41
+ export async function verifyWorktreeRevision(
42
+ cwd: string,
43
+ path: string,
44
+ expected: SourceRevision,
45
+ ): Promise<void> {
46
+ try {
47
+ const current = await lstat(resolve(cwd, path));
48
+ await assertExistingPathInsideCwd(resolve(cwd, path), cwd);
49
+ if (current.isFile() && sameSourceRevision(sourceRevisionFromStats(current), expected)) return;
50
+ } catch (error) {
51
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
52
+ }
53
+ throw new SiftLightError("Working source changed during Git comparison; retry a new search");
54
+ }
55
+
56
+ export function gitPath(cwd: string, path: string): string {
57
+ if (path.length === 0 || path.includes("\0"))
58
+ throw new SiftLightError("Git source path is invalid");
59
+ const absolute = resolve(cwd, path);
60
+ const local = relative(resolve(cwd), absolute).split(sep).join("/");
61
+ if (
62
+ !isPathInsideCwd(absolute, cwd) ||
63
+ local.split("/").some((part) => part.toLowerCase() === ".git")
64
+ ) {
65
+ throw new SiftLightError(
66
+ "Git source path must stay within the working directory and outside .git",
67
+ );
68
+ }
69
+ return local;
70
+ }
71
+
72
+ export async function resolveGitCommit(
73
+ cwd: string,
74
+ ref: string,
75
+ signal?: AbortSignal,
76
+ ): Promise<string> {
77
+ if (ref.trim().length === 0 || ref.length > 1024 || ref.includes("\0")) {
78
+ throw new SiftLightError("Git commit reference must be a nonempty bounded string");
79
+ }
80
+ const { output } = await runGitRead(
81
+ cwd,
82
+ "rev-parse",
83
+ ["--verify", "--end-of-options", `${ref}^{commit}`],
84
+ {
85
+ ...(signal ? { signal } : {}),
86
+ maxBytes: 128,
87
+ },
88
+ );
89
+ const commit = output.toString("ascii").trim();
90
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(commit))
91
+ throw new SiftLightError("Git returned an invalid commit identity");
92
+ return commit;
93
+ }
94
+
95
+ export async function resolveGitRepository(cwd: string, signal?: AbortSignal): Promise<string> {
96
+ const { output } = await runGitRead(
97
+ cwd,
98
+ "rev-parse",
99
+ ["--show-toplevel"],
100
+ signal ? { signal, maxBytes: 4096 } : { maxBytes: 4096 },
101
+ );
102
+ const root = decodeGitPath(output).replace(/\r?\n$/, "");
103
+ if (!isAbsolute(root)) throw new SiftLightError("Git returned an invalid repository root");
104
+ return resolve(root);
105
+ }
106
+
107
+ export async function findGitRepository(
108
+ cwd: string,
109
+ signal?: AbortSignal,
110
+ ): Promise<string | undefined> {
111
+ try {
112
+ return await resolveGitRepository(cwd, signal);
113
+ } catch (error) {
114
+ if (error instanceof SiftLightError && error.message.includes("not a git repository")) {
115
+ return undefined;
116
+ }
117
+ throw error;
118
+ }
119
+ }
120
+
121
+ export async function readGitTree(
122
+ cwd: string,
123
+ commit: string,
124
+ signal?: AbortSignal,
125
+ path?: string,
126
+ ): Promise<{ entries: Map<string, GitTreeEntry>; limited: boolean }> {
127
+ const { output } = await runGitRead(
128
+ cwd,
129
+ "ls-tree",
130
+ ["-r", "-z", "-l", commit, ...(path ? ["--", gitPath(cwd, path)] : [])],
131
+ signal ? { signal } : {},
132
+ );
133
+ const entries = new Map<string, GitTreeEntry>();
134
+ for (const record of splitGitRecords(output)) {
135
+ if (entries.size === MAX_SOURCE_REVISION_FILES) return { entries, limited: true };
136
+ const tab = record.indexOf(9);
137
+ const header = record.subarray(0, tab).toString("ascii").trim().split(/\s+/);
138
+ const [mode, type, blob, size] = header;
139
+ if (
140
+ tab < 0 ||
141
+ !mode ||
142
+ !blob ||
143
+ !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(blob) ||
144
+ !["blob", "commit"].includes(type ?? "")
145
+ ) {
146
+ throw new SiftLightError("Git tree returned an invalid raw object entry");
147
+ }
148
+ const local = gitPath(cwd, decodeGitPath(record.subarray(tab + 1)));
149
+ const byteSize = type === "commit" ? 0 : Number(size);
150
+ if (!Number.isSafeInteger(byteSize) || byteSize < 0)
151
+ throw new SiftLightError("Git tree returned an invalid blob size");
152
+ entries.set(local, { path: local, mode, blob, size: byteSize });
153
+ }
154
+ return { entries, limited: false };
155
+ }
156
+
157
+ export async function worktreeNames(
158
+ cwd: string,
159
+ signal?: AbortSignal,
160
+ ): Promise<{ paths: string[]; limited: boolean }> {
161
+ const { output } = await runGitRead(
162
+ cwd,
163
+ "ls-files",
164
+ ["-z", "--cached", "--others", "--exclude-standard"],
165
+ signal ? { signal } : {},
166
+ );
167
+ const paths = new Set<string>();
168
+ for (const record of splitGitRecords(output)) {
169
+ if (paths.size === MAX_SOURCE_REVISION_FILES) return { paths: [...paths], limited: true };
170
+ paths.add(gitPath(cwd, decodeGitPath(record)));
171
+ }
172
+ return { paths: [...paths], limited: false };
173
+ }
174
+
175
+ /** --no-index deliberately applies today's privacy rules to historically tracked files as well. */
176
+ /* oxlint-disable no-await-in-loop -- batches bound input and process concurrency; predicates may have their own shared budget. */
177
+ export async function visibleGitPaths(
178
+ cwd: string,
179
+ paths: string[],
180
+ signal?: AbortSignal,
181
+ includePath?: (path: string) => boolean | Promise<boolean>,
182
+ ): Promise<string[]> {
183
+ const result: string[] = [];
184
+ for (let start = 0; start < paths.length; start += 128) {
185
+ const batch = paths.slice(start, start + 128);
186
+ const { output } = await runGitRead(cwd, "check-ignore", ["--no-index", "-z", "--stdin"], {
187
+ input: Buffer.from(`${batch.map((path) => `./${path}`).join("\0")}\0`),
188
+ allowedCodes: [0, 1],
189
+ ...(signal ? { signal } : {}),
190
+ });
191
+ const ignored = new Set(
192
+ splitGitRecords(output).map((record) => gitPath(cwd, decodeGitPath(record))),
193
+ );
194
+ for (const path of batch) {
195
+ if (signal?.aborted) throw abortError();
196
+ if (!ignored.has(path) && (!includePath || (await includePath(path)))) result.push(path);
197
+ }
198
+ }
199
+ return result;
200
+ }
201
+
202
+ /* oxlint-enable no-await-in-loop */
203
+
204
+ function limitedSource(path: string, mode: string, reason: string): GitRawSource {
205
+ return { path, mode, sourceStatus: "unavailable", reason };
206
+ }
207
+
208
+ export async function readGitBlob(
209
+ cwd: string,
210
+ commit: string,
211
+ entry: GitTreeEntry,
212
+ budget: GitReadBudget,
213
+ signal?: AbortSignal,
214
+ ): Promise<GitRawSource> {
215
+ const { path, mode, blob, size } = entry;
216
+ if (mode === "120000" || mode === "160000") {
217
+ return {
218
+ path,
219
+ mode,
220
+ sourceStatus: mode === "120000" ? "symlink" : "submodule",
221
+ reason: "Symlink and submodule contents are not followed",
222
+ };
223
+ }
224
+ if (size > MAX_SOURCE_FILE_BYTES)
225
+ return limitedSource(
226
+ path,
227
+ mode,
228
+ `Source exceeds the ${String(MAX_SOURCE_FILE_BYTES)} byte file limit`,
229
+ );
230
+ if (budget.bytes + size > budget.maxBytes)
231
+ return limitedSource(
232
+ path,
233
+ mode,
234
+ `Source reads exceed the ${String(budget.maxBytes)} byte request limit`,
235
+ );
236
+ const { output } = await runGitRead(cwd, "cat-file", ["blob", blob], {
237
+ maxBytes: size,
238
+ ...(signal ? { signal } : {}),
239
+ });
240
+ budget.bytes += output.length;
241
+ if (output.length !== size)
242
+ throw new SiftLightError("Git blob size does not match its immutable tree entry");
243
+ const verifiedBlob = createHash(blob.length === 40 ? "sha1" : "sha256")
244
+ .update(`blob ${String(output.length)}\0`)
245
+ .update(output)
246
+ .digest("hex");
247
+ if (verifiedBlob !== blob)
248
+ throw new SiftLightError("Git blob bytes do not match their immutable object identity");
249
+ return {
250
+ path,
251
+ mode,
252
+ sourceStatus: output.includes(0) ? "binary" : "available",
253
+ ...(output.includes(0) ? { reason: "Binary source contains NUL bytes" } : { content: output }),
254
+ origin: { kind: "git", commit, blob },
255
+ contentHash: createHash("sha256").update(output).digest("hex"),
256
+ };
257
+ }
258
+
259
+ /* oxlint-disable no-await-in-loop -- bounded positional reads consume one file handle in order. */
260
+ export async function readWorktreeSource(
261
+ cwd: string,
262
+ path: string,
263
+ budget: GitReadBudget,
264
+ signal?: AbortSignal,
265
+ ): Promise<GitRawSource> {
266
+ const absolute = resolve(cwd, path);
267
+ if (signal?.aborted) throw abortError();
268
+ let discovered = false;
269
+ try {
270
+ const before = await lstat(absolute);
271
+ discovered = true;
272
+ if (before.isSymbolicLink())
273
+ return {
274
+ path,
275
+ mode: "120000",
276
+ sourceStatus: "symlink",
277
+ reason: "Symlink source is not followed",
278
+ };
279
+ if (!before.isFile())
280
+ return {
281
+ path,
282
+ mode: "160000",
283
+ sourceStatus: before.isDirectory() ? "submodule" : "unavailable",
284
+ reason: "Non-regular source is not read",
285
+ };
286
+ const mode = (before.mode & 0o111) === 0 ? "100644" : "100755";
287
+ if (before.size > MAX_SOURCE_FILE_BYTES)
288
+ return limitedSource(
289
+ path,
290
+ mode,
291
+ `Source exceeds the ${String(MAX_SOURCE_FILE_BYTES)} byte file limit`,
292
+ );
293
+ if (budget.bytes + before.size > budget.maxBytes)
294
+ return limitedSource(
295
+ path,
296
+ mode,
297
+ `Source reads exceed the ${String(budget.maxBytes)} byte request limit`,
298
+ );
299
+ await assertExistingPathInsideCwd(absolute, cwd);
300
+ const handle = await open(absolute, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
301
+ try {
302
+ if (
303
+ !sameSourceRevision(
304
+ sourceRevisionFromStats(before),
305
+ sourceRevisionFromStats(await handle.stat()),
306
+ )
307
+ )
308
+ throw new SiftLightError("Working source changed before reading");
309
+ const buffer = Buffer.alloc(before.size + 1);
310
+ let bytes = 0;
311
+ while (bytes < buffer.length) {
312
+ if (signal?.aborted) throw abortError();
313
+ const { bytesRead } = await handle.read(
314
+ buffer,
315
+ bytes,
316
+ Math.min(64 * 1024, buffer.length - bytes),
317
+ null,
318
+ );
319
+ if (bytesRead === 0) break;
320
+ bytes += bytesRead;
321
+ }
322
+ budget.bytes += bytes;
323
+ const after = await lstat(absolute);
324
+ await assertExistingPathInsideCwd(absolute, cwd);
325
+ if (
326
+ bytes !== before.size ||
327
+ !sameSourceRevision(sourceRevisionFromStats(before), sourceRevisionFromStats(after)) ||
328
+ !sameSourceRevision(
329
+ sourceRevisionFromStats(before),
330
+ sourceRevisionFromStats(await handle.stat()),
331
+ )
332
+ ) {
333
+ throw new SiftLightError(
334
+ "Working source changed while reading; Git ranges and source cannot be mixed",
335
+ );
336
+ }
337
+ const content = buffer.subarray(0, bytes);
338
+ return {
339
+ path,
340
+ mode,
341
+ sourceStatus: content.includes(0) ? "binary" : "available",
342
+ ...(content.includes(0) ? { reason: "Binary source contains NUL bytes" } : { content }),
343
+ origin: {
344
+ kind: "worktree",
345
+ revision: sourceRevisionFromStats(after),
346
+ contentHash: createHash("sha256").update(content).digest("hex"),
347
+ },
348
+ contentHash: createHash("sha256").update(content).digest("hex"),
349
+ };
350
+ } finally {
351
+ await handle.close();
352
+ }
353
+ } catch (error) {
354
+ if (error instanceof Error && "code" in error) {
355
+ if (error.code === "ENOENT") {
356
+ if (discovered)
357
+ throw new SiftLightError("Working source disappeared while reading; retry a new search");
358
+ return { path, mode: "000000", sourceStatus: "absent" };
359
+ }
360
+ if (["EACCES", "EPERM", "ELOOP", "ENOTDIR"].includes(String(error.code)))
361
+ return limitedSource(path, "000000", `Source unavailable: ${String(error.code)}`);
362
+ }
363
+ throw error;
364
+ }
365
+ }
366
+
367
+ /* oxlint-enable no-await-in-loop */