@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,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Portolan MCP server: one stdio server exposing the whole v1 toolset
|
|
3
|
+
* through the registry table. Two rules live here and nowhere else
|
|
4
|
+
* (design.md, decisions 1 and 4):
|
|
5
|
+
*
|
|
6
|
+
* 1. Pass-through. A tool's result is returned verbatim — structured
|
|
7
|
+
* (`structuredContent`) plus the same value as JSON text for older
|
|
8
|
+
* clients. The server never renames, reinterprets, or flattens.
|
|
9
|
+
* 2. The not-crash/not-swallow split. A rejection from an underlying tool
|
|
10
|
+
* becomes an MCP tool error (`isError: true`) carrying the tool's own
|
|
11
|
+
* message verbatim; the process keeps serving. Only transport-level
|
|
12
|
+
* failures may terminate the server. A global catch-and-log is
|
|
13
|
+
* deliberately absent: rejections must stay loud.
|
|
14
|
+
*
|
|
15
|
+
* specs/harness/spec.md
|
|
16
|
+
*/
|
|
17
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
18
|
+
import {
|
|
19
|
+
CallToolRequestSchema,
|
|
20
|
+
ListToolsRequestSchema,
|
|
21
|
+
type CallToolResult,
|
|
22
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
23
|
+
import { resolve } from "node:path";
|
|
24
|
+
import pkg from "../../package.json";
|
|
25
|
+
import { TOOL_TABLE, type ToolContext } from "./registry";
|
|
26
|
+
|
|
27
|
+
export const SERVER_INFO = { name: "portolan", version: pkg.version } as const;
|
|
28
|
+
|
|
29
|
+
export interface PortolanServerOptions {
|
|
30
|
+
/** The one province this server serves; every tool is scoped to it. */
|
|
31
|
+
targetRoot: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Raised when a tool call tries to carry its own target root. The spec's
|
|
36
|
+
* words: the server is bound to one province, and changing provinces means
|
|
37
|
+
* launching a new server.
|
|
38
|
+
*/
|
|
39
|
+
export class TargetRedirectError extends Error {
|
|
40
|
+
constructor(launchedRoot: string, requested: string) {
|
|
41
|
+
super(
|
|
42
|
+
`this server is bound to the province at ${launchedRoot} and cannot be redirected ` +
|
|
43
|
+
`to ${requested}; changing provinces means launching a new server`,
|
|
44
|
+
);
|
|
45
|
+
this.name = "TargetRedirectError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The province binding, enforced once at the handler boundary: a call that
|
|
51
|
+
* echoes the launched root passes; a call naming any other root is refused
|
|
52
|
+
* with an error naming the launched target.
|
|
53
|
+
*/
|
|
54
|
+
export function guardTarget(
|
|
55
|
+
args: Record<string, unknown>,
|
|
56
|
+
ctx: ToolContext,
|
|
57
|
+
): void {
|
|
58
|
+
if (args.targetRoot === undefined) return;
|
|
59
|
+
if (typeof args.targetRoot !== "string") {
|
|
60
|
+
throw new TargetRedirectError(ctx.targetRoot, JSON.stringify(args.targetRoot));
|
|
61
|
+
}
|
|
62
|
+
const requested = resolve(args.targetRoot);
|
|
63
|
+
if (requested !== ctx.targetRoot) {
|
|
64
|
+
throw new TargetRedirectError(ctx.targetRoot, requested);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A successful tool call: the tool's result, enveloped for MCP. */
|
|
69
|
+
export function toolSuccess(result: unknown): CallToolResult {
|
|
70
|
+
const value = result as Record<string, unknown>;
|
|
71
|
+
return {
|
|
72
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
73
|
+
...(value !== null && typeof value === "object" ? { structuredContent: value } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A rejected tool call: the rejection's message, verbatim, as a tool error. */
|
|
78
|
+
export function toolError(err: unknown): CallToolResult {
|
|
79
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
80
|
+
return {
|
|
81
|
+
content: [{ type: "text", text: message }],
|
|
82
|
+
isError: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Build the server bound to one province. Connect it to a transport. */
|
|
87
|
+
export function createPortolanServer(options: PortolanServerOptions): Server {
|
|
88
|
+
const ctx: ToolContext = { targetRoot: options.targetRoot };
|
|
89
|
+
const server = new Server(SERVER_INFO, {
|
|
90
|
+
capabilities: { tools: {} },
|
|
91
|
+
instructions:
|
|
92
|
+
`Portolan — the Cartographer's tools for the province at ${options.targetRoot}. ` +
|
|
93
|
+
`Every result is anchored and trust-labeled; every write lands under ` +
|
|
94
|
+
`.portolan/ inside the province. Tool errors carry the underlying ` +
|
|
95
|
+
`tool's message; they never mean the server died.`,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
server.setRequestHandler(ListToolsRequestSchema, () => ({
|
|
99
|
+
tools: TOOL_TABLE.map((spec) => ({
|
|
100
|
+
name: spec.name,
|
|
101
|
+
description: spec.description,
|
|
102
|
+
inputSchema: spec.inputSchema,
|
|
103
|
+
})),
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
107
|
+
const { name, arguments: args } = request.params;
|
|
108
|
+
const spec = TOOL_TABLE.find((tool) => tool.name === name);
|
|
109
|
+
if (spec === undefined) {
|
|
110
|
+
// An unknown tool is a malformed call, not a tool rejection: report it
|
|
111
|
+
// as a protocol error while the server keeps serving.
|
|
112
|
+
throw new Error(`unknown tool ${JSON.stringify(name)}; call tools/list for the v1 toolset`);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
guardTarget(args ?? {}, ctx);
|
|
116
|
+
return toolSuccess(await spec.handler(args ?? {}, ctx));
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return toolError(err);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return server;
|
|
123
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test harness for the MCP server: builds provinces, launches the real
|
|
3
|
+
* entry point as a subprocess over stdio, and hands a connected SDK client
|
|
4
|
+
* to the test. Every server launch in the suite goes through here, so the
|
|
5
|
+
* parity tests and the integration tests exercise the identical artifact.
|
|
6
|
+
*/
|
|
7
|
+
import { afterAll, expect } from "bun:test";
|
|
8
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, symlinkSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
12
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
13
|
+
import { findBinary } from "../tools/shared";
|
|
14
|
+
|
|
15
|
+
export const SERVER_ENTRY = join(import.meta.dir, "main.ts");
|
|
16
|
+
|
|
17
|
+
/** The repo's test doubles (ctags) and fixtures root. */
|
|
18
|
+
export const fixturesBin = join(import.meta.dir, "..", "..", "test", "fixtures", "bin");
|
|
19
|
+
export const manifestFixtures = join(
|
|
20
|
+
import.meta.dir,
|
|
21
|
+
"..",
|
|
22
|
+
"..",
|
|
23
|
+
"test",
|
|
24
|
+
"fixtures",
|
|
25
|
+
"manifests",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const targets: string[] = [];
|
|
29
|
+
afterAll(() => {
|
|
30
|
+
while (targets.length > 0) rmSync(targets.pop() as string, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build a small province: two TypeScript sources, all five manifests, a
|
|
35
|
+
* README. The same shape the probe-tool tests use, so results here are
|
|
36
|
+
* comparable with the direct tool results asserted in those suites.
|
|
37
|
+
*/
|
|
38
|
+
export function makeProvince(): string {
|
|
39
|
+
const target = mkdtempSync(join(tmpdir(), "portolan-mcp-"));
|
|
40
|
+
targets.push(target);
|
|
41
|
+
mkdirSync(join(target, "src"), { recursive: true });
|
|
42
|
+
writeFileSync(
|
|
43
|
+
join(target, "src", "cart.ts"),
|
|
44
|
+
[
|
|
45
|
+
"// A small cart service used by MCP server tests.",
|
|
46
|
+
"",
|
|
47
|
+
"/** CartService is the symbol under survey. */",
|
|
48
|
+
"export class CartService {",
|
|
49
|
+
" items: string[] = [];",
|
|
50
|
+
"",
|
|
51
|
+
" addItem(item: string): void {",
|
|
52
|
+
" this.items.push(item);",
|
|
53
|
+
" }",
|
|
54
|
+
"}",
|
|
55
|
+
].join("\n") + "\n",
|
|
56
|
+
);
|
|
57
|
+
writeFileSync(
|
|
58
|
+
join(target, "src", "checkout.ts"),
|
|
59
|
+
[
|
|
60
|
+
'import { CartService } from "./cart";',
|
|
61
|
+
"",
|
|
62
|
+
"export function checkout(cart: CartService): number {",
|
|
63
|
+
" return cart.items.length;",
|
|
64
|
+
"}",
|
|
65
|
+
].join("\n") + "\n",
|
|
66
|
+
);
|
|
67
|
+
for (const manifest of [
|
|
68
|
+
"go.mod",
|
|
69
|
+
"pom.xml",
|
|
70
|
+
"package.json",
|
|
71
|
+
"Cargo.toml",
|
|
72
|
+
"pubspec.yaml",
|
|
73
|
+
]) {
|
|
74
|
+
writeFileSync(
|
|
75
|
+
join(target, manifest),
|
|
76
|
+
readFileSync(join(manifestFixtures, manifest), "utf8"),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
writeFileSync(join(target, "README.md"), "# province\n\nCartService lives in src/cart.ts.\n");
|
|
80
|
+
return target;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The full parent environment, optionally with extra PATH entries prepended. */
|
|
84
|
+
export function childEnv(...pathPrefixes: string[]): Record<string, string> {
|
|
85
|
+
const env: Record<string, string> = {};
|
|
86
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
87
|
+
if (value !== undefined) env[key] = value;
|
|
88
|
+
}
|
|
89
|
+
if (pathPrefixes.length > 0) {
|
|
90
|
+
env.PATH = `${pathPrefixes.join(":")}:${env.PATH ?? ""}`;
|
|
91
|
+
}
|
|
92
|
+
return env;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* An environment whose PATH contains ripgrep but provably no ctags: a temp
|
|
97
|
+
* bin directory holding only an rg symlink, REPLACING the inherited PATH —
|
|
98
|
+
* prepending would still reach a system ctags where one is installed.
|
|
99
|
+
*/
|
|
100
|
+
export function envWithoutCtags(): Record<string, string> | undefined {
|
|
101
|
+
const rg = findBinary("rg");
|
|
102
|
+
if (rg === undefined) return undefined;
|
|
103
|
+
const bin = mkdtempSync(join(tmpdir(), "portolan-mcp-bin-"));
|
|
104
|
+
targets.push(bin);
|
|
105
|
+
symlinkSync(rg, join(bin, "rg"));
|
|
106
|
+
const env = childEnv();
|
|
107
|
+
env.PATH = bin;
|
|
108
|
+
return env;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** An environment with the ctags test double on PATH (rg inherited). */
|
|
112
|
+
export function envWithCtagsDouble(): Record<string, string> {
|
|
113
|
+
return childEnv(fixturesBin);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Launch the real server entry point and run `fn` with a connected client. */
|
|
117
|
+
export async function withServer(
|
|
118
|
+
options: {
|
|
119
|
+
targetRoot: string;
|
|
120
|
+
/** Exact launch vector; defaults to `bun main.ts --target <root>`. */
|
|
121
|
+
command?: { command: string; args: string[] };
|
|
122
|
+
/** Child environment; defaults to the parent env. PATH-sensitive tests override. */
|
|
123
|
+
env?: Record<string, string>;
|
|
124
|
+
name?: string;
|
|
125
|
+
},
|
|
126
|
+
fn: (client: Client) => Promise<void>,
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
const targetRoot = options.targetRoot;
|
|
129
|
+
const launch =
|
|
130
|
+
options.command ?? { command: process.execPath, args: [SERVER_ENTRY, "--target", targetRoot] };
|
|
131
|
+
const client = new Client(
|
|
132
|
+
{ name: options.name ?? "portolan-tests", version: "0.0.0" },
|
|
133
|
+
{ capabilities: {} },
|
|
134
|
+
);
|
|
135
|
+
const transport = new StdioClientTransport({
|
|
136
|
+
command: launch.command,
|
|
137
|
+
args: launch.args,
|
|
138
|
+
env: options.env ?? childEnv(),
|
|
139
|
+
stderr: "pipe",
|
|
140
|
+
});
|
|
141
|
+
try {
|
|
142
|
+
await client.connect(transport);
|
|
143
|
+
await fn(client);
|
|
144
|
+
} finally {
|
|
145
|
+
await client.close();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The structured content of a successful call, as the tool produced it. */
|
|
150
|
+
export function structuredOf(result: Record<string, unknown>): unknown {
|
|
151
|
+
expect(result.structuredContent).toBeObject();
|
|
152
|
+
return result.structuredContent;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Assert a call came back as a tool error and return its verbatim message. */
|
|
156
|
+
export function errorTextOf(result: Record<string, unknown>): string {
|
|
157
|
+
expect(result.isError).toBe(true);
|
|
158
|
+
const content = result.content as Array<{ type: string; text?: string }>;
|
|
159
|
+
expect(content.length).toBeGreaterThan(0);
|
|
160
|
+
return content.map((part) => part.text ?? "").join("\n");
|
|
161
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown sheet rendering — the human layer of the Chart.
|
|
3
|
+
*
|
|
4
|
+
* Sheets are derived outputs: the store renders them from index entries on
|
|
5
|
+
* every write, so hand-edited sheets are overwritten by the next expedition
|
|
6
|
+
* (design.md, trade-offs). One sheet per vessel; behavior absence is always
|
|
7
|
+
* rendered as unsurveyed, never omitted.
|
|
8
|
+
*/
|
|
9
|
+
import { formatAnchor, type Anchor, type IndexedEntry } from "./types";
|
|
10
|
+
|
|
11
|
+
type VesselIndexed = Extract<IndexedEntry, { kind: "vessel" }>;
|
|
12
|
+
|
|
13
|
+
/** File name for a vessel's sheet (sanitized; lives in `.portolan/chart/`). */
|
|
14
|
+
export function sheetFileName(vesselId: string): string {
|
|
15
|
+
return `${vesselId.replace(/[^a-zA-Z0-9._-]+/g, "-")}.md`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function anchorList(anchors: Anchor[]): string {
|
|
19
|
+
return anchors.map(formatAnchor).join("; ");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function entryLine(entry: IndexedEntry, label: string): string {
|
|
23
|
+
return `- ${label} (\`${entry.kind}/${entry.id}\`, trust: ${entry.trust}) — anchor: ${anchorList(entry.anchors)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function section(title: string, lines: string[]): string[] {
|
|
27
|
+
if (lines.length === 0) return [];
|
|
28
|
+
return [`## ${title}`, ...lines, ""];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function renderVesselSheet(
|
|
32
|
+
vessel: VesselIndexed,
|
|
33
|
+
owned: IndexedEntry[],
|
|
34
|
+
stale: boolean
|
|
35
|
+
): string {
|
|
36
|
+
const byKind = (kind: IndexedEntry["kind"]) =>
|
|
37
|
+
owned.filter((e) => e.kind === kind);
|
|
38
|
+
|
|
39
|
+
const fairwaysIn = owned.filter((e) => e.kind === "fairway" && e.to === vessel.id);
|
|
40
|
+
const fairwaysOut = owned.filter((e) => e.kind === "fairway" && e.from === vessel.id);
|
|
41
|
+
const unsurveyedEntries = owned.filter((e) => e.trust === "unsurveyed");
|
|
42
|
+
|
|
43
|
+
const parts: string[] = [];
|
|
44
|
+
parts.push(`# Vessel ${vessel.id} — ${vessel.name}`, "");
|
|
45
|
+
if (stale) {
|
|
46
|
+
parts.push("> **Pending correction** — sources changed since the last survey.", "");
|
|
47
|
+
}
|
|
48
|
+
parts.push("```chart", JSON.stringify(vessel, null, 2), "```", "");
|
|
49
|
+
parts.push(`**Trust:** ${vessel.trust}`, "");
|
|
50
|
+
parts.push("## Behavior", "");
|
|
51
|
+
parts.push(vessel.behavior ?? "Unsurveyed — no behavior recorded.", "");
|
|
52
|
+
parts.push(
|
|
53
|
+
...section(
|
|
54
|
+
"Fairways in",
|
|
55
|
+
fairwaysIn.map((e) =>
|
|
56
|
+
entryLine(e, `from \`${(e as Extract<IndexedEntry, { kind: "fairway" }>).from}\``)
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
);
|
|
60
|
+
parts.push(
|
|
61
|
+
...section(
|
|
62
|
+
"Fairways out",
|
|
63
|
+
fairwaysOut.map((e) =>
|
|
64
|
+
entryLine(e, `to \`${(e as Extract<IndexedEntry, { kind: "fairway" }>).to}\``)
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
);
|
|
68
|
+
parts.push(
|
|
69
|
+
...section(
|
|
70
|
+
"Ports of entry",
|
|
71
|
+
byKind("portOfEntry").map((e) => {
|
|
72
|
+
const p = e as Extract<IndexedEntry, { kind: "portOfEntry" }>;
|
|
73
|
+
return entryLine(e, `\`${p.protocol}\`${p.note ? ` — ${p.note}` : ""}`);
|
|
74
|
+
})
|
|
75
|
+
)
|
|
76
|
+
);
|
|
77
|
+
parts.push(
|
|
78
|
+
...section(
|
|
79
|
+
"Lights",
|
|
80
|
+
byKind("light").map((e) =>
|
|
81
|
+
entryLine(
|
|
82
|
+
e,
|
|
83
|
+
`\`${(e as Extract<IndexedEntry, { kind: "light" }>).name}\``
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
);
|
|
88
|
+
parts.push(
|
|
89
|
+
...section(
|
|
90
|
+
"Beacons",
|
|
91
|
+
byKind("beacon").map((e) => {
|
|
92
|
+
const b = e as Extract<IndexedEntry, { kind: "beacon" }>;
|
|
93
|
+
return entryLine(e, `${b.surface} \`${b.key}\``);
|
|
94
|
+
})
|
|
95
|
+
)
|
|
96
|
+
);
|
|
97
|
+
parts.push(
|
|
98
|
+
...section(
|
|
99
|
+
"Dangers",
|
|
100
|
+
byKind("danger").map((e) => {
|
|
101
|
+
const d = e as Extract<IndexedEntry, { kind: "danger" }>;
|
|
102
|
+
return entryLine(e, `${d.category} — ${d.note}`);
|
|
103
|
+
})
|
|
104
|
+
)
|
|
105
|
+
);
|
|
106
|
+
const unsurveyedLines: string[] = [];
|
|
107
|
+
if (vessel.behavior === undefined) unsurveyedLines.push("- behavior not recorded");
|
|
108
|
+
for (const e of unsurveyedEntries) {
|
|
109
|
+
unsurveyedLines.push(`- \`${e.kind}/${e.id}\` — anchor: ${anchorList(e.anchors)}`);
|
|
110
|
+
}
|
|
111
|
+
parts.push(...section("Unsurveyed", unsurveyedLines));
|
|
112
|
+
|
|
113
|
+
return `${parts.join("\n").trimEnd()}\n`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Render one markdown sheet per vessel. `staleVessels` marks sheets with the
|
|
118
|
+
* pending-correction banner (set by refreshStaleness; a fresh write passes
|
|
119
|
+
* nothing).
|
|
120
|
+
*/
|
|
121
|
+
export function renderSheets(
|
|
122
|
+
entries: IndexedEntry[],
|
|
123
|
+
staleVessels: ReadonlySet<string> = new Set()
|
|
124
|
+
): Map<string, string> {
|
|
125
|
+
const sheets = new Map<string, string>();
|
|
126
|
+
const vessels = entries.filter((e): e is VesselIndexed => e.kind === "vessel");
|
|
127
|
+
const owner = new Map<string, string>();
|
|
128
|
+
for (const vessel of vessels) {
|
|
129
|
+
const file = sheetFileName(vessel.id);
|
|
130
|
+
// The sanitization is many-to-one: ids like `foo/bar` and `foo:bar`
|
|
131
|
+
// collapse to the same file name. Refuse rather than let one vessel's
|
|
132
|
+
// sheet silently document another.
|
|
133
|
+
const prior = owner.get(file);
|
|
134
|
+
if (prior !== undefined) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`sheet file name collision: vessel ids ${JSON.stringify(prior)} and ${JSON.stringify(vessel.id)} ` +
|
|
137
|
+
`both render to ${file} — give the vessels distinct ids`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
owner.set(file, vessel.id);
|
|
141
|
+
const owned = entries.filter(
|
|
142
|
+
(e) =>
|
|
143
|
+
e.kind !== "vessel" &&
|
|
144
|
+
(e.kind === "fairway"
|
|
145
|
+
? e.from === vessel.id || e.to === vessel.id
|
|
146
|
+
: e.vessel === vessel.id)
|
|
147
|
+
);
|
|
148
|
+
sheets.set(file, renderVesselSheet(vessel, owned, staleVessels.has(vessel.id)));
|
|
149
|
+
}
|
|
150
|
+
return sheets;
|
|
151
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staleness: per-vessel source signatures and the refresh that flips changed
|
|
3
|
+
* vessels' entries to `pending correction`.
|
|
4
|
+
*
|
|
5
|
+
* The signature is a cheap tree hash (design.md, decision 3): the file list,
|
|
6
|
+
* sizes, and mtimes under the vessel's paths, sorted and hashed. Content
|
|
7
|
+
* hashing is deliberately avoided — the repair expedition re-reads content
|
|
8
|
+
* anyway.
|
|
9
|
+
*/
|
|
10
|
+
import { lstatSync, readdirSync, statSync } from "node:fs";
|
|
11
|
+
import { join, resolve, sep } from "node:path";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import type { IndexedEntry, Notice, VesselSignature } from "./types";
|
|
14
|
+
import {
|
|
15
|
+
INDEX_FILE,
|
|
16
|
+
NOTICES_FILE,
|
|
17
|
+
chartDir,
|
|
18
|
+
indexJsonl,
|
|
19
|
+
readChart,
|
|
20
|
+
writeFilesAtomically,
|
|
21
|
+
} from "./chart-io";
|
|
22
|
+
import { renderSheets, sheetFileName } from "./sheets";
|
|
23
|
+
import { diffNotices, renderNotices } from "./notices";
|
|
24
|
+
|
|
25
|
+
interface FileFact {
|
|
26
|
+
path: string;
|
|
27
|
+
size: number;
|
|
28
|
+
mtime: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function collect(root: string, rel: string, out: FileFact[]): void {
|
|
32
|
+
if (escapesRoot(root, rel)) return; // never walk past the target perimeter
|
|
33
|
+
let stats;
|
|
34
|
+
try {
|
|
35
|
+
stats = statSync(join(root, rel));
|
|
36
|
+
} catch {
|
|
37
|
+
return; // a path that no longer exists contributes nothing
|
|
38
|
+
}
|
|
39
|
+
if (stats.isFile()) {
|
|
40
|
+
out.push({ path: rel, size: stats.size, mtime: Math.round(stats.mtimeMs) });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!stats.isDirectory()) return;
|
|
44
|
+
const entries = readdirSync(join(root, rel), { withFileTypes: true }).sort((a, b) =>
|
|
45
|
+
a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
|
46
|
+
);
|
|
47
|
+
for (const de of entries) {
|
|
48
|
+
if (de.isDirectory()) collect(root, `${rel}/${de.name}`, out);
|
|
49
|
+
else if (de.isFile()) {
|
|
50
|
+
const s = statSync(join(root, rel, de.name));
|
|
51
|
+
out.push({
|
|
52
|
+
path: `${rel}/${de.name}`,
|
|
53
|
+
size: s.size,
|
|
54
|
+
mtime: Math.round(s.mtimeMs),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True when a charted path resolves outside the target root. A `..`
|
|
61
|
+
* segment escapes lexically, and so does an absolute path: resolve() keeps
|
|
62
|
+
* the absolute, which then fails the prefix check. */
|
|
63
|
+
function escapesRoot(root: string, rel: string): boolean {
|
|
64
|
+
const abs = resolve(root, rel);
|
|
65
|
+
return abs !== root && !abs.startsWith(root + sep);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A charted top-level path the province cannot vouch for: one that escapes
|
|
70
|
+
* the root, or one that is itself a symlink (a link's metadata and target
|
|
71
|
+
* live outside what the survey read). Such a vessel is never provably
|
|
72
|
+
* fresh — it always counts as changed.
|
|
73
|
+
*/
|
|
74
|
+
function unprovablePath(root: string, rel: string): boolean {
|
|
75
|
+
if (escapesRoot(root, rel)) return true;
|
|
76
|
+
try {
|
|
77
|
+
return lstatSync(join(root, rel)).isSymbolicLink();
|
|
78
|
+
} catch {
|
|
79
|
+
return false; // absent: collect() contributes nothing, detection decides
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Cheap tree hash over the given paths (relative to the target root):
|
|
85
|
+
* sorted `path\tsize\tmtime` lines, SHA-256. A top-level path is stat'ed as
|
|
86
|
+
* given, so a symlinked vessel root resolves; symlinked entries found
|
|
87
|
+
* during the walk are not followed (only dirents reporting file or dir are
|
|
88
|
+
* descended/collected). Paths that escape the root contribute nothing.
|
|
89
|
+
*/
|
|
90
|
+
export function treeSignature(targetRoot: string, paths: string[]): VesselSignature {
|
|
91
|
+
// Resolve once: collect() compares paths against the root lexically, so a
|
|
92
|
+
// relative targetRoot would classify every path as escaping and sign the
|
|
93
|
+
// empty set — "always drifted" for the whole chart. Callers may pass
|
|
94
|
+
// either form; the signature must not depend on it.
|
|
95
|
+
const root = resolve(targetRoot);
|
|
96
|
+
const facts: FileFact[] = [];
|
|
97
|
+
for (const p of paths) {
|
|
98
|
+
// A symlinked top-level path would be stat'ed through the link, signing
|
|
99
|
+
// metadata the province never surveyed; it contributes nothing here (the
|
|
100
|
+
// refresh already treats such a vessel as never provably fresh).
|
|
101
|
+
try {
|
|
102
|
+
if (lstatSync(join(root, p)).isSymbolicLink()) continue;
|
|
103
|
+
} catch {
|
|
104
|
+
// absent — collect() contributes nothing either
|
|
105
|
+
}
|
|
106
|
+
collect(root, p, facts);
|
|
107
|
+
}
|
|
108
|
+
facts.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
109
|
+
const text = facts.map((f) => `${f.path}\t${f.size}\t${f.mtime}`).join("\n");
|
|
110
|
+
return { hash: createHash("sha256").update(text).digest("hex"), files: facts.length };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Result of a staleness refresh. */
|
|
114
|
+
export interface StalenessResult {
|
|
115
|
+
/** Vessel ids whose source signature changed since the last survey. */
|
|
116
|
+
changedVessels: string[];
|
|
117
|
+
/** Entries now marked `pending correction`. */
|
|
118
|
+
staleEntries: IndexedEntry[];
|
|
119
|
+
/** Notices to Mariners produced by this refresh. */
|
|
120
|
+
notices: Notice[];
|
|
121
|
+
noticesText: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Re-verify every vessel's source signature against the index and recompute
|
|
126
|
+
* the pending-correction marks: an entry is stale exactly while its vessel's
|
|
127
|
+
* sources differ from the survey — a drift sets the mark, a reverted drift
|
|
128
|
+
* clears it (chart spec: entries whose sources are unchanged MUST NOT be
|
|
129
|
+
* marked). With no flag flip in either direction, nothing is written at all.
|
|
130
|
+
*
|
|
131
|
+
* The refresh never deletes `notices.txt`: when a re-detection finds no flag
|
|
132
|
+
* transitions, the outstanding report from the earlier refresh still
|
|
133
|
+
* describes the still-pending vessels and stays on the chart. Only a chart
|
|
134
|
+
* write replaces the report (its own contract).
|
|
135
|
+
*/
|
|
136
|
+
export function refreshStaleness(targetRoot: string): StalenessResult {
|
|
137
|
+
const entries = readChart(targetRoot);
|
|
138
|
+
|
|
139
|
+
const changed = new Set<string>();
|
|
140
|
+
const root = resolve(targetRoot);
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
if (entry.kind !== "vessel") continue;
|
|
143
|
+
// A vessel charted through an escaping or symlinked path can never prove
|
|
144
|
+
// freshness: it counts as changed, so the chart never vouches for what
|
|
145
|
+
// it cannot see.
|
|
146
|
+
if (entry.paths.some((p) => unprovablePath(root, p))) {
|
|
147
|
+
changed.add(entry.id);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const current = treeSignature(targetRoot, entry.paths);
|
|
151
|
+
// A vessel without a recorded signature counts as changed: never claim
|
|
152
|
+
// freshness we cannot prove.
|
|
153
|
+
if (entry.signature?.hash !== current.hash) changed.add(entry.id);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Recompute, never accumulate: the mark states the province's present
|
|
157
|
+
// drift, not its history. entries.map keeps order and length, so the
|
|
158
|
+
// before/after flag comparison below is index-aligned by construction.
|
|
159
|
+
const next = entries.map((entry): IndexedEntry => {
|
|
160
|
+
const stale =
|
|
161
|
+
entry.kind === "vessel"
|
|
162
|
+
? changed.has(entry.id)
|
|
163
|
+
: entry.kind === "fairway"
|
|
164
|
+
? changed.has(entry.from) || changed.has(entry.to)
|
|
165
|
+
: changed.has(entry.vessel);
|
|
166
|
+
return stale === entry.stale ? entry : { ...entry, stale };
|
|
167
|
+
});
|
|
168
|
+
const flipped = next.filter((entry, i) => entry.stale !== entries[i]!.stale);
|
|
169
|
+
|
|
170
|
+
if (flipped.length === 0) {
|
|
171
|
+
return {
|
|
172
|
+
changedVessels: [...changed].sort(),
|
|
173
|
+
staleEntries: next.filter((e) => e.stale),
|
|
174
|
+
notices: [],
|
|
175
|
+
noticesText: "",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const dir = chartDir(targetRoot);
|
|
180
|
+
const notices = diffNotices(entries, next);
|
|
181
|
+
const noticesText = renderNotices(notices);
|
|
182
|
+
const files = new Map<string, string>();
|
|
183
|
+
files.set(INDEX_FILE, indexJsonl(next));
|
|
184
|
+
if (notices.length > 0) files.set(NOTICES_FILE, noticesText);
|
|
185
|
+
const staleNow = new Set(
|
|
186
|
+
next.filter((e) => e.kind === "vessel" && e.stale).map((e) => e.id),
|
|
187
|
+
);
|
|
188
|
+
const sheets = renderSheets(next, staleNow);
|
|
189
|
+
for (const entry of flipped) {
|
|
190
|
+
if (entry.kind !== "vessel") continue;
|
|
191
|
+
const name = sheetFileName(entry.id);
|
|
192
|
+
const sheet = sheets.get(name);
|
|
193
|
+
if (sheet) files.set(name, sheet);
|
|
194
|
+
}
|
|
195
|
+
writeFilesAtomically(dir, files);
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
changedVessels: [...changed].sort(),
|
|
199
|
+
staleEntries: next.filter((e) => e.stale),
|
|
200
|
+
notices,
|
|
201
|
+
noticesText,
|
|
202
|
+
};
|
|
203
|
+
}
|