@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,634 @@
1
+ /**
2
+ * Soundings: deterministic verification of what the Chart asserts
3
+ * specs/tools/spec.md.
4
+ *
5
+ * A sounding never judges — it checks. `sound.anchor` verifies that an
6
+ * anchor cited by a chart entry resolves: a file anchor's file exists, its
7
+ * cited line range is within the file, and any cited content is present at
8
+ * that range; a manifest-key anchor's key exists in the cited manifest; a
9
+ * receipt anchor's id resolves in the ship's log. `sound.edge` verifies an
10
+ * asserted fairway through two deterministic means — a dependency declared
11
+ * in the source vessel's manifest, and name-based references found by a
12
+ * sweep scoped to the source vessel's own paths. Every sounding returns
13
+ * exactly one verdict (`confirmed` / `refuted` / `unconfirmed`) with the
14
+ * anchored evidence that produced it; a `confirmed` verdict without
15
+ * evidence cannot even be constructed. No model judgment participates.
16
+ *
17
+ * Soundings are pure functions over the probe layer (design.md, decision 1)
18
+ * and read-only toward the Chart by construction (decision 4): they accept
19
+ * asserted entries as input values, hold no store handle, and expose no
20
+ * write path. Acting on a verdict — including any trust change — is the
21
+ * Cartographer's separate write.
22
+ */
23
+ import { readdirSync, readFileSync, statSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import type { Anchor, FairwayEntry, VesselEntry } from "../types";
26
+ import { resolveInsideTarget } from "../perimeter";
27
+ import { escapeRegExp } from "./shared";
28
+ import { sweep, type SweepChunk } from "./sweep";
29
+ import {
30
+ ManifestParseError,
31
+ manifestKindOf,
32
+ readManifest,
33
+ type ManifestOutcome,
34
+ } from "./manifests";
35
+ import { readReceipts, resolveReceiptAnchor, type Receipt } from "./log";
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // The shared verdict shape (tasks.md 1.1)
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /** The closed sounding vocabulary: exactly one per result. */
42
+ export const SOUNDING_VERDICTS = ["confirmed", "refuted", "unconfirmed"] as const;
43
+
44
+ export type SoundingVerdict = (typeof SOUNDING_VERDICTS)[number];
45
+
46
+ /** Raised for malformed sounding inputs; never stands in for a verdict. */
47
+ export class SoundingError extends Error {
48
+ constructor(message: string) {
49
+ super(`sound: ${message}`);
50
+ this.name = "SoundingError";
51
+ }
52
+ }
53
+
54
+ /** What a check actually found, and the anchor it was found at. */
55
+ export interface SoundingEvidence {
56
+ /** What was found, in words or as verbatim content (e.g. the lines at a cited range). */
57
+ found: string;
58
+ /** Where it was found, in the core-foundation Anchor shape (design.md, decision 3). */
59
+ anchor: Anchor;
60
+ }
61
+
62
+ /** The shared result: one verdict plus the evidence that produced it. */
63
+ export interface SoundingResult {
64
+ verdict: SoundingVerdict;
65
+ evidence: SoundingEvidence[];
66
+ /** Compact human-readable summary of the sounding outcome. */
67
+ report: string;
68
+ }
69
+
70
+ /** Runtime shape check for the core-foundation anchor variants. */
71
+ function isAnchorShaped(value: unknown): value is Anchor {
72
+ if (typeof value !== "object" || value === null) return false;
73
+ const a = value as Record<string, unknown>;
74
+ if (a.type === "file") return typeof a.path === "string" && a.path.length > 0;
75
+ if (a.type === "manifest") {
76
+ return typeof a.path === "string" && typeof a.key === "string";
77
+ }
78
+ if (a.type === "receipt") return typeof a.id === "string";
79
+ return false;
80
+ }
81
+
82
+ /**
83
+ * Smart constructor for sounding results. Enforces the spec invariants at
84
+ * the only place they can be enforced: evidence is always anchor-shaped,
85
+ * and a `confirmed` verdict with an empty evidence list is refused — a
86
+ * confirmation without evidence does not exist in this module.
87
+ */
88
+ export function soundingResult(
89
+ verdict: SoundingVerdict,
90
+ evidence: SoundingEvidence[],
91
+ report: string,
92
+ ): SoundingResult {
93
+ if (!(SOUNDING_VERDICTS as readonly string[]).includes(verdict)) {
94
+ throw new SoundingError(
95
+ `unknown verdict ${JSON.stringify(verdict)}; the vocabulary is ${SOUNDING_VERDICTS.join(", ")}`,
96
+ );
97
+ }
98
+ for (const item of evidence) {
99
+ if (typeof item.found !== "string" || !isAnchorShaped(item.anchor)) {
100
+ throw new SoundingError(
101
+ `every evidence must pair what was found with a core-foundation anchor, got ${JSON.stringify(item)}`,
102
+ );
103
+ }
104
+ }
105
+ if (verdict === "confirmed" && evidence.length === 0) {
106
+ throw new SoundingError(
107
+ "a confirmed sounding must carry at least one anchored evidence; " +
108
+ "refusing to construct a confirmation without evidence",
109
+ );
110
+ }
111
+ return { verdict, evidence, report };
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // sound.anchor — verify an anchor resolves (tasks.md 2.1, 2.2)
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /**
119
+ * One anchor under survey. The cited-content expectation lives on the
120
+ * sounding input, not on the anchor: the foundation Anchor shape carries
121
+ * only path and (optional) line — the claim "this is what the range holds"
122
+ * is the chart entry's citation, checked here against ground truth.
123
+ */
124
+ export interface AnchorSounding {
125
+ /** The anchor exactly as cited by the chart entry. */
126
+ anchor: Anchor;
127
+ /** For file anchors: the content the entry claims sits at the cited range. */
128
+ content?: string;
129
+ /** For file anchors: the last line of the cited range; defaults to `anchor.line`. */
130
+ endLine?: number;
131
+ }
132
+
133
+ export interface AnchorSoundingResult extends SoundingResult {
134
+ /**
135
+ * `sound.anchor` checks existence and content against ground truth, so it
136
+ * yields `confirmed` or `refuted` — never `unconfirmed` (design.md,
137
+ * decision 2).
138
+ */
139
+ verdict: "confirmed" | "refuted";
140
+ }
141
+
142
+ /** Verify that an anchor as cited by a chart entry resolves. */
143
+ export function soundAnchor(targetRoot: string, sounding: AnchorSounding): AnchorSoundingResult {
144
+ const { anchor } = sounding;
145
+ if (!isAnchorShaped(anchor)) {
146
+ throw new SoundingError(`not a citable anchor: ${JSON.stringify(anchor)}`);
147
+ }
148
+ switch (anchor.type) {
149
+ case "file":
150
+ return soundFileAnchor(targetRoot, anchor, sounding);
151
+ case "manifest":
152
+ return soundManifestAnchor(targetRoot, anchor);
153
+ case "receipt":
154
+ return soundReceiptAnchor(targetRoot, anchor);
155
+ }
156
+ }
157
+
158
+ function refutedAnchor(
159
+ evidence: SoundingEvidence,
160
+ report: string,
161
+ ): AnchorSoundingResult {
162
+ return { ...soundingResult("refuted", [evidence], report), verdict: "refuted" };
163
+ }
164
+
165
+ function confirmedAnchor(
166
+ evidence: SoundingEvidence,
167
+ report: string,
168
+ ): AnchorSoundingResult {
169
+ return { ...soundingResult("confirmed", [evidence], report), verdict: "confirmed" };
170
+ }
171
+
172
+ type FileAnchor = Anchor & { type: "file" };
173
+
174
+ function soundFileAnchor(
175
+ targetRoot: string,
176
+ anchor: FileAnchor,
177
+ sounding: AnchorSounding,
178
+ ): AnchorSoundingResult {
179
+ const { content, endLine } = sounding;
180
+ const start = anchor.line;
181
+ if (start === undefined && (content !== undefined || endLine !== undefined)) {
182
+ throw new SoundingError(
183
+ `a content or range check needs a cited line; ${anchor.path} cites only a file`,
184
+ );
185
+ }
186
+ if (start !== undefined && endLine !== undefined && endLine < start) {
187
+ throw new SoundingError(`inverted line range ${start}-${endLine} for ${anchor.path}`);
188
+ }
189
+ const echo = (): Anchor =>
190
+ ({
191
+ type: "file",
192
+ path: anchor.path,
193
+ ...(start !== undefined ? { line: start } : {}),
194
+ }) as Anchor;
195
+
196
+ const abs = resolveInsideTarget(targetRoot, anchor.path);
197
+ if (abs === undefined) {
198
+ return refutedAnchor(
199
+ { found: "the cited path escapes the target root; it resolves to nothing in the province", anchor: echo() },
200
+ `refuted: ${anchor.path} escapes the target root`,
201
+ );
202
+ }
203
+
204
+ let text: string;
205
+ try {
206
+ const stats = statSync(abs);
207
+ if (!stats.isFile()) {
208
+ return refutedAnchor(
209
+ { found: `${anchor.path} exists but is not a regular file`, anchor: echo() },
210
+ `refuted: ${anchor.path} is not a regular file`,
211
+ );
212
+ }
213
+ text = readFileSync(abs, "utf8");
214
+ } catch (err) {
215
+ const code = (err as NodeJS.ErrnoException).code;
216
+ if (code === "ENOENT" || code === "ENOTDIR") {
217
+ return refutedAnchor(
218
+ { found: `${anchor.path} does not exist in the target`, anchor: echo() },
219
+ `refuted: ${anchor.path} does not exist in the target`,
220
+ );
221
+ }
222
+ throw err;
223
+ }
224
+
225
+ const lines = text.split("\n").map((l) => l.replace(/\r$/, ""));
226
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
227
+
228
+ // File-only citation: existence is the whole claim.
229
+ if (start === undefined) {
230
+ return confirmedAnchor(
231
+ { found: `${anchor.path} exists; ${lines.length} line(s); no line cited`, anchor: echo() },
232
+ `confirmed: ${anchor.path} exists (${lines.length} lines)`,
233
+ );
234
+ }
235
+ const end = endLine ?? start;
236
+
237
+ const range = start === end ? `${start}` : `${start}-${end}`;
238
+ if (start < 1 || end > lines.length) {
239
+ return refutedAnchor(
240
+ {
241
+ found: `cited range ${range} is not within ${anchor.path}: the file has ${lines.length} line(s)`,
242
+ anchor: echo(),
243
+ },
244
+ `refuted: cited range ${range} is out of range; ${anchor.path} has ${lines.length} line(s)`,
245
+ );
246
+ }
247
+
248
+ const actual = lines.slice(start - 1, end).join("\n");
249
+ if (content !== undefined) {
250
+ // The citation claims this content sits at the cited range. Lines are
251
+ // compared trimmed per side, so pure re-indentation stays confirmed
252
+ // while any content drift refutes (design.md: that brittleness is the
253
+ // product).
254
+ const cited = content.split("\n");
255
+ const present =
256
+ cited.length === end - start + 1 &&
257
+ cited.every((l, i) => l.trim() === lines[start - 1 + i]!.trim());
258
+ if (!present) {
259
+ return refutedAnchor(
260
+ {
261
+ found: `the cited content is not what ${anchor.path}:${range} holds; the range holds:\n${actual}`,
262
+ anchor: echo(),
263
+ },
264
+ `refuted: content drift at ${anchor.path}:${range} — the cited content is not what the range holds`,
265
+ );
266
+ }
267
+ return confirmedAnchor(
268
+ { found: `${anchor.path}:${range} holds:\n${actual}`, anchor: echo() },
269
+ `confirmed: ${anchor.path}:${range} holds the cited content`,
270
+ );
271
+ }
272
+ return confirmedAnchor(
273
+ { found: `${anchor.path}:${range} holds:\n${actual}`, anchor: echo() },
274
+ `confirmed: ${anchor.path}:${range} is within the file (${lines.length} lines)`,
275
+ );
276
+ }
277
+
278
+ /** Cap a key list so refutation reports stay bounded on huge manifests. */
279
+ function listCapped(items: string[], cap = 8): string {
280
+ const listed = items.slice(0, cap).join(", ");
281
+ return items.length > cap ? `${listed}, … (+${items.length - cap} more)` : listed;
282
+ }
283
+
284
+ type ManifestAnchor = Anchor & { type: "manifest" };
285
+
286
+ function soundManifestAnchor(
287
+ targetRoot: string,
288
+ anchor: ManifestAnchor,
289
+ ): AnchorSoundingResult {
290
+ const echo = (): Anchor => ({ type: "manifest", path: anchor.path, key: anchor.key });
291
+ const abs = resolveInsideTarget(targetRoot, anchor.path);
292
+ if (abs === undefined) {
293
+ return refutedAnchor(
294
+ { found: "the cited manifest path escapes the target root", anchor: echo() },
295
+ `refuted: ${anchor.path} escapes the target root`,
296
+ );
297
+ }
298
+ let outcome: ManifestOutcome;
299
+ try {
300
+ outcome = readManifest(targetRoot, anchor.path);
301
+ } catch (err) {
302
+ if (err instanceof ManifestParseError) {
303
+ return refutedAnchor(
304
+ { found: `${anchor.path} cannot be parsed: ${err.message}`, anchor: echo() },
305
+ `refuted: the cited manifest ${anchor.path} cannot be parsed`,
306
+ );
307
+ }
308
+ const code = (err as NodeJS.ErrnoException).code;
309
+ if (code === "ENOENT" || code === "ENOTDIR") {
310
+ return refutedAnchor(
311
+ { found: `${anchor.path} does not exist in the target`, anchor: echo() },
312
+ `refuted: ${anchor.path} does not exist in the target`,
313
+ );
314
+ }
315
+ throw err;
316
+ }
317
+ if (!("supported" in outcome)) {
318
+ const fact = outcome.facts.find((f) => f.key === anchor.key);
319
+ if (fact !== undefined) {
320
+ return confirmedAnchor(
321
+ {
322
+ found: `${anchor.path}#${anchor.key} = ${JSON.stringify(fact.value)}`,
323
+ anchor: echo(),
324
+ },
325
+ `confirmed: ${anchor.path}#${anchor.key} exists`,
326
+ );
327
+ }
328
+ return refutedAnchor(
329
+ {
330
+ found: `no key "${anchor.key}" in ${anchor.path}; keys present: ${listCapped(outcome.facts.map((f) => f.key))}`,
331
+ anchor: echo(),
332
+ },
333
+ `refuted: no key "${anchor.key}" in ${anchor.path}`,
334
+ );
335
+ }
336
+ return refutedAnchor(
337
+ { found: `${anchor.path}: ${outcome.reason}`, anchor: echo() },
338
+ `refuted: ${anchor.path} is not a manifest soundings can read`,
339
+ );
340
+ }
341
+
342
+ type ReceiptAnchor = Anchor & { type: "receipt" };
343
+
344
+ function soundReceiptAnchor(
345
+ targetRoot: string,
346
+ anchor: ReceiptAnchor,
347
+ ): AnchorSoundingResult {
348
+ const echo = (): Anchor => ({ type: "receipt", id: anchor.id });
349
+ // A corrupt log fails loudly through the log tool; only a dead id refutes.
350
+ const receipt: Receipt | undefined = resolveReceiptAnchor(targetRoot, anchor);
351
+ if (receipt !== undefined) {
352
+ return confirmedAnchor(
353
+ {
354
+ found: `receipt ${receipt.id}: ${receipt.command} — ${receipt.outcome}`,
355
+ anchor: echo(),
356
+ },
357
+ `confirmed: receipt ${anchor.id} resolves in the ship's log`,
358
+ );
359
+ }
360
+ const onFile = readReceipts(targetRoot).length;
361
+ return refutedAnchor(
362
+ {
363
+ found: `no receipt ${anchor.id} resolves in the ship's log (${onFile} receipt(s) on file)`,
364
+ anchor: echo(),
365
+ },
366
+ `refuted: no receipt ${anchor.id} in the ship's log`,
367
+ );
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // sound.edge — verify an asserted fairway (tasks.md 3.1–3.3)
372
+ // ---------------------------------------------------------------------------
373
+
374
+ /** One asserted fairway plus the two vessels it runs between, as charted. */
375
+ export interface EdgeSounding {
376
+ /** The asserted fairway entry, exactly as charted. */
377
+ fairway: FairwayEntry;
378
+ /** The vessel the fairway departs from; its paths scope both means. */
379
+ source: VesselEntry;
380
+ /** The vessel the fairway arrives at; its name and id drive the match. */
381
+ target: VesselEntry;
382
+ }
383
+
384
+ /** The two deterministic means; reports always come back in this order. */
385
+ export type EdgeMeans = "manifest" | "references";
386
+
387
+ /** What one deterministic means found for an asserted fairway. */
388
+ export interface EdgeMeansReport {
389
+ means: EdgeMeans;
390
+ /** Whether this means found deterministic support. */
391
+ found: boolean;
392
+ /**
393
+ * What the means found. A negative report describes what was checked —
394
+ * it never claims the fairway is absent (dynamic wiring exists).
395
+ */
396
+ report: string;
397
+ /** Anchored evidence from this means; empty when it found none. */
398
+ evidence: SoundingEvidence[];
399
+ }
400
+
401
+ export interface EdgeSoundingResult extends SoundingResult {
402
+ /**
403
+ * `sound.edge` cannot disprove a fairway, so it yields `confirmed` or
404
+ * `unconfirmed` — never `refuted` (design.md, decision 2).
405
+ */
406
+ verdict: "confirmed" | "unconfirmed";
407
+ from: string;
408
+ to: string;
409
+ /** One report per means, in fixed order: manifest, then references. */
410
+ means: EdgeMeansReport[];
411
+ }
412
+
413
+ /** Verify an asserted fairway through the two deterministic means. */
414
+ export function soundEdge(targetRoot: string, sounding: EdgeSounding): EdgeSoundingResult {
415
+ const { fairway, source, target } = sounding;
416
+ if (fairway.kind !== "fairway" || source.kind !== "vessel" || target.kind !== "vessel") {
417
+ throw new SoundingError(
418
+ "sound.edge takes one fairway and its two vessels, as charted entries",
419
+ );
420
+ }
421
+ if (fairway.from !== source.id || fairway.to !== target.id) {
422
+ throw new SoundingError(
423
+ `fairway ${fairway.id} runs ${fairway.from} → ${fairway.to}, but the vessels given are ${source.id} → ${target.id}`,
424
+ );
425
+ }
426
+
427
+ const manifestMeans = manifestDeclarationMeans(targetRoot, source, target);
428
+ const referenceMeans = sourceReferenceMeans(targetRoot, source, target);
429
+ const confirmed = manifestMeans.found || referenceMeans.found;
430
+ const verdict: "confirmed" | "unconfirmed" = confirmed ? "confirmed" : "unconfirmed";
431
+ const evidence = [...manifestMeans.evidence, ...referenceMeans.evidence];
432
+ const report = confirmed
433
+ ? `confirmed: fairway ${source.id} → ${target.id} — ${[manifestMeans, referenceMeans]
434
+ .filter((m) => m.found)
435
+ .map((m) => m.report)
436
+ .join("; ")}`
437
+ : `unconfirmed: fairway ${source.id} → ${target.id} — neither deterministic means found ` +
438
+ `support (${manifestMeans.report}; ${referenceMeans.report}); unconfirmed is not ` +
439
+ `refutation, and the fairway may run through means these checks cannot see`;
440
+ return {
441
+ ...soundingResult(verdict, evidence, report),
442
+ verdict,
443
+ from: source.id,
444
+ to: target.id,
445
+ means: [manifestMeans, referenceMeans],
446
+ };
447
+ }
448
+
449
+ /**
450
+ * A declared dependency names the target vessel when it matches exactly, or
451
+ * when it names a module of that vessel's family (`hadoop` covers
452
+ * `hadoop-common`, `hadoop-client`, …) — the same family rule the sea-trial
453
+ * oracle applies, so a manifest-true fairway is never sounded unconfirmed
454
+ * merely for declaring a submodule.
455
+ */
456
+ function namesVessel(depName: string, target: VesselEntry): boolean {
457
+ return (
458
+ depName === target.name ||
459
+ depName === target.id ||
460
+ (target.name.length > 0 && depName.startsWith(`${target.name}-`)) ||
461
+ (target.id.length > 0 && depName.startsWith(`${target.id}-`))
462
+ );
463
+ }
464
+
465
+ /**
466
+ * Vessel-local manifest discovery: the vessel's own declaration files under
467
+ * its charted paths. Hidden directories and node_modules are skipped —
468
+ * installed and vendored trees are not the vessel's own declarations. A
469
+ * charted path that escapes the province (`..`, or an in-target symlink
470
+ * pointing outside) contributes nothing: nothing outside the perimeter is
471
+ * walked or read (specs/permissions/spec.md).
472
+ */
473
+ function findManifestsUnder(targetRoot: string, paths: string[]): string[] {
474
+ const found = new Set<string>();
475
+ const visit = (rel: string): void => {
476
+ const abs = join(targetRoot, rel);
477
+ let stats;
478
+ try {
479
+ stats = statSync(abs);
480
+ } catch {
481
+ return; // a path that no longer exists contributes nothing
482
+ }
483
+ if (stats.isFile()) {
484
+ if (manifestKindOf(rel) !== undefined) found.add(rel);
485
+ return;
486
+ }
487
+ if (!stats.isDirectory()) return;
488
+ const entries = readdirSync(abs, { withFileTypes: true }).sort((a, b) =>
489
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
490
+ );
491
+ for (const de of entries) {
492
+ if (de.isDirectory()) {
493
+ if (de.name.startsWith(".") || de.name === "node_modules") continue;
494
+ visit(rel === "." ? de.name : `${rel}/${de.name}`);
495
+ } else if (de.isFile()) {
496
+ visit(rel === "." ? de.name : `${rel}/${de.name}`);
497
+ }
498
+ }
499
+ };
500
+ for (const p of paths) {
501
+ const rel = p.replace(/\/+$/, "");
502
+ if (rel === "") continue;
503
+ if (resolveInsideTarget(targetRoot, rel) === undefined) continue;
504
+ visit(rel);
505
+ }
506
+ return [...found].sort();
507
+ }
508
+
509
+ /** Means 1: a dependency on the target declared in the source's manifests. */
510
+ function manifestDeclarationMeans(
511
+ targetRoot: string,
512
+ source: VesselEntry,
513
+ target: VesselEntry,
514
+ ): EdgeMeansReport {
515
+ const manifestPaths = findManifestsUnder(targetRoot, source.paths);
516
+ const evidence: SoundingEvidence[] = [];
517
+ const declaredIn: string[] = [];
518
+ const problems: string[] = [];
519
+ for (const path of manifestPaths) {
520
+ try {
521
+ const outcome = readManifest(targetRoot, path);
522
+ if ("supported" in outcome) {
523
+ problems.push(`${path}: ${outcome.reason}`);
524
+ continue;
525
+ }
526
+ for (const dep of outcome.dependencies) {
527
+ if (namesVessel(dep.name, target)) {
528
+ declaredIn.push(
529
+ `${path}#${dep.fact.key}${dep.version !== undefined ? ` (${dep.version})` : ""}`,
530
+ );
531
+ evidence.push({
532
+ found: `${path} declares ${JSON.stringify(dep.name)}` +
533
+ (dep.version !== undefined ? ` at ${JSON.stringify(dep.version)}` : ""),
534
+ anchor: { type: "manifest", path, key: dep.fact.key },
535
+ });
536
+ }
537
+ }
538
+ } catch (err) {
539
+ problems.push(`${path}: ${err instanceof Error ? err.message : String(err)}`);
540
+ }
541
+ }
542
+ if (evidence.length > 0) {
543
+ return { means: "manifest", found: true, report: `declared in ${declaredIn.join(", ")}`, evidence };
544
+ }
545
+ const checked =
546
+ manifestPaths.length === 0
547
+ ? `no supported manifest found under the source vessel's paths [${source.paths.map((p) => JSON.stringify(p)).join(", ")}]`
548
+ : `no declaration of ${JSON.stringify(target.name)} in the ${manifestPaths.length} manifest(s) read under the source vessel's paths`;
549
+ return {
550
+ means: "manifest",
551
+ found: false,
552
+ report: problems.length > 0 ? `${checked}; read problems: ${problems.join("; ")}` : checked,
553
+ evidence: [],
554
+ };
555
+ }
556
+
557
+ /** Name-based reference patterns: word-bounded regexes for the target's name and id. */
558
+ function referencePatterns(target: VesselEntry): string[] {
559
+ return [...new Set([target.name, target.id])].map((name) =>
560
+ /^\w/.test(name) && /\w$/.test(name) ? `\\b${escapeRegExp(name)}\\b` : escapeRegExp(name),
561
+ );
562
+ }
563
+
564
+ /** rg include-globs that scope a sweep to the source vessel's own paths. */
565
+ function scopeGlobs(targetRoot: string, paths: string[]): string[] {
566
+ const globs: string[] = [];
567
+ for (const p of paths) {
568
+ const rel = p.replace(/\/+$/, "");
569
+ if (rel === "") continue;
570
+ if (resolveInsideTarget(targetRoot, rel) === undefined) continue;
571
+ let stats;
572
+ try {
573
+ stats = statSync(join(targetRoot, rel));
574
+ } catch {
575
+ continue; // a path that no longer exists contributes nothing
576
+ }
577
+ if (stats.isFile()) globs.push(rel);
578
+ else if (stats.isDirectory()) globs.push(rel === "." ? "**" : `${rel}/**`);
579
+ }
580
+ return [...new Set(globs)];
581
+ }
582
+
583
+ /** Means 2: references to the target found by sweeping the source's paths. */
584
+ function sourceReferenceMeans(
585
+ targetRoot: string,
586
+ source: VesselEntry,
587
+ target: VesselEntry,
588
+ ): EdgeMeansReport {
589
+ if (source.paths.length === 0) {
590
+ return {
591
+ means: "references",
592
+ found: false,
593
+ report: "the source vessel charts no paths; there is nothing to sweep",
594
+ evidence: [],
595
+ };
596
+ }
597
+ const patterns = referencePatterns(target);
598
+ const globs = scopeGlobs(targetRoot, source.paths);
599
+ const seen = new Set<string>();
600
+ const chunks: SweepChunk[] = [];
601
+ for (const pattern of patterns) {
602
+ for (const glob of globs) {
603
+ // A missing ripgrep or a bad root propagates from the probe layer —
604
+ // the tools spec forbids substituting a search, and a tool failure is
605
+ // not a negative result.
606
+ for (const chunk of sweep(targetRoot, pattern, { glob }).chunks) {
607
+ const key = `${chunk.path}:${chunk.line}`;
608
+ if (seen.has(key)) continue;
609
+ seen.add(key);
610
+ chunks.push(chunk);
611
+ }
612
+ }
613
+ }
614
+ chunks.sort((a, b) => (a.path === b.path ? a.line - b.line : a.path < b.path ? -1 : 1));
615
+
616
+ if (chunks.length > 0) {
617
+ const at = chunks.map((c) => `${c.path}:${c.line}`);
618
+ return {
619
+ means: "references",
620
+ found: true,
621
+ report: `referenced at ${listCapped(at, 5)}`,
622
+ evidence: chunks.map((c) => ({ found: c.text, anchor: c.anchor })),
623
+ };
624
+ }
625
+ return {
626
+ means: "references",
627
+ found: false,
628
+ report:
629
+ globs.length === 0
630
+ ? `the source vessel's paths match no existing location; there was nothing to sweep`
631
+ : `a name-based sweep for [${patterns.map((p) => JSON.stringify(p)).join(", ")}] across the source vessel's paths found 0 referencing lines`,
632
+ evidence: [],
633
+ };
634
+ }