ast-mcp-server 0.13.0 → 0.14.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.
@@ -0,0 +1,335 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { appendFileSync, readFileSync, statSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { PUBLIC_ERROR_CODES, sanitizePublicText } from "./public-errors.js";
5
+ import { parseH03FixtureDescriptor, parseH05FixtureDescriptor, } from "./runtime-policy.js";
6
+ const FIXTURE_ID = /^[A-Za-z0-9_-]{1,64}$/;
7
+ export class H03TimeoutFixtureError extends Error {
8
+ name = "H03TimeoutFixtureError";
9
+ code = "STALE_GENERATION";
10
+ }
11
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
12
+ /** Closed test-only lifecycle controller used by H-05 through ast_get_project_status. */
13
+ export class H05LifecycleFixtureController {
14
+ #descriptor;
15
+ #generationAbort = new AbortController();
16
+ #gates = new Map();
17
+ #events = [];
18
+ #active = 0;
19
+ #abortListeners = 0;
20
+ #staleSettlements = 0;
21
+ // prettier-ignore
22
+ constructor(descriptor) { if (!statSync(descriptor.controlDirectory, { throwIfNoEntry: false })?.isDirectory())
23
+ throw new Error("H-05 fixture control path must be an existing directory."); this.#descriptor = Object.freeze({ ...descriptor }); }
24
+ // prettier-ignore
25
+ capture(command) { if (Object.keys(command).sort().join(",") !== "callId,correlationId,fixtureId" || !FIXTURE_ID.test(command.callId) || !FIXTURE_ID.test(command.fixtureId) || !UUID.test(command.correlationId))
26
+ throw new Error("Invalid H-05 fixture command."); return Object.freeze({ ...command, generation: this.#descriptor.generation, ownerToken: this.#descriptor.ownerToken }); }
27
+ // prettier-ignore
28
+ hold(identity) { this.#assertCurrent(identity); const key = this.#key(identity); if (this.#gates.has(key))
29
+ throw new Error("Invalid or duplicate H-05 fixture id."); let release; const promise = new Promise((resolve) => (release = resolve)); this.#gates.set(key, { promise, release }); }
30
+ // prettier-ignore
31
+ advance(descriptor) { if (descriptor.generation <= this.#descriptor.generation)
32
+ throw new Error("H-05 fixture generation must increase."); this.#descriptor = Object.freeze({ ...descriptor }); this.#generationAbort.abort(); this.#generationAbort = new AbortController(); for (const gate of this.#gates.values())
33
+ gate.release(); this.#gates.clear(); }
34
+ // prettier-ignore
35
+ async run(identity, context, operation) {
36
+ this.#assertIdentity(identity);
37
+ const eventIdentity = { callId: identity.callId, correlationId: identity.correlationId, fixtureId: identity.fixtureId, generation: identity.generation, ownerToken: identity.ownerToken };
38
+ const generationSignal = this.#generationAbort.signal, signal = AbortSignal.any([context.signal, generationSignal]);
39
+ const checkpoint = () => { if (!this.accepts(identity) || generationSignal.aborted)
40
+ throw new H03TimeoutFixtureError(); context.checkpoint(); };
41
+ const hooked = Object.freeze({ get sequence() { return context.sequence; }, get phase() { return context.phase; }, signal, checkpoint, markExecuting: () => context.markExecuting(), enterCompletionCritical: () => { checkpoint(); context.enterCompletionCritical(); } });
42
+ this.#active += 1;
43
+ this.#emit({ ...eventIdentity, phase: "started" });
44
+ try {
45
+ const gate = this.#gates.get(this.#key(identity));
46
+ if (gate)
47
+ await this.#awaitGate(identity, gate, hooked);
48
+ checkpoint();
49
+ const result = await operation(hooked);
50
+ checkpoint();
51
+ this.#emit({ ...eventIdentity, phase: "terminal", outcome: "succeeded" });
52
+ return result;
53
+ }
54
+ catch (error) {
55
+ const code = error?.code, stale = !this.accepts(identity) || generationSignal.aborted, deadline = code === "OPERATION_DEADLINE_EXCEEDED", cancelled = code === "REQUEST_CANCELLED" || context.signal.aborted;
56
+ if (stale)
57
+ this.#staleSettlements += 1;
58
+ this.#emit({ ...eventIdentity, phase: "terminal", outcome: stale ? "STALE_GENERATION" : deadline ? code : cancelled ? "REQUEST_CANCELLED" : "failed" });
59
+ if (stale)
60
+ this.#staleSettlements -= 1;
61
+ throw stale ? new H03TimeoutFixtureError() : error;
62
+ }
63
+ finally {
64
+ this.#active -= 1;
65
+ }
66
+ }
67
+ // prettier-ignore
68
+ drainEvents() { const events = Object.freeze(this.#events); this.#events = []; return events; }
69
+ // prettier-ignore
70
+ snapshot() { return Object.freeze({ active: this.#active, held: this.#gates.size, abortListeners: this.#abortListeners, timers: 0, staleSettlements: this.#staleSettlements }); }
71
+ // prettier-ignore
72
+ async #awaitGate(identity, gate, context) { let rejectAbort; const aborted = new Promise((_, reject) => (rejectAbort = reject)); const abort = () => { this.#gates.delete(this.#key(identity)); try {
73
+ context.checkpoint();
74
+ }
75
+ catch (error) {
76
+ rejectAbort(error);
77
+ } }; context.signal.addEventListener("abort", abort, { once: true }); this.#abortListeners += 1; try {
78
+ if (context.signal.aborted)
79
+ abort();
80
+ await Promise.race([gate.promise, aborted]);
81
+ }
82
+ finally {
83
+ context.signal.removeEventListener("abort", abort);
84
+ this.#abortListeners -= 1;
85
+ } }
86
+ // prettier-ignore
87
+ #emit(event) { const normalized = Object.freeze(Object.fromEntries(Object.entries(event).filter(([, value]) => value !== undefined))); this.#events.push(normalized); appendFileSync(path.join(this.#descriptor.controlDirectory, "events.jsonl"), `${JSON.stringify(normalized)}\n`, { encoding: "utf8", flag: "a" }); }
88
+ // prettier-ignore
89
+ accepts(identity) { return identity.generation === this.#descriptor.generation && identity.ownerToken === this.#descriptor.ownerToken; }
90
+ // prettier-ignore
91
+ #assertCurrent(identity) { this.#assertIdentity(identity); if (!this.accepts(identity))
92
+ throw new H03TimeoutFixtureError(); }
93
+ // prettier-ignore
94
+ #assertIdentity(identity) { if (!Object.isFrozen(identity) || !FIXTURE_ID.test(identity.callId) || !FIXTURE_ID.test(identity.fixtureId) || !UUID.test(identity.correlationId))
95
+ throw new Error("Invalid H-05 lifecycle identity."); }
96
+ // prettier-ignore
97
+ #key(identity) { return `${identity.generation}:${identity.fixtureId}`; }
98
+ }
99
+ export class H03TimeoutFixtureController {
100
+ #descriptor;
101
+ #gates = new Map();
102
+ #events = [];
103
+ #generation;
104
+ #generationAbort = new AbortController();
105
+ #active = 0;
106
+ #abortListeners = 0;
107
+ #staleSettlements = 0;
108
+ constructor(descriptor) {
109
+ const directory = statSync(descriptor.controlDirectory, { throwIfNoEntry: false });
110
+ if (!directory?.isDirectory())
111
+ throw new Error("H-03 fixture control path must be an existing directory.");
112
+ this.#descriptor = descriptor;
113
+ this.#generation = descriptor.generation;
114
+ }
115
+ hold(fixtureId) {
116
+ this.#assertId(fixtureId);
117
+ const key = this.#key(fixtureId, this.#generation);
118
+ if (this.#gates.has(key))
119
+ throw new Error("Invalid or duplicate H-03 fixture id.");
120
+ let release;
121
+ const promise = new Promise((resolve) => (release = resolve));
122
+ this.#gates.set(key, { promise, release });
123
+ }
124
+ release(fixtureId, generation) {
125
+ if (!FIXTURE_ID.test(fixtureId) || generation !== this.#generation)
126
+ return false;
127
+ const key = this.#key(fixtureId, generation);
128
+ const gate = this.#gates.get(key);
129
+ if (!gate)
130
+ return false;
131
+ this.#gates.delete(key);
132
+ gate.release();
133
+ return true;
134
+ }
135
+ advanceGeneration(generation) {
136
+ if (!Number.isSafeInteger(generation) || generation <= this.#generation)
137
+ throw new Error("H-03 fixture generation must increase.");
138
+ this.#generation = generation;
139
+ this.#generationAbort.abort();
140
+ this.#generationAbort = new AbortController();
141
+ for (const gate of this.#gates.values())
142
+ gate.release();
143
+ this.#gates.clear();
144
+ }
145
+ async run(fixtureId, generation, context, operation, callId = fixtureId) {
146
+ this.#assertId(fixtureId);
147
+ this.#assertId(callId);
148
+ if (generation !== this.#generation)
149
+ return this.#stale(fixtureId, generation, callId);
150
+ const generationSignal = this.#generationAbort.signal;
151
+ const signal = AbortSignal.any([context.signal, generationSignal]);
152
+ const hooked = this.#context(context, signal, generationSignal, fixtureId, generation, callId);
153
+ this.#active += 1;
154
+ this.#emit({ callId, fixtureId, generation, nonce: this.#descriptor.nonce, phase: "started" });
155
+ try {
156
+ const gate = this.#gates.get(this.#key(fixtureId, generation));
157
+ if (gate)
158
+ await this.#awaitGate(this.#key(fixtureId, generation), gate, hooked);
159
+ hooked.checkpoint();
160
+ const result = await operation(hooked);
161
+ hooked.checkpoint();
162
+ return result;
163
+ }
164
+ finally {
165
+ this.#active -= 1;
166
+ this.#emit({
167
+ callId,
168
+ fixtureId,
169
+ generation,
170
+ nonce: this.#descriptor.nonce,
171
+ phase: "terminal",
172
+ });
173
+ }
174
+ }
175
+ drainEvents() {
176
+ const events = Object.freeze(this.#events);
177
+ this.#events = [];
178
+ return events;
179
+ }
180
+ snapshot() {
181
+ return Object.freeze({
182
+ active: this.#active,
183
+ held: this.#gates.size,
184
+ abortListeners: this.#abortListeners,
185
+ staleSettlements: this.#staleSettlements,
186
+ });
187
+ }
188
+ #context(context, signal, generationSignal, fixtureId, generation, callId) {
189
+ const checkpoint = () => {
190
+ if (generationSignal.aborted || generation !== this.#generation)
191
+ this.#stale(fixtureId, generation, callId);
192
+ context.checkpoint();
193
+ };
194
+ return {
195
+ get sequence() {
196
+ return context.sequence;
197
+ },
198
+ get phase() {
199
+ return context.phase;
200
+ },
201
+ signal,
202
+ checkpoint,
203
+ markExecuting: () => context.markExecuting(),
204
+ enterCompletionCritical: () => {
205
+ checkpoint();
206
+ context.enterCompletionCritical();
207
+ },
208
+ };
209
+ }
210
+ async #awaitGate(key, gate, context) {
211
+ let rejectAbort;
212
+ const aborted = new Promise((_, reject) => (rejectAbort = reject));
213
+ const abort = () => {
214
+ if (this.#gates.get(key) === gate)
215
+ this.#gates.delete(key);
216
+ try {
217
+ context.checkpoint();
218
+ }
219
+ catch (error) {
220
+ rejectAbort(error);
221
+ }
222
+ };
223
+ context.signal.addEventListener("abort", abort, { once: true });
224
+ this.#abortListeners += 1;
225
+ try {
226
+ if (context.signal.aborted)
227
+ abort();
228
+ await Promise.race([gate.promise, aborted]);
229
+ }
230
+ finally {
231
+ context.signal.removeEventListener("abort", abort);
232
+ this.#abortListeners -= 1;
233
+ }
234
+ }
235
+ #stale(fixtureId, generation, callId) {
236
+ this.#staleSettlements += 1;
237
+ this.#emit({ callId, fixtureId, generation, nonce: this.#descriptor.nonce, phase: "stale" });
238
+ throw new H03TimeoutFixtureError();
239
+ }
240
+ #emit(event) {
241
+ this.#events.push(Object.freeze(event));
242
+ appendFileSync(path.join(this.#descriptor.controlDirectory, "events.jsonl"), `${JSON.stringify(event)}\n`, { encoding: "utf8", flag: "a" });
243
+ }
244
+ #assertId(fixtureId) {
245
+ if (!FIXTURE_ID.test(fixtureId))
246
+ throw new Error("Invalid H-03 fixture id.");
247
+ }
248
+ #key(fixtureId, generation) {
249
+ return `${generation}:${fixtureId}`;
250
+ }
251
+ }
252
+ let configuredH05Controller;
253
+ let configuredH05Descriptor;
254
+ // Capture the closed command once before scheduler enqueue; mutable files cannot relabel it.
255
+ // prettier-ignore
256
+ export function captureConfiguredH05Command() { const raw = process.env.AST_H05_FIXTURE; if (raw === undefined)
257
+ return undefined; const descriptor = parseH05FixtureDescriptor(raw); if (!descriptor)
258
+ throw new Error("Invalid H-05 fixture descriptor."); if (configuredH05Descriptor) {
259
+ const sameGeneration = descriptor.generation === configuredH05Descriptor.generation, sameIdentity = descriptor.controlDirectory === configuredH05Descriptor.controlDirectory && descriptor.nonce === configuredH05Descriptor.nonce && descriptor.ownerToken === configuredH05Descriptor.ownerToken;
260
+ if (sameGeneration && !sameIdentity)
261
+ throw new Error("H-05 fixture descriptor drift.");
262
+ if (descriptor.generation < configuredH05Descriptor.generation)
263
+ throw new Error("H-05 fixture generation regressed.");
264
+ if (descriptor.generation > configuredH05Descriptor.generation) {
265
+ configuredH05Controller?.advance({ ...configuredH05Descriptor, generation: descriptor.generation });
266
+ configuredH05Controller = undefined;
267
+ configuredH05Descriptor = descriptor;
268
+ const replacement = new H05LifecycleFixtureController(descriptor);
269
+ configuredH05Controller = replacement;
270
+ }
271
+ }
272
+ else {
273
+ configuredH05Controller = new H05LifecycleFixtureController(descriptor);
274
+ configuredH05Descriptor = descriptor;
275
+ } if (!configuredH05Controller)
276
+ throw new Error("H-05 fixture generation is retired."); const value = JSON.parse(readFileSync(path.join(descriptor.controlDirectory, "command.json"), "utf8")); if (Object.keys(value).sort().join(",") !== "callId,correlationId,fixtureId,mode,nonce,ownerToken" || value.nonce !== descriptor.nonce || value.ownerToken !== descriptor.ownerToken || !["hold", "pass", "readback"].includes(value.mode))
277
+ throw new Error("Invalid H-05 fixture command."); const command = Object.freeze({ ...value }); const captured = configuredH05Controller.capture({ callId: command.callId, correlationId: command.correlationId, fixtureId: command.fixtureId }); return Object.freeze({ controller: configuredH05Controller, descriptor, command, captured }); }
278
+ // prettier-ignore
279
+ export async function runConfiguredH05Fixture(context, operation, configured) { const { controller, descriptor, command, captured } = configured; if (!controller.accepts(captured))
280
+ throw new H03TimeoutFixtureError(); if (command.mode === "readback") {
281
+ const snapshot = controller.snapshot(), eventsDrained = controller.drainEvents().length;
282
+ appendFileSync(path.join(descriptor.controlDirectory, "events.jsonl"), `${JSON.stringify({ ...snapshot, callId: captured.callId, correlationId: captured.correlationId, eventsDrained, fixtureId: captured.fixtureId, generation: captured.generation, phase: "readback" })}\n`);
283
+ const result = await operation(context);
284
+ if (!controller.accepts(captured))
285
+ throw new H03TimeoutFixtureError();
286
+ context.checkpoint();
287
+ return result;
288
+ } if (command.mode === "hold")
289
+ controller.hold(captured); return controller.run(captured, context, operation); }
290
+ let configuredController;
291
+ let configuredDescriptor;
292
+ const errorCommands = new WeakMap();
293
+ // Compact closed test-only wiring preserves PR3's hard review budget.
294
+ // prettier-ignore
295
+ function configuredFixture() { const descriptor = parseH03FixtureDescriptor(process.env.AST_H03_FIXTURE); if (!descriptor)
296
+ return undefined; if (!configuredController || configuredDescriptor?.generation !== descriptor.generation) {
297
+ configuredDescriptor = descriptor;
298
+ configuredController = new H03TimeoutFixtureController(descriptor);
299
+ } return { controller: configuredController, descriptor }; }
300
+ // prettier-ignore
301
+ function readCommand(descriptor) { const value = JSON.parse(readFileSync(path.join(descriptor.controlDirectory, "command.json"), "utf8")); if (Object.keys(value).sort().join(",") !== "callId,fixtureId,mode,nonce" || typeof value.callId !== "string" || !FIXTURE_ID.test(value.callId) || typeof value.fixtureId !== "string" || !FIXTURE_ID.test(value.fixtureId) || value.nonce !== descriptor.nonce || !["hold", "pass", "readback"].includes(value.mode))
302
+ throw new Error("Invalid H-03 fixture command."); return value; }
303
+ // Capture once before scheduler enqueue; later command.json writes cannot relabel this request.
304
+ // prettier-ignore
305
+ export function captureConfiguredH03Command() { const fixture = configuredFixture(); return fixture && Object.freeze({ ...fixture, command: readCommand(fixture.descriptor) }); }
306
+ // prettier-ignore
307
+ export function bindConfiguredH03Error(error, captured) { if (captured && typeof error === "object" && error !== null)
308
+ errorCommands.set(error, captured); }
309
+ export function configuredH05CorrelationId(error) {
310
+ const configured = typeof error === "object" && error !== null ? errorCommands.get(error) : undefined;
311
+ return configured && "captured" in configured ? configured.captured.correlationId : undefined;
312
+ }
313
+ // prettier-ignore
314
+ export async function runConfiguredH03Fixture(context, operation, captured) { const { controller, descriptor, command } = captured; if (command.mode === "readback") {
315
+ const snapshot = controller.snapshot(), eventsDrained = controller.drainEvents().length;
316
+ appendFileSync(path.join(descriptor.controlDirectory, "events.jsonl"), `${JSON.stringify({ ...snapshot, callId: command.callId, eventsDrained, fixtureId: command.fixtureId, generation: descriptor.generation, nonce: descriptor.nonce, phase: "readback" })}\n`);
317
+ return operation(context);
318
+ } if (command.mode === "hold")
319
+ controller.hold(command.fixtureId); return controller.run(command.fixtureId, descriptor.generation, context, operation, command.callId); }
320
+ // prettier-ignore
321
+ export function emitConfiguredH03HostEvent(phase, generation) { const descriptor = parseH03FixtureDescriptor(process.env.AST_H03_FIXTURE); if (!descriptor)
322
+ return; appendFileSync(path.join(descriptor.controlDirectory, "events.jsonl"), `${JSON.stringify({ fixtureId: "host", generation, nonce: descriptor.nonce, phase })}\n`, { encoding: "utf8", flag: "a" }); }
323
+ // prettier-ignore
324
+ export function emitConfiguredH03ErrorEvidence(text, error) { const configured = typeof error === "object" && error !== null ? errorCommands.get(error) : undefined; if (!configured)
325
+ return; const { descriptor, command } = configured; if (!("captured" in configured)) {
326
+ appendFileSync(path.join(descriptor.controlDirectory, "events.jsonl"), `${JSON.stringify({ callId: command.callId, fixtureId: command.fixtureId, generation: descriptor.generation, nonce: descriptor.nonce, phase: "error", result: { isError: true, error: { info: { name: error instanceof Error ? error.name : "Error" }, message: text } } })}\n`);
327
+ return;
328
+ } const { controller, captured } = configured; if (!controller.accepts(captured))
329
+ return; let publicError; try {
330
+ publicError = JSON.parse(text);
331
+ }
332
+ catch {
333
+ publicError = undefined;
334
+ } const source = publicError?.error, code = typeof source?.code === "string" && PUBLIC_ERROR_CODES.some((value) => value === source.code) ? source.code : "INTERNAL_ERROR", correlation_id = typeof source?.correlation_id === "string" && UUID.test(source.correlation_id) ? source.correlation_id : captured.correlationId, name = error instanceof Error && /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(error.name) ? error.name : "Error", message = typeof source?.message === "string" ? sanitizePublicText(source.message) : "An internal error occurred."; const identity = { callId: captured.callId, correlationId: captured.correlationId, fixtureId: captured.fixtureId, generation: captured.generation, ownerToken: captured.ownerToken, phase: "error", resources: controller.snapshot(), result: { isError: true, error: { code, message, correlation_id, info: { name } } } }; let serialized = JSON.stringify(identity); if (Buffer.byteLength(serialized) > 4096)
335
+ serialized = JSON.stringify({ ...identity, result: { ...identity.result, error: { ...identity.result.error, message: "Evidence message omitted: bounded record exceeded." } } }); appendFileSync(path.join(descriptor.controlDirectory, "events.jsonl"), `${serialized}\n`); }
@@ -3,7 +3,7 @@ import { createTruncationMetadata, createSourceLocation, } from "./read-contract
3
3
  import { getSourceFileOrThrow } from "./project.js";
4
4
  import { findLocatedDeclaration } from "./symbols.js";
5
5
  import { NO_REQUEST_CONTEXT } from "./request-context.js";
6
- import { createCompilerRelationshipResolver, RELATIONSHIP_EDGE_KINDS, } from "./relationships.js";
6
+ import { createCompilerRelationshipResolver, isRelationshipCoverageComplete, mergeRelationshipCoverage, RELATIONSHIP_EDGE_KINDS, } from "./relationships.js";
7
7
  export const IMPACT_DIRECTIONS = Object.freeze(["incoming", "outgoing", "both"]);
8
8
  export const DEFAULT_IMPACT_MAX_DEPTH = 3;
9
9
  export const DEFAULT_IMPACT_MAX_NODES = 100;
@@ -24,7 +24,7 @@ export function isExactImpactEdge(edge) {
24
24
  Array.isArray(edge.freshness.causes) &&
25
25
  edge.freshness.causes.length === 0);
26
26
  }
27
- export function assertExactImpactEvidence(impact) {
27
+ export function assertCompleteExactImpactEvidence(impact) {
28
28
  if (typeof impact !== "object" || impact === null || !Array.isArray(impact.edges)) {
29
29
  throw new Error("Impact evidence is invalid.");
30
30
  }
@@ -35,6 +35,9 @@ export function assertExactImpactEvidence(impact) {
35
35
  throw new Error("Test candidates require fresh exact compiler impact evidence.");
36
36
  }
37
37
  }
38
+ export function assertExactImpactEvidence(impact) {
39
+ assertCompleteExactImpactEvidence(impact);
40
+ }
38
41
  const IMPACT_PROBE_WORK_ITEMS = 1_024;
39
42
  const IMPACT_RELATIONSHIP_WORK_ITEMS = 100_000;
40
43
  function assertIntegerInRange(value, minimum, maximum, name) {
@@ -152,7 +155,7 @@ function collectNeighbors(current, edges, options, requestContext) {
152
155
  }
153
156
  return [...neighbors.values()].sort(neighborOrder);
154
157
  }
155
- function traverseWithNeighborProvider(root, neighborsFor, options = {}, requestContext = NO_REQUEST_CONTEXT) {
158
+ function traverseWithNeighborProvider(root, neighborsFor, options = {}, requestContext = NO_REQUEST_CONTEXT, semanticAuthority = false) {
156
159
  requestContext.checkpoint();
157
160
  if (typeof root !== "object" || root === null) {
158
161
  throw new Error("Impact traversal input is invalid.");
@@ -165,6 +168,27 @@ function traverseWithNeighborProvider(root, neighborsFor, options = {}, requestC
165
168
  const queue = [{ key: endpointKey(root), endpoint: root, depth: 0 }];
166
169
  const truncationReasons = new Set();
167
170
  let maxDepthReached = 0;
171
+ let coverage = [];
172
+ let relationshipWorkItems = 0;
173
+ let relationshipWorkLimitReached = false;
174
+ const resolveNeighbors = (current, providerOptions, context, maxEdges, stopAfterFirst, allowedNeighborKeys, excludedRelationshipIds, maxWorkItems) => {
175
+ const remainingWorkItems = IMPACT_RELATIONSHIP_WORK_ITEMS - relationshipWorkItems;
176
+ if (remainingWorkItems <= 0) {
177
+ relationshipWorkLimitReached = true;
178
+ return {
179
+ neighbors: [],
180
+ incomplete: true,
181
+ excludedNeighbors: false,
182
+ workLimitReached: true,
183
+ };
184
+ }
185
+ const result = neighborsFor(current, providerOptions, context, maxEdges, stopAfterFirst, allowedNeighborKeys, excludedRelationshipIds, Math.min(maxWorkItems ?? remainingWorkItems, remainingWorkItems));
186
+ coverage = mergeRelationshipCoverage(coverage, result.coverage ?? []);
187
+ relationshipWorkItems += result.work?.work_items ?? 0;
188
+ relationshipWorkLimitReached ||=
189
+ result.workLimitReached || result.work?.work_limit_reached === true;
190
+ return result;
191
+ };
168
192
  const classifyObservedNeighbor = (current, neighbor, edgeBlocked) => {
169
193
  const nextKey = endpointKey(neighbor.endpoint);
170
194
  const isKnownNode = nodes.has(nextKey);
@@ -182,7 +206,7 @@ function traverseWithNeighborProvider(root, neighborsFor, options = {}, requestC
182
206
  const current = queue.shift();
183
207
  const remainingEdges = normalized.max_edges - selectedEdges.size;
184
208
  if (remainingEdges <= 0) {
185
- const probe = neighborsFor(current.endpoint, normalized, requestContext, 1, true, undefined, new Set(selectedEdges.keys()), IMPACT_PROBE_WORK_ITEMS);
209
+ const probe = resolveNeighbors(current.endpoint, normalized, requestContext, 1, true, undefined, new Set(selectedEdges.keys()), IMPACT_PROBE_WORK_ITEMS);
186
210
  for (const neighbor of probe.neighbors) {
187
211
  classifyObservedNeighbor(current, neighbor, true);
188
212
  }
@@ -196,11 +220,9 @@ function traverseWithNeighborProvider(root, neighborsFor, options = {}, requestC
196
220
  ? "record_limit"
197
221
  : undefined;
198
222
  const allowedNeighborKeys = restrictionReason ? new Set(nodes.keys()) : undefined;
199
- const batch = neighborsFor(current.endpoint, normalized, requestContext, remainingEdges, false, allowedNeighborKeys, new Set(selectedEdges.keys()));
223
+ const batch = resolveNeighbors(current.endpoint, normalized, requestContext, remainingEdges, false, allowedNeighborKeys, new Set(selectedEdges.keys()));
200
224
  if (batch.excludedNeighbors && restrictionReason)
201
225
  truncationReasons.add(restrictionReason);
202
- if (batch.workLimitReached)
203
- truncationReasons.add("record_limit");
204
226
  let skippedNewNode = false;
205
227
  for (const neighbor of batch.neighbors) {
206
228
  requestContext.checkpoint();
@@ -236,7 +258,7 @@ function traverseWithNeighborProvider(root, neighborsFor, options = {}, requestC
236
258
  if (restrictionReason) {
237
259
  if (batch.incomplete && !batch.workLimitReached)
238
260
  truncationReasons.add("edge_limit");
239
- const probe = neighborsFor(current.endpoint, normalized, requestContext, 1, true, undefined, new Set(selectedEdges.keys()), IMPACT_PROBE_WORK_ITEMS);
261
+ const probe = resolveNeighbors(current.endpoint, normalized, requestContext, 1, true, undefined, new Set(selectedEdges.keys()), IMPACT_PROBE_WORK_ITEMS);
240
262
  for (const neighbor of probe.neighbors) {
241
263
  classifyObservedNeighbor(current, neighbor, selectedEdges.size >= normalized.max_edges);
242
264
  }
@@ -244,11 +266,9 @@ function traverseWithNeighborProvider(root, neighborsFor, options = {}, requestC
244
266
  }
245
267
  if ((skippedNewNode || nodes.size >= normalized.max_nodes) &&
246
268
  selectedEdges.size < normalized.max_edges) {
247
- const knownBatch = neighborsFor(current.endpoint, normalized, requestContext, normalized.max_edges - selectedEdges.size, false, new Set(nodes.keys()), new Set(selectedEdges.keys()));
269
+ const knownBatch = resolveNeighbors(current.endpoint, normalized, requestContext, normalized.max_edges - selectedEdges.size, false, new Set(nodes.keys()), new Set(selectedEdges.keys()));
248
270
  if (knownBatch.excludedNeighbors)
249
271
  truncationReasons.add("record_limit");
250
- if (knownBatch.workLimitReached)
251
- truncationReasons.add("record_limit");
252
272
  for (const neighbor of knownBatch.neighbors) {
253
273
  requestContext.checkpoint();
254
274
  if (selectedEdges.size >= normalized.max_edges) {
@@ -263,7 +283,7 @@ function traverseWithNeighborProvider(root, neighborsFor, options = {}, requestC
263
283
  }
264
284
  else if (batch.incomplete && !batch.workLimitReached) {
265
285
  truncationReasons.add("edge_limit");
266
- const overflowProbe = neighborsFor(current.endpoint, normalized, requestContext, 1, true, undefined, new Set(selectedEdges.keys()), IMPACT_PROBE_WORK_ITEMS);
286
+ const overflowProbe = resolveNeighbors(current.endpoint, normalized, requestContext, 1, true, undefined, new Set(selectedEdges.keys()), IMPACT_PROBE_WORK_ITEMS);
267
287
  for (const neighbor of overflowProbe.neighbors) {
268
288
  classifyObservedNeighbor(current, neighbor, true);
269
289
  }
@@ -271,6 +291,9 @@ function traverseWithNeighborProvider(root, neighborsFor, options = {}, requestC
271
291
  }
272
292
  const orderedReasons = ["depth_limit", "record_limit", "edge_limit"].filter((reason) => truncationReasons.has(reason));
273
293
  const truncated = orderedReasons.length > 0;
294
+ const boundedIncomplete = truncated || relationshipWorkLimitReached;
295
+ const semanticIncomplete = boundedIncomplete || !isRelationshipCoverageComplete(coverage);
296
+ const incomplete = semanticAuthority ? semanticIncomplete : boundedIncomplete;
274
297
  requestContext.checkpoint();
275
298
  return {
276
299
  root,
@@ -287,7 +310,14 @@ function traverseWithNeighborProvider(root, neighborsFor, options = {}, requestC
287
310
  max_depth: normalized.max_depth,
288
311
  max_nodes: normalized.max_nodes,
289
312
  max_edges: normalized.max_edges,
290
- incomplete: truncated,
313
+ coverage,
314
+ work: {
315
+ work_items: relationshipWorkItems,
316
+ max_work_items: IMPACT_RELATIONSHIP_WORK_ITEMS,
317
+ work_limit_reached: relationshipWorkLimitReached,
318
+ },
319
+ proven_empty: selectedEdges.size === 0 && !semanticIncomplete,
320
+ incomplete,
291
321
  truncation: createTruncationMetadata(truncated, truncated ? orderedReasons[0] : null),
292
322
  truncation_reasons: orderedReasons,
293
323
  };
@@ -326,9 +356,11 @@ export function traverseCompilerImpact(project, projectRoot, root, freshness, op
326
356
  });
327
357
  return {
328
358
  neighbors: collectNeighbors(current, resolution.edges, normalized, requestContext),
359
+ coverage: resolution.coverage,
360
+ work: resolution.work,
329
361
  incomplete: resolution.incomplete,
330
362
  excludedNeighbors: resolution.excluded_neighbors,
331
363
  workLimitReached: resolution.work_limit_reached,
332
364
  };
333
- }, options, requestContext);
365
+ }, options, requestContext, options.authority === "semantic");
334
366
  }
@@ -1,8 +1,8 @@
1
1
  import { randomUUID, createHash } from "node:crypto";
2
- import { spawn } from "node:child_process";
3
2
  import { constants as fsConstants } from "node:fs";
4
3
  import { lstat, mkdir, open, realpath, rm } from "node:fs/promises";
5
4
  import path from "node:path";
5
+ import { runPublicationPrimitive } from "./authenticated-publication.js";
6
6
  export class ManagedFileApplyError extends Error {
7
7
  commitState;
8
8
  rollbackState;
@@ -23,8 +23,6 @@ export class ManagedFileRollbackError extends Error {
23
23
  }
24
24
  const GNU_LN_PATH = "/usr/bin/ln";
25
25
  const GNU_MV_PATH = "/usr/bin/mv";
26
- const COREUTILS_TIMEOUT_MS = 5_000;
27
- const COREUTILS_STDERR_LIMIT = 4_096;
28
26
  function errno(error) {
29
27
  return error.code;
30
28
  }
@@ -34,51 +32,6 @@ function objectIdentity(stats) {
34
32
  function identitiesEqual(left, right) {
35
33
  return (left !== undefined && right !== undefined && left.dev === right.dev && left.ino === right.ino);
36
34
  }
37
- async function runCoreutils(executable, args, inheritedDescriptors) {
38
- await new Promise((resolve, reject) => {
39
- const child = spawn(executable, args, {
40
- env: { LANG: "C", LC_ALL: "C", PATH: "/usr/bin:/bin" },
41
- shell: false,
42
- stdio: ["ignore", "ignore", "pipe", ...inheritedDescriptors],
43
- windowsHide: true,
44
- });
45
- let stderrBytes = 0;
46
- let settled = false;
47
- let requestedFailure;
48
- const requestFailure = (message, cause) => {
49
- if (settled || requestedFailure !== undefined)
50
- return;
51
- requestedFailure = new Error(message, cause === undefined ? undefined : { cause });
52
- clearTimeout(timeout);
53
- child.kill("SIGKILL");
54
- };
55
- const timeout = setTimeout(() => requestFailure(`Managed Linux primitive timed out: ${path.basename(executable)}`), COREUTILS_TIMEOUT_MS);
56
- child.stderr.on("data", (chunk) => {
57
- stderrBytes += chunk.byteLength;
58
- if (stderrBytes > COREUTILS_STDERR_LIMIT) {
59
- requestFailure(`Managed Linux primitive exceeded its stderr limit: ${path.basename(executable)}`);
60
- }
61
- });
62
- child.on("error", (error) => {
63
- requestFailure(`Managed Linux primitive is unavailable: ${path.basename(executable)}`, error);
64
- });
65
- child.on("close", (code, signal) => {
66
- if (settled)
67
- return;
68
- settled = true;
69
- clearTimeout(timeout);
70
- if (requestedFailure !== undefined) {
71
- reject(requestedFailure);
72
- return;
73
- }
74
- if (code === 0 && signal === null) {
75
- resolve();
76
- return;
77
- }
78
- reject(new Error(`Managed Linux primitive failed: ${path.basename(executable)} (${signal ?? code ?? "unknown"})`));
79
- });
80
- });
81
- }
82
35
  export function hashBytes(content) {
83
36
  return createHash("sha256").update(content).digest("hex");
84
37
  }
@@ -425,10 +378,10 @@ async function removePathIfOwned(directory, basename, expectedIdentity) {
425
378
  return (await capturePathIdentity(directory, basename)) === undefined ? "removed" : "changed";
426
379
  }
427
380
  async function linkHeldFile(source, directory, basename) {
428
- await runCoreutils(GNU_LN_PATH, ["-L", "-T", "--", "/proc/self/fd/3", `/proc/self/fd/4/${basename}`], [source.fd, directory.fd]);
381
+ await runPublicationPrimitive(GNU_LN_PATH, ["-L", "-T", "--", "/proc/self/fd/3", `/proc/self/fd/4/${basename}`], [source.fd, directory.fd]);
429
382
  }
430
383
  async function exchangeDirectoryEntries(directory, leftBasename, rightBasename) {
431
- await runCoreutils(GNU_MV_PATH, [
384
+ await runPublicationPrimitive(GNU_MV_PATH, [
432
385
  "--exchange",
433
386
  "--no-copy",
434
387
  "-T",
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { z } from "zod";
6
6
  import { applyOperation, configureOperationApply, exportOperationRecord, importOperationRecord, } from "./operations.js";
7
7
  import { ensurePrivateDirectory, resolveRuntimeStateDirectory, } from "./runtime-state.js";
8
- export const PLAN_SCHEMA_VERSION = 1;
8
+ export const PLAN_SCHEMA_VERSION = 2;
9
9
  export const MAX_PLAN_BYTES = 50 * 1024 * 1024;
10
10
  const HashSchema = z.string().regex(/^[a-f0-9]{64}$/);
11
11
  const IsoDateSchema = z.string().datetime({ offset: true });
@@ -62,12 +62,13 @@ const PersistedOperationSchema = z
62
62
  files: z.array(PersistedFileSchema).min(1),
63
63
  })
64
64
  .strict();
65
- const PlanEnvelopeSchema = z
66
- .object({
67
- schema_version: z.literal(PLAN_SCHEMA_VERSION),
68
- operation: PersistedOperationSchema,
69
- })
70
- .strict();
65
+ const PlanEnvelopeSchema = z.discriminatedUnion("schema_version", [
66
+ z.object({ schema_version: z.literal(1), operation: PersistedOperationSchema }).strict(),
67
+ z.object({ schema_version: z.literal(2), operation: PersistedOperationSchema }).strict(),
68
+ ]);
69
+ function planEnvelope(version, operation) {
70
+ return version === 1 ? { schema_version: 1, operation } : { schema_version: 2, operation };
71
+ }
71
72
  function stateDirectory(options) {
72
73
  return resolveRuntimeStateDirectory(options);
73
74
  }
@@ -147,10 +148,7 @@ async function readPlanEnvelope(planFileInput) {
147
148
  export async function persistOperationPlan(operationId, options = {}) {
148
149
  const plansDirectory = path.join(stateDirectory(options), "plans");
149
150
  const planFile = path.join(plansDirectory, `${operationId}.astplan`);
150
- await atomicWritePlan(planFile, {
151
- schema_version: PLAN_SCHEMA_VERSION,
152
- operation: exportOperationRecord(operationId),
153
- });
151
+ await atomicWritePlan(planFile, planEnvelope(PLAN_SCHEMA_VERSION, exportOperationRecord(operationId)));
154
152
  return planFile;
155
153
  }
156
154
  export async function applyPersistedOperation(planFileInput, planHash, options = {}) {
@@ -158,15 +156,16 @@ export async function applyPersistedOperation(planFileInput, planHash, options =
158
156
  throw new Error("plan_hash must be a 64-character lowercase SHA-256 digest.");
159
157
  }
160
158
  const current = await readPlanEnvelope(planFileInput);
161
- const operationId = current.envelope.operation.operation_id;
162
- importOperationRecord(current.envelope.operation, planHash);
159
+ const { schema_version: planVersion, operation } = current.envelope;
160
+ if (planVersion === 1 && operation.status === "prepared") {
161
+ throw new Error("Legacy v1 prepared plans are not authorized for apply.");
162
+ }
163
+ const operationId = operation.operation_id;
164
+ importOperationRecord(operation, planHash, planVersion);
163
165
  configureOperationApply(operationId, {
164
166
  stateDirectory: options.stateDirectory,
165
167
  receiptWriter: async () => {
166
- await atomicWritePlan(current.planFile, {
167
- schema_version: PLAN_SCHEMA_VERSION,
168
- operation: exportOperationRecord(operationId),
169
- });
168
+ await atomicWritePlan(current.planFile, planEnvelope(planVersion, exportOperationRecord(operationId)));
170
169
  },
171
170
  });
172
171
  return applyOperation(operationId, planHash);
@@ -175,7 +174,7 @@ export async function inspectPersistedPlan(planFileInput) {
175
174
  const { envelope } = await readPlanEnvelope(planFileInput);
176
175
  const operation = envelope.operation;
177
176
  return {
178
- schema_version: PLAN_SCHEMA_VERSION,
177
+ schema_version: envelope.schema_version,
179
178
  operation_id: operation.operation_id,
180
179
  plan_hash: operation.plan_hash,
181
180
  status: operation.status,