ast-mcp-server 0.13.1 → 0.14.1

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 CHANGED
@@ -6,18 +6,40 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- ## [0.13.1] - 2026-09-01
9
+ ## [0.14.1] — not yet published (2026-10-26)
10
+
11
+ ### Security
12
+
13
+ - Deduplicated `zod` in the lockfile to the patched `4.6.7` (GHSA-mrhx-c7xc-m3w5). The `@modelcontextprotocol/sdk` range `^3.25 || ^4.0` had kept a second, vulnerable `4.4.3` copy beside the direct dependency; `yarn audit` did not report it. No runtime code or advertised tool contract changes.
14
+
15
+ ## [0.14.0] — published to `latest` and `next`, registry verified (2026-10-25)
16
+
17
+ ### Changed
18
+
19
+ - Migrated schema validation to Zod 4 (`^4.6.7`). The published `tools/list` contract changes shape and Zod's validation error messages differ: tool input schemas no longer all declare `additionalProperties: false` (16 of 16 before, 1 of 16 now), shared `$ref` definitions are inlined (27 before, 0 now), the serialized `tools/list` grows from 100,923 to 126,562 bytes, integer fields declare a maximum of 9007199254740991, and UUID fields gain a `pattern`. Clients that cache or diff the advertised schemas should refresh them.
20
+
21
+ ### Security
22
+
23
+ - Upgraded `@modelcontextprotocol/sdk` to `^1.32.1` (GHSA-6qxp-vccf-f47h), `body-parser` to 2.4.0, and `zod` to 4.6.7 for the memory-exhaustion advisory, and raised the `hono` and `fast-uri` resolutions to 4.13.13 and 3.1.9. `yarn audit` reports no suggestions.
24
+
25
+ ### Release status
26
+
27
+ - npm published immutable `0.14.0` bytes with integrity `sha512-+g1GpmB9ZMd4tB/z7o75AZ4LdnqEn+MQDiRh0LDBV2j4xb9iKB8XMoiME47tW0u2HgfxWsKtQBChGxfnrz3dVw==`. Public-registry verification passed; `latest` and `next` both resolve to `0.14.0`; and npm `gitHead`, the annotated `v0.14.0` tag, and the GitHub Release target exact commit `f6e64751398f69eb3f0f1abc808f4221f7d2bc52`.
28
+ - Publish run `40232707904` reported its readback as ambiguous because of registry lag although npm had accepted the version; verification `40249874010` and promotion `40255528749` are green and authoritative.
29
+
30
+ ## [0.13.1] — published to `latest` and `next`, registry verified (2026-09-01)
10
31
 
11
32
  ### Fixed
12
33
 
13
- - The DeepSeek Harness adapter now opts into a bounded canonical-JSON text projection when a successful MCP result has structured data but no ordinary text, preserving lossless `structuredContent` while making native Agent/Session results useful and durable. The mandatory pinned-host smoke freezes the public 0.13.0 RED baseline and verifies the corrected candidate across the next model request, persisted `tool/result`, cold replay, guarded catalog, and cleanup readback.
14
- - `ast_explore` now publishes its complete object input schema while retaining all cross-field runtime rejection, and `ast_get_impact` no longer advertises one false output shape across JSON and TOON. The pinned Harness gate binds the public empty-schema RED to candidate registry/native parity and unchanged hashes for the other 14 model schemas.
34
+ - The DeepSeek Harness adapter now opts into a bounded canonical-JSON text projection when a successful MCP result has structured data but no ordinary text, preserving lossless `structuredContent` while making native Agent/Session results useful and durable. The mandatory pinned-host smoke freezes the public 0.13.0 RED baseline and verifies the released correction across the next model request, persisted `tool/result`, cold replay, guarded catalog, and cleanup readback.
35
+ - `ast_explore` now publishes its complete object input schema while retaining all cross-field runtime rejection, and `ast_get_impact` no longer advertises one false output shape across JSON and TOON. The pinned Harness gate binds the public empty-schema RED to released registry/native parity and unchanged hashes for the other 14 model schemas.
15
36
  - Timeout ownership is now explicit across the Harness, queue, and AST worker: the shipped outer timeout exceeds the complete queue/execution/margin budget, deterministic slow work returns bounded AST-owned operational errors, queued work never starts after its deadline, cancellation remains correlated across worker generations, and cleanup proves zero active work, held requests, listeners, or owned processes.
16
37
  - Harness lifecycle handling now converges deterministically through MCP removal and reconnect, rejects retired-generation effects, preserves AST `REQUEST_CANCELLED` authority separately from bridge transport observations, and completes correlated shutdown without residual state. The pinned native and rendered-GUI gates verify the `15→0→15` catalog sequence, request/header identity, secret-safe diagnostics, and ordered cleanup that still runs every owner check after an earlier cleanup failure.
17
38
 
18
39
  ### Release status
19
40
 
20
- - This is the reviewed `0.13.1` release candidate. Publication to npm `next`, public-registry verification, promotion to `latest`, tagging, and GitHub Release creation remain pending and are not claimed here.
41
+ - npm published immutable `0.13.1` bytes with integrity `sha512-jqgGoYs8fe7J+E25lZusLK4wV6sjM5n5qiWnfe1RJIxOFo1r5nbtcBr1a/fdSWTYf/37bUNkshQp86UrdBHOsA==`. Public-registry verification passed; `latest` and `next` both resolve to `0.13.1`; and npm `gitHead`, the annotated `v0.13.1` tag, and the GitHub Release target exact commit `27b80a3da169b473a3b5c5dfea69ed52903ed4c7`.
42
+ - Main CI `33460585683`, Security `33460585625`, publish `33461292810`, verification `33461385440`, and promotion retry `33461651288` are green. Initial promotion run `33461571363` changed `latest` successfully but its bounded readback timed out; the verified idempotent retry is authoritative.
21
43
 
22
44
  ## [0.13.0] — published to `latest` and `next`, registry verified (2026-08-29)
23
45
 
package/README.md CHANGED
@@ -46,8 +46,8 @@ Reads can start with a bounded file slice, a compact outline, or exact source on
46
46
  - Use `ast_explore` when the question spans discovery and evidence. Its default summary is bounded; use `detail: "context"` for selected source and `detail: "full"` for source plus compiler references.
47
47
  - Use `ast_get_outline` for a compact body-free view of a known file without source lines.
48
48
  - Use `ast_get_symbol_source` when one declaration or implementation is the required evidence.
49
- - Use `ast_get_impact` when the exact symbol is known and bounded direct/transitive compiler relationships are needed; it is read-only evidence, not a mutation plan.
50
- - Use `ast_find_test_candidates` when an exact symbol should map to conservative test candidates. It forces incoming compiler traversal, returns complete relationship paths, and never executes tests.
49
+ - Use `ast_get_impact` when the exact symbol is known and bounded direct/transitive compiler relationships are needed. Check `coverage`, `work`, `truncation`, `incomplete`, and `proven_empty`; this is read-only evidence, not a mutation plan.
50
+ - Use `ast_find_test_candidates` when an exact symbol should map to conservative tests. It admits only complete incoming `reference`, `import`, `export`, `extends`, `implements`, and `call` evidence, returns whole paths, and never executes tests.
51
51
 
52
52
  `snapshot_state: "fresh"` means that the returned file bytes match the synchronized compiler snapshot. The separate `freshness` object describes the project/session state and preserves causes such as source changes or watcher failure. Neither field means that the project has zero TypeScript diagnostics; use `ast_get_diagnostics` for compiler errors and warnings.
53
53
 
@@ -64,7 +64,20 @@ The server exposes evidence labels instead of collapsing every result into an un
64
64
 
65
65
  Freshness is orthogonal to TypeScript diagnostics. `fresh` means the evidence matches the synchronized snapshot; `pending`, `rebuilding`, `stale`, or `degraded` means the response must not be presented as current compiler evidence. Read tools expose the state, causes (`source_change`, `config_change`, `index_failure`, `watcher_failure`, or `compiler_rebuild`), and bounded `checked_at` timestamp. `ast_get_impact` refuses non-fresh compiler relationships. `ast_explore` returns the state together with `completeness`, `unresolved`, `budget`, and `truncation` metadata rather than silently dropping evidence.
66
66
 
67
- All reads are budgeted. Callers control pagination and, where applicable, `max_bytes`, `reference_limit`, `max_depth`, `max_nodes`, and `max_edges`; responses report the effective limits and whether a record, byte, depth, edge, invocation, or serialization limit truncated the result. A truncated or unresolved result is incomplete evidence, not an empty negative result. `ast_find_test_candidates` follows the same rule: it accepts only fresh, exact compiler-backed impact, emits direct/transitive evidence and bounded relationship IDs, and never executes tests or guesses from filenames alone. Only a complete authoritative traversal may return `candidates: []` with `proven_empty: true`.
67
+ All reads are budgeted. Callers control pagination and, where applicable, `max_bytes`, `reference_limit`, `max_depth`, `max_nodes`, and `max_edges`; responses report effective limits. A bounded stop is incomplete evidence, not an empty negative result.
68
+
69
+ Impact has two independent completeness channels:
70
+
71
+ | Channel | Evidence | Incomplete when |
72
+ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
73
+ | Semantic coverage | One canonical cell per requested kind, direction, and endpoint class: `not_applicable`, `completed`, `unsupported`, or `unfinished` | Any applicable cell is `unsupported` or `unfinished` |
74
+ | Traversal/work | `truncation` plus `work.work_limit_reached` and its counters | A depth/node/edge/work bound prevents completion |
75
+
76
+ `incomplete` is true when either channel is incomplete. `proven_empty` is true only when there are zero edges, every applicable cell completed, and no bound was exhausted. The public seven-kind/default impact request includes `contains`; because no scoped `contains` producer exists, applicable default or explicit containment is `unsupported`, so the result is incomplete even when traversal was not truncated.
77
+
78
+ `ast_find_test_candidates` deliberately excludes `contains` and fixes the admission scope to six incoming kinds: `reference`, `import`, `export`, `extends`, `implements`, and `call`. It returns `INCOMPLETE_EVIDENCE` before pagination for stale, inexact, unresolved, truncated, work-exhausted, unsupported, or unfinished evidence. Only complete six-kind authority may return `candidates: []` with `completeness.proven_empty: true`; pagination slices deterministic candidates while each proof and the unpaginated `coverage`, `work`, and traversal metadata stay whole.
79
+
80
+ These fields are additive: existing edge shapes, kind strings, defaults, and public errors remain stable. MCP `ast_get_impact` JSON and TOON represent the same logical coverage/work result; candidate MCP output remains canonical JSON, while read-only batch output may encode that same candidate result as TOON. Property, element, dynamic, computed-key, and external-alternative dispatch remains edge-free and unfinished—this contract does not certify the deferred #219 or #220 classifiers.
68
81
 
69
82
  ## Why this helps
70
83
 
@@ -84,7 +97,7 @@ The included batch benchmark records a 50% reduction in model round-trips and a
84
97
 
85
98
  ## Supported environment and trust boundary
86
99
 
87
- Published v0.12.0 requires Node.js `>=22.13.0`; its immutable evidence matrix targets exact Node.js 22.13.0 and the current Node.js 24 line. Managed setup-file publication additionally requires GNU coreutils 9.7 `mv` supporting `--update=none-fail`, `--exchange`, `--no-copy`, and `--no-target-directory`, GNU coreutils `ln -L -T`, procfs descriptor paths at `/proc/self/fd`, and `O_DIRECTORY`/`O_NOFOLLOW`. Other Linux architectures or systems without those filesystem primitives, macOS, and Windows remain unverified.
100
+ Published v0.12.0 requires Node.js `>=22.13.0`; its immutable evidence matrix targets exact Node.js 22.13.0 and the current Node.js 24 line. Structural apply and managed setup-file publication are verified only on Linux x64 with GNU coreutils 9.7 `mv` supporting `--update=none-fail`, `--exchange`, `--no-copy`, and `--no-target-directory`, GNU coreutils `ln -L -T`, procfs descriptor paths at `/proc/self/fd`, `O_DIRECTORY`/`O_NOFOLLOW`, and a destination filesystem that passes the owned link/exchange identity probe. A failed or denied primitive blocks mutation before source effects; there is no rename, copy/delete, or pathname-only fallback. Other Linux architectures or systems without this complete matrix, macOS, and Windows remain unverified.
88
101
 
89
102
  This is a local stdio server. It runs with the invoking user's filesystem permissions, and clients may request any `project_root` that user can access. It does not provide HTTP authentication, sandboxing, tenant isolation, or a remote-service security boundary. Remote, untrusted, and multi-tenant operation is unsupported.
90
103
 
@@ -340,7 +353,7 @@ For a local candidate, pack and install its tarball instead:
340
353
 
341
354
  ```bash
342
355
  yarn pack --out ast-mcp-server-%v.tgz
343
- dsh plugin --profile web add ./ast-mcp-server-0.13.1.tgz
356
+ dsh plugin --profile web add ./ast-mcp-server-0.14.1.tgz
344
357
  ```
345
358
 
346
359
  The first supported surface is **reads + prepare + preview**. Every apply path is
@@ -529,12 +542,13 @@ Apply loads the exact retained postimages, requires the separately supplied revi
529
542
 
530
543
  ### Guarantee boundary
531
544
 
532
- The server does **not** claim a filesystem-wide transaction:
545
+ The server does **not** claim a filesystem-wide transaction or safety against a continuously mutating writer:
533
546
 
534
- - Replacement is atomic per file where the local filesystem provides atomic rename.
535
- - A multi-file apply has a short interval in which some replacements may already be visible.
536
- - Rollback is best effort and refuses to overwrite a file changed by another writer after replacement.
537
- - MCP and CLI apply share a fail-closed filesystem lock keyed by canonical `tsconfig.json` when they use the same state directory. It does not coordinate editors, NFS writers, or hostile external processes.
547
+ - Each replacement exchanges and authenticates one staged/destination pair. Multi-file apply remains sequential, so earlier postimages may be visible before later files commit.
548
+ - Reverse rollback restores only an exact operation-owned pair. Lost ownership preserves the observable entries and returns `AMBIGUOUS_APPLY`; it never falls back to pathname replacement.
549
+ - Creation is no-clobber. Once a created inode is published, a later failure cannot safely infer unlink ownership, so the destination and hidden stage are preserved and rollback fails closed as ambiguous.
550
+ - MCP and CLI apply share a fail-closed filesystem lock keyed by canonical `tsconfig.json` when they use the same state directory. It does not coordinate editors, NFS writers, writers using another configuration, or hostile external processes.
551
+ - Deterministic promise barriers prove the tested publication/rollback interleavings. They are a threat-boundary test seam, not a global atomicity or continuous-writer guarantee.
538
552
  - Receipt persistence runs before that lock is released. If receipt storage fails after source replacement, apply exits non-zero and reports that verified postimages may be present; retry recovers the receipt only when the complete workspace exactly matches the reviewed post-workspace fingerprint.
539
553
  - A hard process crash can leave a stale lock. Remove it only after inspecting its metadata and proving no apply is running; exact complete postimages can then recover the receipt, while partial or divergent state remains a conflict.
540
554
  - Source encoding support is UTF-8, with or without BOM. Unsupported encodings are rejected.
package/cordis.patch.yml CHANGED
@@ -3,7 +3,7 @@
3
3
  # Mounts the packaged `ast-mcp-server` stdio command through the official
4
4
  # `@deepseek-ai/dsh-mcp-client` bridge. Harness does not resolve MCP binaries
5
5
  # from PATH, so the entrypoint is resolved relative to the profile directory
6
- # (`baseUrl`), where `dsh plugin --profile <name> add ./ast-mcp-server-0.13.1.tgz`
6
+ # (`baseUrl`), where `dsh plugin --profile <name> add ./ast-mcp-server-0.14.1.tgz`
7
7
  # installs a locally packed package as a dependency.
8
8
  #
9
9
  # First-slice surface: reads + prepare + preview. Every apply path is denied
@@ -20,7 +20,7 @@ const BatchStepSchema = z
20
20
  .object({
21
21
  id: z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,63}$/),
22
22
  tool: BatchToolSchema,
23
- input: z.record(z.unknown()).default({}),
23
+ input: z.record(z.string(), z.unknown()).default({}),
24
24
  foreach: z
25
25
  .object({ $ref: z.string().min(1) })
26
26
  .strict()
@@ -0,0 +1,181 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { constants } from "node:fs";
4
+ import { lstat, mkdir, open, readFile, rm, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ function sameStat(left, right) {
7
+ const fields = ["dev", "ino", "size", "mode", "mtimeNs", "ctimeNs"];
8
+ return fields.every((field) => left[field] === right[field]);
9
+ }
10
+ export async function snapshotHeldFile(handle) {
11
+ const before = await handle.stat({ bigint: true });
12
+ if (!before.isFile() || before.size > BigInt(Number.MAX_SAFE_INTEGER))
13
+ throw new Error("Publication descriptor cannot be authenticated");
14
+ const bytes = await readFile(`/proc/self/fd/${handle.fd}`);
15
+ const after = await handle.stat({ bigint: true });
16
+ if (!after.isFile() || !sameStat(before, after))
17
+ throw new Error("Publication descriptor changed during read");
18
+ return {
19
+ dev: after.dev.toString(),
20
+ ino: after.ino.toString(),
21
+ sha256: createHash("sha256").update(bytes).digest("hex"),
22
+ mode: Number(after.mode & 511n),
23
+ };
24
+ }
25
+ function equal(left, right) {
26
+ return (left !== undefined &&
27
+ right !== undefined &&
28
+ left.dev === right.dev &&
29
+ left.ino === right.ino &&
30
+ left.sha256 === right.sha256 &&
31
+ left.mode === right.mode);
32
+ }
33
+ async function entry(directory, basename) {
34
+ const target = `/proc/self/fd/${directory.fd}/${basename}`;
35
+ let handle;
36
+ try {
37
+ handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
38
+ const snapshot = await snapshotHeldFile(handle);
39
+ const current = await lstat(target, { bigint: true });
40
+ return current.dev.toString() === snapshot.dev && current.ino.toString() === snapshot.ino
41
+ ? snapshot
42
+ : undefined;
43
+ }
44
+ catch (error) {
45
+ if (error.code === "ENOENT")
46
+ return undefined;
47
+ throw error;
48
+ }
49
+ finally {
50
+ await handle?.close();
51
+ }
52
+ }
53
+ export async function runPublicationPrimitive(executable, args, fds) {
54
+ await new Promise((resolve, reject) => {
55
+ const child = spawn(executable, args, {
56
+ env: { LANG: "C", LC_ALL: "C", PATH: "/usr/bin:/bin" },
57
+ shell: false,
58
+ stdio: ["ignore", "ignore", "pipe", ...fds],
59
+ windowsHide: true,
60
+ });
61
+ let failure;
62
+ let stderrBytes = 0;
63
+ const fail = (message, cause) => {
64
+ if (failure)
65
+ return;
66
+ failure = new Error(message, cause === undefined ? undefined : { cause });
67
+ child.kill("SIGKILL");
68
+ };
69
+ const timeout = setTimeout(() => fail("Publication primitive timed out"), 5_000);
70
+ child.stderr.on("data", (chunk) => {
71
+ stderrBytes += chunk.byteLength;
72
+ if (stderrBytes > 4_096)
73
+ fail("Publication primitive stderr exceeded limit");
74
+ });
75
+ child.on("error", (error) => fail("Publication primitive unavailable", error));
76
+ child.on("close", (code, signal) => {
77
+ clearTimeout(timeout);
78
+ if (failure)
79
+ reject(failure);
80
+ else if (code === 0 && signal === null)
81
+ resolve();
82
+ else
83
+ reject(new Error(`Publication primitive failed (${signal ?? code ?? "unknown"})`));
84
+ });
85
+ });
86
+ }
87
+ export const exchangeDirectoryEntries = (directory, left, right) => runPublicationPrimitive("/usr/bin/mv", ["--exchange", "--no-copy", "-T", "--", `/proc/self/fd/3/${left}`, `/proc/self/fd/3/${right}`], [directory.fd]);
88
+ export const linkHeldFile = (source, directory, basename) => runPublicationPrimitive("/usr/bin/ln", ["-L", "-T", "--", "/proc/self/fd/3", `/proc/self/fd/4/${basename}`], [source.fd, directory.fd]);
89
+ export async function removeOwnedPublicationEntry(directory, basename, expected) {
90
+ if (!equal(await entry(directory, basename), expected))
91
+ return false;
92
+ await rm(`/proc/self/fd/${directory.fd}/${basename}`);
93
+ return (await entry(directory, basename)) === undefined;
94
+ }
95
+ export async function rollbackOwnedCommit(token, hooks = {}) {
96
+ if (token.kind === "creation")
97
+ return { state: "ambiguous", phase: "rollback" };
98
+ await hooks.beforeRollback?.();
99
+ if (!equal(await entry(token.directory, token.destinationBasename), token.destinationIdentity) ||
100
+ !equal(await entry(token.directory, token.displacedBasename), token.displacedIdentity))
101
+ return { state: "ambiguous", phase: "rollback" };
102
+ await exchangeDirectoryEntries(token.directory, token.displacedBasename, token.destinationBasename).catch(() => undefined);
103
+ const restored = equal(await entry(token.directory, token.destinationBasename), token.displacedIdentity) &&
104
+ equal(await entry(token.directory, token.displacedBasename), token.destinationIdentity);
105
+ if (!restored)
106
+ return { state: "ambiguous", phase: "rollback" };
107
+ return { state: "rolled_back", reason: "conflict" };
108
+ }
109
+ export async function publishAuthenticated(plan) {
110
+ if (process.platform !== "linux")
111
+ return { state: "pre_effect", reason: "unsupported" };
112
+ await plan.beforePublish?.();
113
+ if (plan.kind === "creation") {
114
+ await linkHeldFile(plan.stage, plan.directory, plan.destinationBasename).catch(() => undefined);
115
+ const destination = await entry(plan.directory, plan.destinationBasename);
116
+ if (equal(destination, plan.stageIdentity))
117
+ return {
118
+ state: "committed",
119
+ token: {
120
+ kind: "creation",
121
+ directory: plan.directory,
122
+ destinationBasename: plan.destinationBasename,
123
+ destinationIdentity: plan.stageIdentity,
124
+ },
125
+ };
126
+ if (destination?.dev === plan.stageIdentity.dev && destination.ino === plan.stageIdentity.ino)
127
+ return { state: "ambiguous", phase: "commit" };
128
+ return { state: "pre_effect", reason: destination ? "conflict" : "unsupported" };
129
+ }
130
+ await exchangeDirectoryEntries(plan.directory, plan.stageBasename, plan.destinationBasename).catch(() => undefined);
131
+ const destination = await entry(plan.directory, plan.destinationBasename);
132
+ const displaced = await entry(plan.directory, plan.stageBasename);
133
+ if (equal(destination, plan.stageIdentity) && displaced) {
134
+ const token = {
135
+ kind: "replacement",
136
+ directory: plan.directory,
137
+ destinationBasename: plan.destinationBasename,
138
+ displacedBasename: plan.stageBasename,
139
+ destinationIdentity: destination,
140
+ displacedIdentity: displaced,
141
+ };
142
+ const held = plan.preimage && (await snapshotHeldFile(plan.preimage));
143
+ if (equal(displaced, plan.preimageIdentity) && equal(held, plan.preimageIdentity))
144
+ return { state: "committed", token };
145
+ return rollbackOwnedCommit(token, plan);
146
+ }
147
+ if (equal(destination, plan.preimageIdentity) && equal(displaced, plan.stageIdentity))
148
+ return { state: "pre_effect", reason: "unsupported" };
149
+ return { state: "ambiguous", phase: "commit" };
150
+ }
151
+ export async function probePublicationCapability(parent) {
152
+ const probe = path.join(parent, `.ast-publication-probe-${randomUUID()}`);
153
+ const handles = [];
154
+ try {
155
+ await mkdir(probe);
156
+ const directory = await open(probe, "r");
157
+ handles.push(directory);
158
+ for (const [name, bytes] of [
159
+ ["stage", "stage"],
160
+ ["replacement", "replacement"],
161
+ ])
162
+ await writeFile(path.join(probe, name), bytes);
163
+ const stage = await open(path.join(probe, "stage"), "r");
164
+ handles.push(stage);
165
+ const replacement = await open(path.join(probe, "replacement"), "r");
166
+ handles.push(replacement);
167
+ const stageIdentity = await snapshotHeldFile(stage);
168
+ const replacementIdentity = await snapshotHeldFile(replacement);
169
+ await linkHeldFile(stage, directory, "created");
170
+ if (!equal(await entry(directory, "created"), stageIdentity))
171
+ throw new Error("Link unsupported");
172
+ await exchangeDirectoryEntries(directory, "replacement", "created");
173
+ if (!equal(await entry(directory, "created"), replacementIdentity) ||
174
+ !equal(await entry(directory, "replacement"), stageIdentity))
175
+ throw new Error("Publication exchange unsupported");
176
+ }
177
+ finally {
178
+ await Promise.allSettled(handles.map((handle) => handle.close()));
179
+ await rm(probe, { recursive: true, force: true });
180
+ }
181
+ }
@@ -17,11 +17,7 @@ export function planCallSpines(root, edges, options) {
17
17
  const maxEdges = assertLimit(options.max_edges, 1, "max_edges");
18
18
  const accepted = edges.filter(exactCall);
19
19
  const freshness = options.freshness?.state ?? edges[0]?.freshness.state;
20
- const authorityState = freshness !== "fresh" || accepted.length !== edges.length
21
- ? "untrusted"
22
- : options.discovery_complete === false
23
- ? "incomplete"
24
- : "authoritative";
20
+ const trusted = freshness === "fresh" && accepted.length === edges.length;
25
21
  const reasons = new Set();
26
22
  const rootKey = endpointKey(root);
27
23
  const initial = { endpoint: root, endpoints: [root], relationshipIds: [], depth: 0 };
@@ -82,6 +78,15 @@ export function planCallSpines(root, edges, options) {
82
78
  endpoints: record.endpoints,
83
79
  relationship_ids: record.relationshipIds,
84
80
  }));
81
+ const reached = new Set(records.keys());
82
+ const relevantGap = (options.unfinished_gaps ?? []).some((gap) => options.direction === "outgoing"
83
+ ? reached.has(endpointKey(gap.source))
84
+ : gap.alternatives.some((alternative) => reached.has(endpointKey(alternative))));
85
+ const authorityState = !trusted
86
+ ? "untrusted"
87
+ : options.discovery_complete === false || relevantGap
88
+ ? "incomplete"
89
+ : "authoritative";
85
90
  const incomplete = authorityState !== "authoritative" || reasons.size > 0;
86
91
  return {
87
92
  root,
@@ -167,7 +167,8 @@ export async function buildExploreContext(context, request, requestContext = NO_
167
167
  max_depth: request.callSpines.maxDepth,
168
168
  max_nodes: request.callSpines.maxNodes,
169
169
  max_edges: request.callSpines.maxEdges,
170
- discovery_complete: !projected.incomplete,
170
+ discovery_complete: !projected.bounded_incomplete,
171
+ unfinished_gaps: projected.unfinished_gaps,
171
172
  freshness,
172
173
  });
173
174
  }