@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -0
  3. package/adapters/README.md +226 -0
  4. package/adapters/omp/portolan-mcp +19 -0
  5. package/adapters/opencode/expedition-launcher +70 -0
  6. package/adapters/opencode/install.test.ts +105 -0
  7. package/adapters/opencode/install.ts +357 -0
  8. package/adapters/pi/portolan-mcp +19 -0
  9. package/adapters/scheduling/night-watch.cron +23 -0
  10. package/core/schema/chart.schema.json +154 -0
  11. package/core/src/bin/portolan.ts +84 -0
  12. package/core/src/chart-io.rollback-fixture.ts +55 -0
  13. package/core/src/chart-io.ts +121 -0
  14. package/core/src/chart-store.ts +137 -0
  15. package/core/src/chartroom/cli.ts +63 -0
  16. package/core/src/chartroom/render.ts +213 -0
  17. package/core/src/chartroom/review-template.html +232 -0
  18. package/core/src/chartroom/review.ts +109 -0
  19. package/core/src/chartroom/template.html +1090 -0
  20. package/core/src/fan-in.ts +84 -0
  21. package/core/src/harbor/chat-format.ts +154 -0
  22. package/core/src/harbor/cli.ts +178 -0
  23. package/core/src/harbor/errors.ts +22 -0
  24. package/core/src/harbor/fingerprint.ts +29 -0
  25. package/core/src/harbor/history.ts +178 -0
  26. package/core/src/harbor/launcher.ts +155 -0
  27. package/core/src/harbor/night-policy.ts +64 -0
  28. package/core/src/harbor/proposals.ts +324 -0
  29. package/core/src/harbor/run.ts +72 -0
  30. package/core/src/harbor/settings.ts +108 -0
  31. package/core/src/harbor/snapshot.ts +187 -0
  32. package/core/src/harbor/watch.ts +103 -0
  33. package/core/src/index.ts +28 -0
  34. package/core/src/notices.ts +117 -0
  35. package/core/src/perimeter.ts +44 -0
  36. package/core/src/server/adapter-boundary.ts +66 -0
  37. package/core/src/server/main.ts +27 -0
  38. package/core/src/server/registry.ts +609 -0
  39. package/core/src/server/server.ts +123 -0
  40. package/core/src/server/test-harness.ts +161 -0
  41. package/core/src/sheets.ts +151 -0
  42. package/core/src/staleness.ts +203 -0
  43. package/core/src/tools/log.ts +215 -0
  44. package/core/src/tools/manifests.ts +912 -0
  45. package/core/src/tools/neighborhood.ts +423 -0
  46. package/core/src/tools/shared.ts +72 -0
  47. package/core/src/tools/sound.ts +634 -0
  48. package/core/src/tools/sweep.ts +198 -0
  49. package/core/src/tools/symbols.ts +176 -0
  50. package/core/src/tools/trust-report.ts +193 -0
  51. package/core/src/types.ts +162 -0
  52. package/core/src/validate.ts +106 -0
  53. package/package.json +34 -0
  54. package/skill/SKILL.md +279 -0
  55. package/skill/examples/sailing-directions-example.md +35 -0
  56. package/skill/sailing-directions.template.md +59 -0
  57. package/skill/verify/checks.ts +476 -0
  58. package/skill/verify/dry-run.ts +738 -0
  59. 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
+ }