knodin 0.10.8 → 0.12.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/dist/bin/cli.js +41 -4
- package/dist/src/cli-model.js +1 -0
- package/dist/src/compact-structural.js +8 -3
- package/dist/src/credential-patterns.js +14 -1
- package/dist/src/engine/index.js +371 -74
- package/dist/src/engine/parse-pool.js +21 -4
- package/dist/src/engine/seal.js +122 -3
- package/dist/src/engine/sealed-open.js +125 -8
- package/dist/src/engine/text-matches.js +121 -79
- package/dist/src/graph-query-health.js +9 -3
- package/dist/src/init.js +109 -3
- package/dist/src/lifecycle-health.js +46 -3
- package/dist/src/mcp-worker-supervisor.js +10 -2
- package/dist/src/repair-progress.js +12 -0
- package/dist/src/structural-fast-path.js +64 -1
- package/dist/src/tools/knodin-tools.js +32 -9
- package/docs/releases/0.11.0.md +227 -0
- package/docs/releases/0.12.0.md +281 -0
- package/package.json +5 -2
package/dist/src/engine/seal.js
CHANGED
|
@@ -22,16 +22,114 @@ const SEAL_SCHEMA = [
|
|
|
22
22
|
function countLines(buf) {
|
|
23
23
|
return buf.toString("utf8").split("\n").length;
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* A database claims to be an artifact but carries an attestation the reader
|
|
27
|
+
* cannot trust.
|
|
28
|
+
*
|
|
29
|
+
* Distinct from "no attestation at all", which is an ordinary index and a
|
|
30
|
+
* routine answer. Every field here is consumed as a decision downstream — the
|
|
31
|
+
* schema gate, the version gate, and the repository path the artifact is
|
|
32
|
+
* allowed to serve source for — so a half-populated object must stop at the
|
|
33
|
+
* reader rather than reach them.
|
|
34
|
+
*/
|
|
35
|
+
export class SealAttestationError extends Error {
|
|
36
|
+
code = "malformed-attestation";
|
|
37
|
+
constructor(reason) {
|
|
38
|
+
super(reason);
|
|
39
|
+
this.name = "SealAttestationError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function isRecord(value) {
|
|
43
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Validates the WHOLE shape, not just the fields the immediate caller reads.
|
|
47
|
+
* A sealed artifact is transported between machines, so it is untrusted input
|
|
48
|
+
* rather than something this process wrote (KNODIN-21), and an unchecked cast
|
|
49
|
+
* would let `undefined` stand where a version or a path belongs.
|
|
50
|
+
*/
|
|
51
|
+
function validateAttestation(value) {
|
|
52
|
+
const bad = (reason) => {
|
|
53
|
+
throw new SealAttestationError(`seal attestation is malformed: ${reason}`);
|
|
54
|
+
};
|
|
55
|
+
if (!isRecord(value))
|
|
56
|
+
return bad("not an object");
|
|
57
|
+
const str = (key, on) => typeof on[key] === "string" ? on[key] : bad(`${key} is not a string`);
|
|
58
|
+
const num = (key, on) => typeof on[key] === "number" && Number.isFinite(on[key])
|
|
59
|
+
? on[key]
|
|
60
|
+
: bad(`${key} is not a finite number`);
|
|
61
|
+
const nullableStr = (key, on) => on[key] === null || typeof on[key] === "string"
|
|
62
|
+
? on[key]
|
|
63
|
+
: bad(`${key} is not a string or null`);
|
|
64
|
+
const obj = (key) => isRecord(value[key]) ? value[key] : bad(`${key} is not an object`);
|
|
65
|
+
if (value.sealVersion !== 1)
|
|
66
|
+
bad(`unsupported sealVersion ${String(value.sealVersion)}`);
|
|
67
|
+
const repository = obj("repository");
|
|
68
|
+
const health = obj("health");
|
|
69
|
+
const source = obj("source");
|
|
70
|
+
const embeddings = obj("embeddings");
|
|
71
|
+
const policy = source.policy;
|
|
72
|
+
if (policy !== "all-indexed" && policy !== "excluded")
|
|
73
|
+
bad(`unsupported source policy ${String(policy)}`);
|
|
74
|
+
const stripped = embeddings.stripped;
|
|
75
|
+
if (typeof stripped !== "boolean")
|
|
76
|
+
bad("embeddings.stripped is not a boolean");
|
|
77
|
+
return {
|
|
78
|
+
sealVersion: 1,
|
|
79
|
+
knodinVersion: str("knodinVersion", value),
|
|
80
|
+
schemaVersion: num("schemaVersion", value),
|
|
81
|
+
repository: {
|
|
82
|
+
identity: str("identity", repository),
|
|
83
|
+
path: str("path", repository),
|
|
84
|
+
remoteUrl: nullableStr("remoteUrl", repository),
|
|
85
|
+
},
|
|
86
|
+
sealedCommit: str("sealedCommit", value),
|
|
87
|
+
sealedRef: nullableStr("sealedRef", value),
|
|
88
|
+
sealedAt: str("sealedAt", value),
|
|
89
|
+
health: {
|
|
90
|
+
status: str("status", health),
|
|
91
|
+
pendingPaths: num("pendingPaths", health),
|
|
92
|
+
freshness: str("freshness", health),
|
|
93
|
+
},
|
|
94
|
+
source: {
|
|
95
|
+
policy: policy,
|
|
96
|
+
files: num("files", source),
|
|
97
|
+
uniqueBlobs: num("uniqueBlobs", source),
|
|
98
|
+
rawBytes: num("rawBytes", source),
|
|
99
|
+
storedBytes: num("storedBytes", source),
|
|
100
|
+
},
|
|
101
|
+
embeddings: { stripped: stripped, removed: num("removed", embeddings) },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* `null` when the database carries no attestation; THROWS
|
|
106
|
+
* `SealAttestationError` when it carries one that does not validate. The split
|
|
107
|
+
* has teeth: "ordinary index" is something a caller falls back from, while a
|
|
108
|
+
* database misrepresenting itself must not be quietly downgraded to the same
|
|
109
|
+
* answer.
|
|
110
|
+
*/
|
|
25
111
|
export function readSealAttestation(db) {
|
|
112
|
+
let raw;
|
|
26
113
|
try {
|
|
27
114
|
const row = db
|
|
28
115
|
.query("SELECT value FROM meta WHERE key = 'sealAttestation'")
|
|
29
116
|
.get();
|
|
30
|
-
|
|
117
|
+
if (!row)
|
|
118
|
+
return null;
|
|
119
|
+
raw = row.value;
|
|
31
120
|
}
|
|
32
121
|
catch {
|
|
122
|
+
// No `meta` table, or an unreadable one: not a sealed artifact.
|
|
33
123
|
return null;
|
|
34
124
|
}
|
|
125
|
+
let parsed;
|
|
126
|
+
try {
|
|
127
|
+
parsed = JSON.parse(raw);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
throw new SealAttestationError(`seal attestation is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
131
|
+
}
|
|
132
|
+
return validateAttestation(parsed);
|
|
35
133
|
}
|
|
36
134
|
/**
|
|
37
135
|
* Reads embedded source out of a sealed artifact.
|
|
@@ -64,9 +162,30 @@ export function listSealedFiles(db) {
|
|
|
64
162
|
.all()
|
|
65
163
|
.map((row) => row.filePath);
|
|
66
164
|
}
|
|
67
|
-
/**
|
|
165
|
+
/**
|
|
166
|
+
* Content hash of every embedded file, as recorded at seal time.
|
|
167
|
+
*
|
|
168
|
+
* The hash is over the RAW bytes, so a caller can compare against a file on
|
|
169
|
+
* disk without decompressing the blob.
|
|
170
|
+
*/
|
|
171
|
+
export function listSealedFileHashes(db) {
|
|
172
|
+
return db
|
|
173
|
+
.query("SELECT filePath, sha256 FROM sealed_file ORDER BY filePath")
|
|
174
|
+
.all();
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* True when this database carries embedded source, i.e. can answer with no
|
|
178
|
+
* checkout. A malformed attestation answers false: it is not an artifact any
|
|
179
|
+
* reader here can serve from, and this predicate's callers route on it rather
|
|
180
|
+
* than handle it.
|
|
181
|
+
*/
|
|
68
182
|
export function isSealedDatabase(db) {
|
|
69
|
-
|
|
183
|
+
try {
|
|
184
|
+
return readSealAttestation(db) !== null;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
70
189
|
}
|
|
71
190
|
/**
|
|
72
191
|
* Produce a sealed artifact, or refuse with a machine-readable code.
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { KNODIN_VERSION as RUNTIME_VERSION } from "../version.js";
|
|
5
6
|
import { KNODIN_SCHEMA_VERSION, registerSealedRepository, unregisterSealedRepository, } from "./index.js";
|
|
6
|
-
import { readSealAttestation } from "./seal.js";
|
|
7
|
+
import { listSealedFileHashes, readSealAttestation, SealAttestationError, } from "./seal.js";
|
|
7
8
|
import { Database } from "./sqlite.js";
|
|
8
9
|
export function openSealedArtifact(artifactPath, options = {}) {
|
|
9
10
|
const resolved = path.resolve(artifactPath);
|
|
@@ -20,7 +21,18 @@ export function openSealedArtifact(artifactPath, options = {}) {
|
|
|
20
21
|
reason: error instanceof Error ? error.message : String(error),
|
|
21
22
|
};
|
|
22
23
|
}
|
|
23
|
-
|
|
24
|
+
let attestation;
|
|
25
|
+
try {
|
|
26
|
+
attestation = readSealAttestation(db);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
db.close();
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
code: error instanceof SealAttestationError ? "malformed-attestation" : "unreadable",
|
|
33
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
24
36
|
if (!attestation) {
|
|
25
37
|
db.close();
|
|
26
38
|
return {
|
|
@@ -84,10 +96,31 @@ export function openSealedArtifact(artifactPath, options = {}) {
|
|
|
84
96
|
// only the mount would leave those internal reads falling through to a
|
|
85
97
|
// filesystem that does not have the source, which fails as "" rather than
|
|
86
98
|
// loudly.
|
|
87
|
-
registerSealedRepository(mountDir, db);
|
|
88
99
|
const attestedPath = attestation.repository.path;
|
|
89
|
-
|
|
90
|
-
|
|
100
|
+
const registerAttested = attestedPath && path.resolve(attestedPath) !== path.resolve(mountDir);
|
|
101
|
+
// The attested path is chosen by the artifact, and the registry it lands in
|
|
102
|
+
// takes precedence over the filesystem for every source read in the
|
|
103
|
+
// process. An artifact naming a path that exists here would therefore serve
|
|
104
|
+
// its own bytes to live queries against that checkout (KNODIN-21). Sealing
|
|
105
|
+
// and opening on ONE machine is a real flow, so this refuses on
|
|
106
|
+
// DISAGREEMENT rather than on existence: where the embedded blobs match the
|
|
107
|
+
// files on disk the artifact describes that tree and shadowing it changes
|
|
108
|
+
// nothing, and where they do not, whichever side is wrong, no read of that
|
|
109
|
+
// path can be trusted.
|
|
110
|
+
if (registerAttested) {
|
|
111
|
+
const conflict = firstSourceDisagreement(db, path.resolve(attestedPath));
|
|
112
|
+
if (conflict) {
|
|
113
|
+
db.close();
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
code: "path-conflict",
|
|
117
|
+
reason: `artifact attests ${path.resolve(attestedPath)}, which exists here with different source (${conflict}); re-seal from this checkout, or open the artifact where that path is absent`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
acquireSealedRepository(mountDir, db);
|
|
122
|
+
if (registerAttested)
|
|
123
|
+
acquireSealedRepository(attestedPath, db);
|
|
91
124
|
const sealedAt = Date.parse(attestation.sealedAt);
|
|
92
125
|
const ageDays = Number.isNaN(sealedAt)
|
|
93
126
|
? 0
|
|
@@ -100,13 +133,97 @@ export function openSealedArtifact(artifactPath, options = {}) {
|
|
|
100
133
|
degraded,
|
|
101
134
|
ageDays,
|
|
102
135
|
close() {
|
|
103
|
-
|
|
104
|
-
if (
|
|
105
|
-
|
|
136
|
+
releaseSealedRepository(mountDir, db);
|
|
137
|
+
if (registerAttested)
|
|
138
|
+
releaseSealedRepository(attestedPath, db);
|
|
106
139
|
db.close();
|
|
107
140
|
},
|
|
108
141
|
};
|
|
109
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Open handles per registered path, most recent last.
|
|
145
|
+
*
|
|
146
|
+
* The engine's registry is a bare path→database map, so two artifacts
|
|
147
|
+
* attesting the same repository would have the first `close()` unregister the
|
|
148
|
+
* other's source and leave it reading `""` (KNODIN-21). Held here rather than
|
|
149
|
+
* widened there because the map is the engine's chokepoint for a single
|
|
150
|
+
* answer to "where do bytes for this path come from"; ownership of the
|
|
151
|
+
* handles that claim it belongs to whoever opened them.
|
|
152
|
+
*/
|
|
153
|
+
const sealedRegistrations = new Map();
|
|
154
|
+
function acquireSealedRepository(repoPath, db) {
|
|
155
|
+
const key = path.resolve(repoPath);
|
|
156
|
+
const held = sealedRegistrations.get(key) ?? [];
|
|
157
|
+
held.push(db);
|
|
158
|
+
sealedRegistrations.set(key, held);
|
|
159
|
+
registerSealedRepository(key, db);
|
|
160
|
+
}
|
|
161
|
+
function releaseSealedRepository(repoPath, db) {
|
|
162
|
+
const key = path.resolve(repoPath);
|
|
163
|
+
const held = sealedRegistrations.get(key);
|
|
164
|
+
if (!held)
|
|
165
|
+
return;
|
|
166
|
+
const last = held.lastIndexOf(db);
|
|
167
|
+
if (last === -1)
|
|
168
|
+
return;
|
|
169
|
+
held.splice(last, 1);
|
|
170
|
+
// Re-point at a surviving handle rather than leaving the closed one
|
|
171
|
+
// registered: an unregister here would fall through to a filesystem that
|
|
172
|
+
// has no source, and a closed database would throw on the next read.
|
|
173
|
+
const survivor = held.at(-1);
|
|
174
|
+
if (survivor)
|
|
175
|
+
registerSealedRepository(key, survivor);
|
|
176
|
+
else {
|
|
177
|
+
sealedRegistrations.delete(key);
|
|
178
|
+
unregisterSealedRepository(key);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* First embedded file whose bytes differ from the file at the same relative
|
|
183
|
+
* path under `dir`, or `null` when every one that exists there agrees.
|
|
184
|
+
*
|
|
185
|
+
* Files absent from `dir` are not disagreement — a partially or wholly removed
|
|
186
|
+
* checkout is precisely what an artifact is for. Compared by hash against the
|
|
187
|
+
* one recorded at seal time, which is over raw bytes, so nothing is
|
|
188
|
+
* decompressed to answer this.
|
|
189
|
+
*
|
|
190
|
+
* `filePath` comes out of the artifact and is therefore attacker-controlled, so
|
|
191
|
+
* every candidate is resolved and required to stay under `dir` before it is
|
|
192
|
+
* opened. A `..` segment would otherwise have this check read and hash files
|
|
193
|
+
* outside the attested checkout — reachable during `openSealedArtifact`, and
|
|
194
|
+
* pure attack surface, since a repository-relative path never needs to escape.
|
|
195
|
+
* An entry that does is reported as disagreement rather than skipped: it is not
|
|
196
|
+
* a path this artifact can honestly claim, and failing closed keeps a traversal
|
|
197
|
+
* from being a way to hide an entry from this comparison.
|
|
198
|
+
*/
|
|
199
|
+
function firstSourceDisagreement(db, dir) {
|
|
200
|
+
let stat;
|
|
201
|
+
try {
|
|
202
|
+
stat = fs.statSync(dir);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
if (!stat.isDirectory())
|
|
208
|
+
return null;
|
|
209
|
+
const root = path.resolve(dir);
|
|
210
|
+
for (const entry of listSealedFileHashes(db)) {
|
|
211
|
+
const candidate = path.resolve(root, entry.filePath);
|
|
212
|
+
if (candidate !== root && !candidate.startsWith(root + path.sep))
|
|
213
|
+
return entry.filePath;
|
|
214
|
+
let onDisk;
|
|
215
|
+
try {
|
|
216
|
+
onDisk = fs.readFileSync(candidate);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const hash = crypto.createHash("sha256").update(onDisk).digest("hex");
|
|
222
|
+
if (hash !== entry.sha256)
|
|
223
|
+
return entry.filePath;
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
110
227
|
/**
|
|
111
228
|
* Read at call time rather than imported as a constant so that a test can
|
|
112
229
|
* exercise the soft gate without rebuilding the package version.
|
|
@@ -25,6 +25,24 @@ import { walkRepoFiles } from "./file-walker.js";
|
|
|
25
25
|
const WORD_CHARACTER = /[\p{L}\p{N}_]/u;
|
|
26
26
|
/** Upper bound on `TextMatch.enclosingText`, so one row cannot flood a preview. */
|
|
27
27
|
const ENCLOSING_TEXT_LIMIT = 120;
|
|
28
|
+
/**
|
|
29
|
+
* Largest file this search will read, in bytes.
|
|
30
|
+
*
|
|
31
|
+
* R1 deliberately drops the source-extension filter, so the walk reaches
|
|
32
|
+
* lockfiles, NDJSON dumps, fixtures and generated JSON as well as prose. Four
|
|
33
|
+
* mebibytes clears every hand-written source or document file by a wide margin
|
|
34
|
+
* while excluding the generated artefacts that would otherwise decide the
|
|
35
|
+
* process's peak memory. Oversized files are reported in `uncovered`, never
|
|
36
|
+
* dropped.
|
|
37
|
+
*/
|
|
38
|
+
export const MAX_SEARCHABLE_FILE_BYTES = 4 * 1024 * 1024;
|
|
39
|
+
/**
|
|
40
|
+
* Largest number of matches this search will accumulate.
|
|
41
|
+
*
|
|
42
|
+
* Past this the result is no longer reviewable and the match objects alone
|
|
43
|
+
* dominate memory; the honest answer is a bounded list plus `truncated: true`.
|
|
44
|
+
*/
|
|
45
|
+
export const MAX_TEXT_MATCHES = 5_000;
|
|
28
46
|
function isWordCharacter(value) {
|
|
29
47
|
return value !== undefined && WORD_CHARACTER.test(value);
|
|
30
48
|
}
|
|
@@ -164,86 +182,101 @@ function classifyAgainstTree(parser, content, raw) {
|
|
|
164
182
|
* over a large repository touches enough files to reproduce it exactly.
|
|
165
183
|
*/
|
|
166
184
|
export async function searchFiles(files, term, loadLanguage, newParser, options = {}) {
|
|
167
|
-
const
|
|
168
|
-
const matches = [];
|
|
169
|
-
const uncovered = [];
|
|
170
|
-
let filesSearched = 0;
|
|
185
|
+
const state = newSearchState();
|
|
171
186
|
for (const { file, content } of files) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
187
|
+
await searchOneFile(state, file, content, term, loadLanguage, newParser, options);
|
|
188
|
+
if (state.truncated)
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
return { ...state, searched: true };
|
|
192
|
+
}
|
|
193
|
+
function newSearchState() {
|
|
194
|
+
return { matches: [], uncovered: [], filesSearched: 0, truncated: false };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Search and classify one file, appending to `state`.
|
|
198
|
+
*
|
|
199
|
+
* Takes a single file's content rather than a list so that a repository-wide
|
|
200
|
+
* search never has to hold more than one file in memory at once (KNODIN-24).
|
|
201
|
+
* Sets `state.truncated` when the match cap is reached; the caller is
|
|
202
|
+
* responsible for stopping there.
|
|
203
|
+
*/
|
|
204
|
+
async function searchOneFile(state, file, content, term, loadLanguage, newParser, options) {
|
|
205
|
+
const caseSensitive = options.caseSensitive ?? true;
|
|
206
|
+
const raw = findOccurrences(content, term, caseSensitive);
|
|
207
|
+
state.filesSearched++;
|
|
208
|
+
if (raw.length === 0)
|
|
209
|
+
return;
|
|
210
|
+
let classified = null;
|
|
211
|
+
let language = null;
|
|
212
|
+
try {
|
|
213
|
+
language = await loadLanguage(file);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// A grammar that will not load costs classification, not the match.
|
|
217
|
+
language = null;
|
|
218
|
+
}
|
|
219
|
+
if (language) {
|
|
220
|
+
const parser = newParser();
|
|
178
221
|
try {
|
|
179
|
-
language
|
|
222
|
+
parser.setLanguage(language);
|
|
223
|
+
classified = classifyAgainstTree(parser, content, raw);
|
|
180
224
|
}
|
|
181
|
-
catch {
|
|
182
|
-
|
|
183
|
-
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (isWasmAbort(error))
|
|
227
|
+
throw error;
|
|
228
|
+
// Parsing failed on this file only. The matches are still real and
|
|
229
|
+
// still reported — just without node evidence.
|
|
230
|
+
classified = null;
|
|
231
|
+
state.uncovered.push({
|
|
232
|
+
file,
|
|
233
|
+
reason: `matched but could not be parsed for classification: ${error instanceof Error ? error.message : String(error)}`,
|
|
234
|
+
});
|
|
184
235
|
}
|
|
185
|
-
|
|
186
|
-
const parser = newParser();
|
|
236
|
+
finally {
|
|
187
237
|
try {
|
|
188
|
-
parser.
|
|
189
|
-
classified = classifyAgainstTree(parser, content, raw);
|
|
238
|
+
parser.delete();
|
|
190
239
|
}
|
|
191
|
-
catch
|
|
192
|
-
|
|
193
|
-
throw error;
|
|
194
|
-
// Parsing failed on this file only. The matches are still real and
|
|
195
|
-
// still reported — just without node evidence.
|
|
196
|
-
classified = null;
|
|
197
|
-
uncovered.push({
|
|
198
|
-
file,
|
|
199
|
-
reason: `matched but could not be parsed for classification: ${error instanceof Error ? error.message : String(error)}`,
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
finally {
|
|
203
|
-
try {
|
|
204
|
-
parser.delete();
|
|
205
|
-
}
|
|
206
|
-
catch {
|
|
207
|
-
/* a dead module has nothing to reclaim */
|
|
208
|
-
}
|
|
240
|
+
catch {
|
|
241
|
+
/* a dead module has nothing to reclaim */
|
|
209
242
|
}
|
|
210
243
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
244
|
+
}
|
|
245
|
+
const starts = lineStarts(content);
|
|
246
|
+
for (const [position, match] of raw.entries()) {
|
|
247
|
+
if (state.matches.length >= MAX_TEXT_MATCHES) {
|
|
248
|
+
state.truncated = true;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const lineIndex = lineIndexFor(starts, match.startIndex);
|
|
252
|
+
const lineStart = starts[lineIndex];
|
|
253
|
+
const nextStart = starts[lineIndex + 1] ?? content.length + 1;
|
|
254
|
+
const node = classified?.[position] ?? null;
|
|
255
|
+
state.matches.push({
|
|
256
|
+
file,
|
|
257
|
+
line: lineIndex + 1,
|
|
258
|
+
column: match.startIndex - lineStart + 1,
|
|
259
|
+
startIndex: match.startIndex,
|
|
260
|
+
endIndex: match.endIndex,
|
|
261
|
+
lineText: content.slice(lineStart, Math.max(lineStart, nextStart - 1)).replace(/\r$/, ""),
|
|
262
|
+
matchedText: match.matchedText,
|
|
263
|
+
caseMatchesQuery: match.matchedText === term,
|
|
264
|
+
withinLargerWord: isWordCharacter(content[match.startIndex - 1]) || isWordCharacter(content[match.endIndex]),
|
|
265
|
+
classification: node ? node.classification : classifyByFileType(file),
|
|
266
|
+
basis: node ? "parse-node" : language ? "none" : "file-type",
|
|
267
|
+
nodeType: node ? node.nodeType : null,
|
|
268
|
+
enclosingText: node ? node.enclosingText : null,
|
|
233
269
|
});
|
|
234
270
|
}
|
|
235
|
-
return { matches, uncovered, filesSearched, searched: true };
|
|
236
271
|
}
|
|
237
|
-
|
|
238
|
-
* Read a file for searching, or say why it could not be.
|
|
239
|
-
*
|
|
240
|
-
* Binary detection is a NUL-byte sniff over the head of the file rather than an
|
|
241
|
-
* extension list: the extensions that matter here are open-ended, and a
|
|
242
|
-
* misjudged binary read produces garbage matches rather than an honest skip.
|
|
243
|
-
*/
|
|
244
|
-
function readSearchable(absolute) {
|
|
272
|
+
export function readSearchable(absolute) {
|
|
245
273
|
let buffer;
|
|
246
274
|
try {
|
|
275
|
+
const stats = fs.statSync(absolute);
|
|
276
|
+
if (stats.size > MAX_SEARCHABLE_FILE_BYTES)
|
|
277
|
+
return {
|
|
278
|
+
reason: `too large to search: ${stats.size} bytes exceeds the ${MAX_SEARCHABLE_FILE_BYTES}-byte limit`,
|
|
279
|
+
};
|
|
247
280
|
buffer = fs.readFileSync(absolute);
|
|
248
281
|
}
|
|
249
282
|
catch (error) {
|
|
@@ -264,8 +297,15 @@ function readSearchable(absolute) {
|
|
|
264
297
|
* Every file that could not be searched is returned in `uncovered` (R5). A
|
|
265
298
|
* rename that silently skipped files would leave a half-renamed repository
|
|
266
299
|
* looking finished, which is the worst available outcome for this operation.
|
|
300
|
+
*
|
|
301
|
+
* Files are read and searched one at a time. Reading them all up front held the
|
|
302
|
+
* entire non-pruned working tree in memory at once, which on a tree carrying
|
|
303
|
+
* generated dumps or vendored data is gigabytes for a single call (KNODIN-24).
|
|
304
|
+
*
|
|
305
|
+
* `readFile` is injected only so a test can observe that reads interleave with
|
|
306
|
+
* searching rather than preceding it.
|
|
267
307
|
*/
|
|
268
|
-
export async function searchRepoText(repoPath, term, loadLanguage, newParser, options = {}) {
|
|
308
|
+
export async function searchRepoText(repoPath, term, loadLanguage, newParser, options = {}, readFile = readSearchable) {
|
|
269
309
|
let candidates;
|
|
270
310
|
try {
|
|
271
311
|
// Checked explicitly because `walkRepoFiles` swallows a missing directory
|
|
@@ -291,19 +331,21 @@ export async function searchRepoText(repoPath, term, loadLanguage, newParser, op
|
|
|
291
331
|
],
|
|
292
332
|
filesSearched: 0,
|
|
293
333
|
searched: false,
|
|
334
|
+
truncated: false,
|
|
294
335
|
};
|
|
295
336
|
}
|
|
296
|
-
const
|
|
297
|
-
const uncovered = [];
|
|
298
|
-
for (const file of candidates) {
|
|
299
|
-
const outcome = readSearchable(path.join(repoPath, file));
|
|
300
|
-
if ("reason" in outcome)
|
|
301
|
-
uncovered.push({ file, reason: outcome.reason });
|
|
302
|
-
else
|
|
303
|
-
readable.push({ file, content: outcome.content });
|
|
304
|
-
}
|
|
305
|
-
const result = await searchFiles(readable, term, loadLanguage, newParser, options);
|
|
337
|
+
const state = newSearchState();
|
|
306
338
|
// Files skipped at read time and files that failed classification are both
|
|
307
339
|
// gaps in the same claim, so they are reported through one list.
|
|
308
|
-
|
|
340
|
+
for (const file of candidates) {
|
|
341
|
+
const outcome = readFile(path.join(repoPath, file));
|
|
342
|
+
if ("reason" in outcome) {
|
|
343
|
+
state.uncovered.push({ file, reason: outcome.reason });
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
await searchOneFile(state, file, outcome.content, term, loadLanguage, newParser, options);
|
|
347
|
+
if (state.truncated)
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
return { ...state, searched: true };
|
|
309
351
|
}
|
|
@@ -101,15 +101,21 @@ export function decorateGraphQueryResult(value, graphState = "healthy", freshnes
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
const root = value;
|
|
104
|
-
|
|
104
|
+
// An unresolved target is not a no-match: nothing was searched for. Calling
|
|
105
|
+
// it `no-match` is what let a misspelled symbol read as proof that nothing
|
|
106
|
+
// calls it (KNODIN-28). Ambiguity is exempted for the same reason.
|
|
107
|
+
const notFound = root.targetResolution === "not-found";
|
|
108
|
+
const noMatch = hasNoMatches(root) && root.ambiguity === undefined && !notFound;
|
|
109
|
+
const state = notFound ? "target-not-found" : noMatch ? "no-match" : graphState;
|
|
105
110
|
return {
|
|
106
111
|
...root,
|
|
107
112
|
...(freshness ? { freshness } : {}),
|
|
113
|
+
...(notFound && root.status === undefined ? { status: "target-not-found" } : {}),
|
|
108
114
|
...(noMatch && root.status === undefined ? { status: "no-match" } : {}),
|
|
109
115
|
availability: {
|
|
110
|
-
state
|
|
116
|
+
state,
|
|
111
117
|
graphState,
|
|
112
|
-
outcome: noMatch ? "no-match" : "matches",
|
|
118
|
+
outcome: notFound ? "target-not-found" : noMatch ? "no-match" : "matches",
|
|
113
119
|
},
|
|
114
120
|
};
|
|
115
121
|
}
|