@fcon-tech/portolan 0.4.5
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/LICENSE +21 -0
- package/README.md +110 -0
- package/adapters/README.md +226 -0
- package/adapters/omp/portolan-mcp +19 -0
- package/adapters/opencode/expedition-launcher +70 -0
- package/adapters/opencode/install.test.ts +105 -0
- package/adapters/opencode/install.ts +357 -0
- package/adapters/pi/portolan-mcp +19 -0
- package/adapters/scheduling/night-watch.cron +23 -0
- package/core/schema/chart.schema.json +154 -0
- package/core/src/bin/portolan.ts +84 -0
- package/core/src/chart-io.rollback-fixture.ts +55 -0
- package/core/src/chart-io.ts +121 -0
- package/core/src/chart-store.ts +137 -0
- package/core/src/chartroom/cli.ts +63 -0
- package/core/src/chartroom/render.ts +213 -0
- package/core/src/chartroom/review-template.html +232 -0
- package/core/src/chartroom/review.ts +109 -0
- package/core/src/chartroom/template.html +1090 -0
- package/core/src/fan-in.ts +84 -0
- package/core/src/harbor/chat-format.ts +154 -0
- package/core/src/harbor/cli.ts +178 -0
- package/core/src/harbor/errors.ts +22 -0
- package/core/src/harbor/fingerprint.ts +29 -0
- package/core/src/harbor/history.ts +178 -0
- package/core/src/harbor/launcher.ts +155 -0
- package/core/src/harbor/night-policy.ts +64 -0
- package/core/src/harbor/proposals.ts +324 -0
- package/core/src/harbor/run.ts +72 -0
- package/core/src/harbor/settings.ts +108 -0
- package/core/src/harbor/snapshot.ts +187 -0
- package/core/src/harbor/watch.ts +103 -0
- package/core/src/index.ts +28 -0
- package/core/src/notices.ts +117 -0
- package/core/src/perimeter.ts +44 -0
- package/core/src/server/adapter-boundary.ts +66 -0
- package/core/src/server/main.ts +27 -0
- package/core/src/server/registry.ts +609 -0
- package/core/src/server/server.ts +123 -0
- package/core/src/server/test-harness.ts +161 -0
- package/core/src/sheets.ts +151 -0
- package/core/src/staleness.ts +203 -0
- package/core/src/tools/log.ts +215 -0
- package/core/src/tools/manifests.ts +912 -0
- package/core/src/tools/neighborhood.ts +423 -0
- package/core/src/tools/shared.ts +72 -0
- package/core/src/tools/sound.ts +634 -0
- package/core/src/tools/sweep.ts +198 -0
- package/core/src/tools/symbols.ts +176 -0
- package/core/src/tools/trust-report.ts +193 -0
- package/core/src/types.ts +162 -0
- package/core/src/validate.ts +106 -0
- package/package.json +34 -0
- package/skill/SKILL.md +279 -0
- package/skill/examples/sailing-directions-example.md +35 -0
- package/skill/sailing-directions.template.md +59 -0
- package/skill/verify/checks.ts +476 -0
- package/skill/verify/dry-run.ts +738 -0
- package/skill/verify/fixture.ts +128 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chart.neighborhood`: one vessel's fairway neighborhood in one
|
|
3
|
+
* deterministic call (openspec/changes/chart-neighborhood,
|
|
4
|
+
* specs/tools/spec.md).
|
|
5
|
+
*
|
|
6
|
+
* Traversal follows charted fairways only, out to the requested depth
|
|
7
|
+
* (default 1, at most 3) in the requested direction (`in`, `out`, or
|
|
8
|
+
* `both`; default `both`), visiting each vessel at most once so a cycle
|
|
9
|
+
* cannot recurse. The returned vessels — the queried vessel plus every
|
|
10
|
+
* vessel a kept edge touches — are ordered by direct fan-in over the whole
|
|
11
|
+
* chart (the count of charted incoming fairways), highest first, and edges
|
|
12
|
+
* are packed greedily in that rank order under the budget: at most
|
|
13
|
+
* `maxEdges` edges (default 40, cap 200) and at most `maxBytes` of
|
|
14
|
+
* serialized response (default 32768, cap 131072). The byte budget governs
|
|
15
|
+
* the whole serialized response: edges pack under it first, and if the
|
|
16
|
+
* assembled response still overflows — typically a touched vessel carrying
|
|
17
|
+
* many ports of entry — vessels are dropped from the tail of the rank order
|
|
18
|
+
* (never the queried vessel) until it fits. A budget that cuts the
|
|
19
|
+
* neighborhood is loud: `truncated` plus the `droppedEdges` and
|
|
20
|
+
* `droppedVessels` counts, never a silent prefix. An edge's rank key is the
|
|
21
|
+
* fan-in of the neighbor it was discovered through (ties broken by edge
|
|
22
|
+
* id), so packing is deterministic.
|
|
23
|
+
*
|
|
24
|
+
* By default the response serves chart truth as stored. With `verify: true`
|
|
25
|
+
* every returned edge's anchors are re-sounded through the deterministic
|
|
26
|
+
* `sound.anchor` machinery; each edge then carries its verdict and the
|
|
27
|
+
* anchors that failed. Only an edge with at least one sounded anchor, all
|
|
28
|
+
* confirmed, stands confirmed — an anchorless edge resolves nothing and is
|
|
29
|
+
* refuted, and an anchor that cannot be sounded at all refutes its edge by
|
|
30
|
+
* name. A refuted sounding never modifies the Chart — the verdict informs,
|
|
31
|
+
* the Cartographer writes.
|
|
32
|
+
*
|
|
33
|
+
* Staleness follows chart.read semantics: staleness is refreshed before
|
|
34
|
+
* answering, so pending correction is visible, never hidden. That refresh
|
|
35
|
+
* is the only write this call may cause (nothing on unchanged signatures);
|
|
36
|
+
* the per-call ship's-log receipt is the serving handler's append.
|
|
37
|
+
*/
|
|
38
|
+
import type {
|
|
39
|
+
Anchor,
|
|
40
|
+
FairwayEntry,
|
|
41
|
+
FairwayRelation,
|
|
42
|
+
IndexedEntry,
|
|
43
|
+
PortOfEntryEntry,
|
|
44
|
+
TrustLabel,
|
|
45
|
+
VesselEntry,
|
|
46
|
+
} from "../types";
|
|
47
|
+
import { readChart } from "../chart-store";
|
|
48
|
+
import { refreshStaleness } from "../staleness";
|
|
49
|
+
import { SoundingError, soundAnchor } from "./sound";
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// The call contract: directions, defaults, caps
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/** The closed direction vocabulary. */
|
|
56
|
+
export const NEIGHBORHOOD_DIRECTIONS = ["in", "out", "both"] as const;
|
|
57
|
+
|
|
58
|
+
export type NeighborhoodDirection = (typeof NEIGHBORHOOD_DIRECTIONS)[number];
|
|
59
|
+
|
|
60
|
+
/** Defaults and caps: conservative budgets a caller cannot inflate. */
|
|
61
|
+
export const NEIGHBORHOOD_DEFAULTS = {
|
|
62
|
+
direction: "both",
|
|
63
|
+
depth: 1,
|
|
64
|
+
maxEdges: 40,
|
|
65
|
+
maxBytes: 32768,
|
|
66
|
+
} as const;
|
|
67
|
+
|
|
68
|
+
export const NEIGHBORHOOD_CAPS = {
|
|
69
|
+
depth: 3,
|
|
70
|
+
maxEdges: 200,
|
|
71
|
+
maxBytes: 131072,
|
|
72
|
+
} as const;
|
|
73
|
+
|
|
74
|
+
/** Raised for every rejection of this tool: bad parameters, unsurveyed vessel. */
|
|
75
|
+
export class NeighborhoodError extends Error {
|
|
76
|
+
constructor(message: string) {
|
|
77
|
+
super(`neighborhood: ${message}`);
|
|
78
|
+
this.name = "NeighborhoodError";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface NeighborhoodParams {
|
|
83
|
+
/** The vessel whose neighborhood is asked for. */
|
|
84
|
+
vessel: string;
|
|
85
|
+
/** Which fairways count as touching: default `both`. */
|
|
86
|
+
direction?: NeighborhoodDirection;
|
|
87
|
+
/** Hops to traverse: default 1, at most 3. */
|
|
88
|
+
depth?: number;
|
|
89
|
+
/** Edge budget: default 40, at most 200. */
|
|
90
|
+
maxEdges?: number;
|
|
91
|
+
/** Serialized-response budget in bytes: default 32768, at most 131072. */
|
|
92
|
+
maxBytes?: number;
|
|
93
|
+
/** Re-sound every returned edge's anchors: default false. */
|
|
94
|
+
verify?: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// The response shapes
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
export interface NeighborhoodPortOfEntry {
|
|
102
|
+
id: string;
|
|
103
|
+
protocol: string;
|
|
104
|
+
trust: TrustLabel;
|
|
105
|
+
anchors: Anchor[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface NeighborhoodVessel {
|
|
109
|
+
id: string;
|
|
110
|
+
trust: TrustLabel;
|
|
111
|
+
stale: boolean;
|
|
112
|
+
/** Direct fan-in over the whole chart: charted incoming fairways. */
|
|
113
|
+
fanIn: number;
|
|
114
|
+
portsOfEntry: NeighborhoodPortOfEntry[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface NeighborhoodEdgeVerification {
|
|
118
|
+
verdict: "confirmed" | "refuted";
|
|
119
|
+
/** The cited anchors whose sounding refuted, in citation order. */
|
|
120
|
+
refutedAnchors: Anchor[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface NeighborhoodEdge {
|
|
124
|
+
id: string;
|
|
125
|
+
from: string;
|
|
126
|
+
to: string;
|
|
127
|
+
trust: TrustLabel;
|
|
128
|
+
/** Present exactly when the charted fairway carries one; absent reads untyped. */
|
|
129
|
+
relation?: FairwayRelation;
|
|
130
|
+
/** The charted note rides along: it is stored truth and counts toward the byte budget. */
|
|
131
|
+
note?: string;
|
|
132
|
+
stale: boolean;
|
|
133
|
+
anchors: Anchor[];
|
|
134
|
+
/** Present exactly when `verify: true`. */
|
|
135
|
+
verification?: NeighborhoodEdgeVerification;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface NeighborhoodResponse {
|
|
139
|
+
vessel: string;
|
|
140
|
+
direction: NeighborhoodDirection;
|
|
141
|
+
depth: number;
|
|
142
|
+
edges: NeighborhoodEdge[];
|
|
143
|
+
/** Queried vessel plus every vessel a kept edge touches, fan-in ranked. */
|
|
144
|
+
vessels: NeighborhoodVessel[];
|
|
145
|
+
truncated: boolean;
|
|
146
|
+
droppedEdges: number;
|
|
147
|
+
/** Vessels dropped from the tail of the rank order to fit `maxBytes`. */
|
|
148
|
+
droppedVessels: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Parameter validation: strict, parameter-named, loud
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
function requireVessel(params: NeighborhoodParams): string {
|
|
156
|
+
const { vessel } = params;
|
|
157
|
+
if (typeof vessel !== "string" || vessel.length === 0) {
|
|
158
|
+
throw new NeighborhoodError("vessel must be a non-empty string naming a charted vessel");
|
|
159
|
+
}
|
|
160
|
+
return vessel;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function requireDirection(params: NeighborhoodParams): NeighborhoodDirection {
|
|
164
|
+
const { direction } = params;
|
|
165
|
+
if (direction === undefined) return NEIGHBORHOOD_DEFAULTS.direction;
|
|
166
|
+
if (!(NEIGHBORHOOD_DIRECTIONS as readonly string[]).includes(direction)) {
|
|
167
|
+
throw new NeighborhoodError(
|
|
168
|
+
`direction must be one of ${NEIGHBORHOOD_DIRECTIONS.join(", ")}, got ${JSON.stringify(direction)}`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return direction;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function requireBoundedInt(
|
|
175
|
+
params: NeighborhoodParams,
|
|
176
|
+
key: "depth" | "maxEdges" | "maxBytes",
|
|
177
|
+
min: number,
|
|
178
|
+
max: number,
|
|
179
|
+
): number {
|
|
180
|
+
const value = params[key];
|
|
181
|
+
if (value === undefined) {
|
|
182
|
+
return NEIGHBORHOOD_DEFAULTS[key] as number;
|
|
183
|
+
}
|
|
184
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) {
|
|
185
|
+
throw new NeighborhoodError(`${key} must be an integer between ${min} and ${max}, got ${JSON.stringify(value)}`);
|
|
186
|
+
}
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function requireVerify(params: NeighborhoodParams): boolean {
|
|
191
|
+
const { verify } = params;
|
|
192
|
+
if (verify === undefined) return false;
|
|
193
|
+
if (typeof verify !== "boolean") {
|
|
194
|
+
throw new NeighborhoodError(`verify must be a boolean, got ${JSON.stringify(verify)}`);
|
|
195
|
+
}
|
|
196
|
+
return verify;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// The engine
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
type ChartedFairway = IndexedEntry & FairwayEntry;
|
|
204
|
+
type ChartedVessel = IndexedEntry & VesselEntry;
|
|
205
|
+
|
|
206
|
+
/** Descending fan-in, ties broken by id — the one ranking this tool serves. */
|
|
207
|
+
function rankBy(fanIn: Map<string, number>): (a: string, b: string) => number {
|
|
208
|
+
return (a, b) => {
|
|
209
|
+
const fa = fanIn.get(a) ?? 0;
|
|
210
|
+
const fb = fanIn.get(b) ?? 0;
|
|
211
|
+
if (fa !== fb) return fb - fa;
|
|
212
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* `chart.neighborhood`: the queried vessel's neighborhood, ranked and
|
|
218
|
+
* budgeted. Deterministic — no timestamps, no map-order leakage: two runs
|
|
219
|
+
* over an unchanged province return the same response in the same order.
|
|
220
|
+
*/
|
|
221
|
+
export function neighborhood(targetRoot: string, params: NeighborhoodParams): NeighborhoodResponse {
|
|
222
|
+
const vesselId = requireVessel(params);
|
|
223
|
+
const direction = requireDirection(params);
|
|
224
|
+
const depth = requireBoundedInt(params, "depth", 1, NEIGHBORHOOD_CAPS.depth);
|
|
225
|
+
const maxEdges = requireBoundedInt(params, "maxEdges", 1, NEIGHBORHOOD_CAPS.maxEdges);
|
|
226
|
+
const maxBytes = requireBoundedInt(params, "maxBytes", 1, NEIGHBORHOOD_CAPS.maxBytes);
|
|
227
|
+
const verify = requireVerify(params);
|
|
228
|
+
|
|
229
|
+
// chart.read semantics: staleness is refreshed before answering. On
|
|
230
|
+
// unchanged signatures the refresh writes nothing at all.
|
|
231
|
+
refreshStaleness(targetRoot);
|
|
232
|
+
const entries = readChart(targetRoot);
|
|
233
|
+
|
|
234
|
+
const vessels = new Map<string, ChartedVessel>();
|
|
235
|
+
const incoming = new Map<string, ChartedFairway[]>();
|
|
236
|
+
const outgoing = new Map<string, ChartedFairway[]>();
|
|
237
|
+
const ports = new Map<string, PortOfEntryEntry[]>();
|
|
238
|
+
const fanIn = new Map<string, number>();
|
|
239
|
+
for (const entry of entries) {
|
|
240
|
+
if (entry.kind === "vessel") {
|
|
241
|
+
vessels.set(entry.id, entry);
|
|
242
|
+
} else if (entry.kind === "fairway") {
|
|
243
|
+
const outs = outgoing.get(entry.from) ?? [];
|
|
244
|
+
outs.push(entry);
|
|
245
|
+
outgoing.set(entry.from, outs);
|
|
246
|
+
const ins = incoming.get(entry.to) ?? [];
|
|
247
|
+
ins.push(entry);
|
|
248
|
+
incoming.set(entry.to, ins);
|
|
249
|
+
fanIn.set(entry.to, (fanIn.get(entry.to) ?? 0) + 1);
|
|
250
|
+
} else if (entry.kind === "portOfEntry") {
|
|
251
|
+
const list = ports.get(entry.vessel) ?? [];
|
|
252
|
+
list.push(entry);
|
|
253
|
+
ports.set(entry.vessel, list);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const byId = (a: { id: string }, b: { id: string }): number => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
257
|
+
for (const list of incoming.values()) list.sort(byId);
|
|
258
|
+
for (const list of outgoing.values()) list.sort(byId);
|
|
259
|
+
for (const list of ports.values()) list.sort(byId);
|
|
260
|
+
|
|
261
|
+
if (!vessels.has(vesselId)) {
|
|
262
|
+
throw new NeighborhoodError(
|
|
263
|
+
`vessel ${JSON.stringify(vesselId)} is not on the Chart (unsurveyed): no charted vessel carries this id`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Breadth-first traversal over charted fairways; each vessel is visited at
|
|
268
|
+
// most once, so a cycle terminates, and each fairway is considered once.
|
|
269
|
+
const visited = new Set<string>([vesselId]);
|
|
270
|
+
const seenEdges = new Set<string>();
|
|
271
|
+
const candidates: Array<{ fairway: ChartedFairway; neighbor: string }> = [];
|
|
272
|
+
const frontier: Array<{ id: string; hop: number }> = [{ id: vesselId, hop: 0 }];
|
|
273
|
+
while (frontier.length > 0) {
|
|
274
|
+
const { id, hop } = frontier.shift()!;
|
|
275
|
+
if (hop >= depth) continue;
|
|
276
|
+
const nexts: string[] = [];
|
|
277
|
+
if (direction !== "out") {
|
|
278
|
+
for (const fairway of incoming.get(id) ?? []) {
|
|
279
|
+
if (seenEdges.has(fairway.id)) continue;
|
|
280
|
+
seenEdges.add(fairway.id);
|
|
281
|
+
candidates.push({ fairway, neighbor: fairway.from });
|
|
282
|
+
if (!visited.has(fairway.from)) {
|
|
283
|
+
visited.add(fairway.from);
|
|
284
|
+
nexts.push(fairway.from);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (direction !== "in") {
|
|
289
|
+
for (const fairway of outgoing.get(id) ?? []) {
|
|
290
|
+
if (seenEdges.has(fairway.id)) continue;
|
|
291
|
+
seenEdges.add(fairway.id);
|
|
292
|
+
candidates.push({ fairway, neighbor: fairway.to });
|
|
293
|
+
if (!visited.has(fairway.to)) {
|
|
294
|
+
visited.add(fairway.to);
|
|
295
|
+
nexts.push(fairway.to);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
for (const next of nexts) frontier.push({ id: next, hop: hop + 1 });
|
|
300
|
+
}
|
|
301
|
+
const neighborRank = rankBy(fanIn);
|
|
302
|
+
candidates.sort((a, b) => {
|
|
303
|
+
const byNeighbor = neighborRank(a.neighbor, b.neighbor);
|
|
304
|
+
return byNeighbor !== 0 ? byNeighbor : byId(a.fairway, b.fairway);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// With verify: true, every returned edge's anchors are re-sounded once per
|
|
308
|
+
// edge (memoized — the packing probe may ask twice); the Chart is never
|
|
309
|
+
// written: the verdict informs, the Cartographer writes. An anchor that
|
|
310
|
+
// cannot be sounded at all — a non-citable one, reachable only by direct
|
|
311
|
+
// index edits — does not resolve, so it refutes its edge by name instead
|
|
312
|
+
// of crashing the verify; an edge citing no anchor resolves nothing and is
|
|
313
|
+
// refuted too.
|
|
314
|
+
const verifications = new Map<string, NeighborhoodEdgeVerification>();
|
|
315
|
+
const verificationOf = (fairway: ChartedFairway): NeighborhoodEdgeVerification => {
|
|
316
|
+
const cached = verifications.get(fairway.id);
|
|
317
|
+
if (cached) return cached;
|
|
318
|
+
const refutedAnchors: Anchor[] = [];
|
|
319
|
+
let sounded = 0;
|
|
320
|
+
for (const anchor of fairway.anchors) {
|
|
321
|
+
try {
|
|
322
|
+
if (soundAnchor(targetRoot, { anchor }).verdict === "refuted") refutedAnchors.push(anchor);
|
|
323
|
+
sounded++;
|
|
324
|
+
} catch (err) {
|
|
325
|
+
if (!(err instanceof SoundingError)) throw err;
|
|
326
|
+
refutedAnchors.push(anchor);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const result: NeighborhoodEdgeVerification = {
|
|
330
|
+
verdict: sounded > 0 && refutedAnchors.length === 0 ? "confirmed" : "refuted",
|
|
331
|
+
refutedAnchors,
|
|
332
|
+
};
|
|
333
|
+
verifications.set(fairway.id, result);
|
|
334
|
+
return result;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const toEdge = (fairway: ChartedFairway): NeighborhoodEdge => ({
|
|
338
|
+
id: fairway.id,
|
|
339
|
+
from: fairway.from,
|
|
340
|
+
to: fairway.to,
|
|
341
|
+
trust: fairway.trust,
|
|
342
|
+
...(fairway.relation !== undefined ? { relation: fairway.relation } : {}),
|
|
343
|
+
...(fairway.note !== undefined ? { note: fairway.note } : {}),
|
|
344
|
+
stale: fairway.stale,
|
|
345
|
+
anchors: fairway.anchors,
|
|
346
|
+
...(verify ? { verification: verificationOf(fairway) } : {}),
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const toVessel = (id: string): NeighborhoodVessel => {
|
|
350
|
+
const vessel = vessels.get(id);
|
|
351
|
+
return {
|
|
352
|
+
id,
|
|
353
|
+
// A fairway endpoint with no charted vessel entry is served honestly
|
|
354
|
+
// as what it is: unsurveyed.
|
|
355
|
+
trust: vessel?.trust ?? "unsurveyed",
|
|
356
|
+
stale: vessel?.stale ?? false,
|
|
357
|
+
fanIn: fanIn.get(id) ?? 0,
|
|
358
|
+
portsOfEntry: (ports.get(id) ?? []).map((port) => ({
|
|
359
|
+
id: port.id,
|
|
360
|
+
protocol: port.protocol,
|
|
361
|
+
trust: port.trust,
|
|
362
|
+
anchors: port.anchors,
|
|
363
|
+
})),
|
|
364
|
+
};
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const assemble = (kept: ChartedFairway[], droppedVessels: number): NeighborhoodResponse => {
|
|
368
|
+
const touched = new Set<string>([vesselId]);
|
|
369
|
+
for (const fairway of kept) {
|
|
370
|
+
touched.add(fairway.from);
|
|
371
|
+
touched.add(fairway.to);
|
|
372
|
+
}
|
|
373
|
+
const vesselRank = rankBy(fanIn);
|
|
374
|
+
const ranked = [...touched].sort(vesselRank);
|
|
375
|
+
// Tail drops never touch the queried vessel, even when it ranks low.
|
|
376
|
+
const dropped = new Set<string>();
|
|
377
|
+
for (let i = ranked.length - 1; i >= 0 && dropped.size < droppedVessels; i--) {
|
|
378
|
+
if (ranked[i] !== vesselId) dropped.add(ranked[i] as string);
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
vessel: vesselId,
|
|
382
|
+
direction,
|
|
383
|
+
depth,
|
|
384
|
+
edges: kept.map(toEdge),
|
|
385
|
+
vessels: ranked.filter((id) => !dropped.has(id)).map(toVessel),
|
|
386
|
+
truncated: candidates.length > kept.length || dropped.size > 0,
|
|
387
|
+
droppedEdges: candidates.length - kept.length,
|
|
388
|
+
droppedVessels: dropped.size,
|
|
389
|
+
};
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// Greedy packing in rank order under both budgets: a prefix of the ranked
|
|
393
|
+
// candidates, and the first edge that no longer fits drops it and all
|
|
394
|
+
// after it. The bytes measured here are the serialized edges — the
|
|
395
|
+
// touched-vessel list is budgeted whole, below. The cut is stated, never
|
|
396
|
+
// smoothed over.
|
|
397
|
+
//
|
|
398
|
+
// The budget is measured on the served serialization: the server envelopes
|
|
399
|
+
// tool results pretty-printed with 2-space indent (server.ts toolSuccess),
|
|
400
|
+
// so maxBytes bounds the bytes the client actually receives.
|
|
401
|
+
const budgetOf = (value: unknown): number =>
|
|
402
|
+
Buffer.byteLength(JSON.stringify(value, null, 2), "utf8");
|
|
403
|
+
const kept: ChartedFairway[] = [];
|
|
404
|
+
for (const candidate of candidates) {
|
|
405
|
+
if (kept.length >= maxEdges) break;
|
|
406
|
+
kept.push(candidate.fairway);
|
|
407
|
+
if (budgetOf(kept.map(toEdge)) <= maxBytes) continue;
|
|
408
|
+
kept.pop();
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
// The byte budget governs the whole serialized response: if the
|
|
412
|
+
// edge-packed response still overflows — typically a touched vessel
|
|
413
|
+
// carrying many ports of entry — vessels are dropped from the tail of the
|
|
414
|
+
// rank order until it fits. The queried vessel is never dropped, so a
|
|
415
|
+
// province whose every cut still leaves the queried vessel over budget
|
|
416
|
+
// serves over budget rather than serve a hole.
|
|
417
|
+
let droppedVessels = 0;
|
|
418
|
+
const droppable = assemble(kept, 0).vessels.length - 1;
|
|
419
|
+
while (droppedVessels < droppable && budgetOf(assemble(kept, droppedVessels)) > maxBytes) {
|
|
420
|
+
droppedVessels++;
|
|
421
|
+
}
|
|
422
|
+
return assemble(kept, droppedVessels);
|
|
423
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plumbing for the probe tools: PATH-based binary discovery and the
|
|
3
|
+
* honest missing-binary error. Tools wrap external binaries (ripgrep for
|
|
4
|
+
* sweep, ctags for symbols); when one is absent the tool names it and
|
|
5
|
+
* refuses to improvise a substitute search
|
|
6
|
+
* specs/tools/spec.md.
|
|
7
|
+
*/
|
|
8
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
9
|
+
import { delimiter, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
export type Env = Record<string, string | undefined>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Raised when the external binary a tool depends on is not installed.
|
|
15
|
+
* Names the binary and states that no results were gathered; the tool
|
|
16
|
+
* never falls back to an improvised search.
|
|
17
|
+
*/
|
|
18
|
+
export class MissingBinaryError extends Error {
|
|
19
|
+
readonly binary: string;
|
|
20
|
+
constructor(binary: string, tool: string) {
|
|
21
|
+
super(
|
|
22
|
+
`${tool}: the "${binary}" binary is not installed (searched PATH); ` +
|
|
23
|
+
`no results were gathered and no substitute search was attempted. ` +
|
|
24
|
+
`Installing binaries is the expedition's one approval — install ` +
|
|
25
|
+
`${binary} and re-run.`,
|
|
26
|
+
);
|
|
27
|
+
this.name = "MissingBinaryError";
|
|
28
|
+
this.binary = binary;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Resolve an executable file on PATH; undefined when not found. */
|
|
33
|
+
export function findBinary(
|
|
34
|
+
name: string,
|
|
35
|
+
env: Env = process.env,
|
|
36
|
+
): string | undefined {
|
|
37
|
+
const pathValue = env.PATH ?? "";
|
|
38
|
+
for (const dir of pathValue.split(delimiter)) {
|
|
39
|
+
if (dir.length === 0) continue;
|
|
40
|
+
const candidate = join(dir, name);
|
|
41
|
+
try {
|
|
42
|
+
accessSync(candidate, constants.X_OK);
|
|
43
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
44
|
+
} catch {
|
|
45
|
+
// absent or not executable in this directory — keep scanning
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Escape a literal so it can be embedded in a ripgrep regex. */
|
|
52
|
+
export function escapeRegExp(literal: string): string {
|
|
53
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** ripgrep reports paths relative to the cwd it ran in; normalize harder. */
|
|
57
|
+
export function relativeToTarget(path: string, targetRoot: string): string {
|
|
58
|
+
if (path.startsWith("./")) return path.slice(2);
|
|
59
|
+
const root = targetRoot.endsWith("/") ? targetRoot : `${targetRoot}/`;
|
|
60
|
+
if (path.startsWith(root)) return path.slice(root.length);
|
|
61
|
+
return path;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The first non-blank line of a probe's text output, trimmed. */
|
|
65
|
+
export function firstLine(text: string): string {
|
|
66
|
+
return (
|
|
67
|
+
text
|
|
68
|
+
.split("\n")
|
|
69
|
+
.map((l) => l.trim())
|
|
70
|
+
.find((l) => l.length > 0) ?? ""
|
|
71
|
+
);
|
|
72
|
+
}
|