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.
@@ -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
- return row ? JSON.parse(row.value) : null;
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
- /** True when this database carries embedded source, i.e. can answer with no checkout. */
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
- return readSealAttestation(db) !== null;
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
- const attestation = readSealAttestation(db);
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
- if (attestedPath && path.resolve(attestedPath) !== path.resolve(mountDir))
90
- registerSealedRepository(attestedPath, db);
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
- unregisterSealedRepository(mountDir);
104
- if (attestedPath)
105
- unregisterSealedRepository(attestedPath);
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 caseSensitive = options.caseSensitive ?? true;
168
- const matches = [];
169
- const uncovered = [];
170
- let filesSearched = 0;
185
+ const state = newSearchState();
171
186
  for (const { file, content } of files) {
172
- const raw = findOccurrences(content, term, caseSensitive);
173
- filesSearched++;
174
- if (raw.length === 0)
175
- continue;
176
- let classified = null;
177
- let language = null;
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 = await loadLanguage(file);
222
+ parser.setLanguage(language);
223
+ classified = classifyAgainstTree(parser, content, raw);
180
224
  }
181
- catch {
182
- // A grammar that will not load costs classification, not the match.
183
- language = null;
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
- if (language) {
186
- const parser = newParser();
236
+ finally {
187
237
  try {
188
- parser.setLanguage(language);
189
- classified = classifyAgainstTree(parser, content, raw);
238
+ parser.delete();
190
239
  }
191
- catch (error) {
192
- if (isWasmAbort(error))
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
- const starts = lineStarts(content);
212
- raw.forEach((match, position) => {
213
- const lineIndex = lineIndexFor(starts, match.startIndex);
214
- const lineStart = starts[lineIndex];
215
- const nextStart = starts[lineIndex + 1] ?? content.length + 1;
216
- const node = classified?.[position] ?? null;
217
- matches.push({
218
- file,
219
- line: lineIndex + 1,
220
- column: match.startIndex - lineStart + 1,
221
- startIndex: match.startIndex,
222
- endIndex: match.endIndex,
223
- lineText: content.slice(lineStart, Math.max(lineStart, nextStart - 1)).replace(/\r$/, ""),
224
- matchedText: match.matchedText,
225
- caseMatchesQuery: match.matchedText === term,
226
- withinLargerWord: isWordCharacter(content[match.startIndex - 1]) ||
227
- isWordCharacter(content[match.endIndex]),
228
- classification: node ? node.classification : classifyByFileType(file),
229
- basis: node ? "parse-node" : language ? "none" : "file-type",
230
- nodeType: node ? node.nodeType : null,
231
- enclosingText: node ? node.enclosingText : null,
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 readable = [];
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
- return { ...result, uncovered: [...uncovered, ...result.uncovered] };
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
- const noMatch = hasNoMatches(root) && root.ambiguity === undefined;
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: noMatch ? "no-match" : graphState,
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
  }