@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,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixture for chart-io.rollback.test.ts — runs in its OWN process because
|
|
3
|
+
* mock.module("node:fs") must not leak into the main test runner's module
|
|
4
|
+
* registry (it deadlocked the full-suite run). Applies the sabotaged
|
|
5
|
+
* renameSync, performs the failing write, and prints the resulting
|
|
6
|
+
* directory state as JSON on stdout.
|
|
7
|
+
*
|
|
8
|
+
* bun src/chart-io.rollback-fixture.ts <dir> <sabotageAt>
|
|
9
|
+
*/
|
|
10
|
+
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
const [dir, sabotageArg] = process.argv.slice(2);
|
|
14
|
+
if (!dir || !sabotageArg) throw new Error("usage: rollback-fixture.ts <dir> <sabotageAt>");
|
|
15
|
+
const sabotageAt = Number(sabotageArg);
|
|
16
|
+
|
|
17
|
+
const real = await import("node:fs");
|
|
18
|
+
let renameCalls = 0;
|
|
19
|
+
const { mock } = await import("bun:test");
|
|
20
|
+
mock.module("node:fs", () => ({
|
|
21
|
+
...real,
|
|
22
|
+
renameSync: (from: string, to: string) => {
|
|
23
|
+
renameCalls += 1;
|
|
24
|
+
if (renameCalls === sabotageAt) throw new Error("sabotaged rename (simulated ENOSPC)");
|
|
25
|
+
return real.renameSync(from, to);
|
|
26
|
+
},
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const { writeFilesAtomically } = await import("./chart-io");
|
|
30
|
+
|
|
31
|
+
// Two existing files (each replace costs two renames: final→bak,
|
|
32
|
+
// tmp→final) plus one new file.
|
|
33
|
+
writeFileSync(join(dir, "a.md"), "old a\n");
|
|
34
|
+
writeFileSync(join(dir, "b.md"), "old b\n");
|
|
35
|
+
const files = new Map<string, string>([
|
|
36
|
+
["a.md", "new a\n"],
|
|
37
|
+
["b.md", "new b\n"],
|
|
38
|
+
["c.md", "new c\n"],
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
let threw: string | null = null;
|
|
42
|
+
try {
|
|
43
|
+
writeFilesAtomically(dir, files);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
threw = (err as Error).message;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ls = readdirSync(dir).sort();
|
|
49
|
+
const contents: Record<string, string> = {};
|
|
50
|
+
for (const name of ls) {
|
|
51
|
+
contents[name] = readFileSync(join(dir, name), "utf8");
|
|
52
|
+
}
|
|
53
|
+
process.stdout.write(
|
|
54
|
+
`${JSON.stringify({ threw, ls, contents }, null, 1)}\n`
|
|
55
|
+
);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Low-level chart file IO shared by the store and the staleness refresher:
|
|
3
|
+
* paths, index (de)serialization, and the atomic stage-temp-then-rename
|
|
4
|
+
* writer. All writes stay under `<target>/.portolan/chart/`.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import type { IndexedEntry } from "./types";
|
|
10
|
+
|
|
11
|
+
export const INDEX_FILE = "index.jsonl";
|
|
12
|
+
export const NOTICES_FILE = "notices.txt";
|
|
13
|
+
|
|
14
|
+
/** Where the Chart lives for a given target root. */
|
|
15
|
+
export function chartDir(targetRoot: string): string {
|
|
16
|
+
return join(targetRoot, ".portolan", "chart");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function entryKey(entry: { kind: string; id: string }): string {
|
|
20
|
+
return `${entry.kind}/${entry.id}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function sortEntries<T extends { kind: string; id: string }>(entries: T[]): T[] {
|
|
24
|
+
return [...entries].sort((a, b) => {
|
|
25
|
+
const ka = entryKey(a);
|
|
26
|
+
const kb = entryKey(b);
|
|
27
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Read the machine index. Throws with a remediation hint when absent. */
|
|
32
|
+
export function readChart(targetRoot: string): IndexedEntry[] {
|
|
33
|
+
const indexPath = join(chartDir(targetRoot), INDEX_FILE);
|
|
34
|
+
if (!existsSync(indexPath)) {
|
|
35
|
+
throw new Error(`no chart index at ${indexPath} — write a chart first`);
|
|
36
|
+
}
|
|
37
|
+
const lines = readFileSync(indexPath, "utf8")
|
|
38
|
+
.split("\n")
|
|
39
|
+
.filter((line) => line.trim().length > 0);
|
|
40
|
+
return lines.map((line, i) => {
|
|
41
|
+
let entry: IndexedEntry;
|
|
42
|
+
try {
|
|
43
|
+
entry = JSON.parse(line) as IndexedEntry;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
// Same remediation shape as the check below: the corruption message
|
|
46
|
+
// names the file and the line, so index repair knows where to look.
|
|
47
|
+
throw new Error(
|
|
48
|
+
`corrupt chart index ${indexPath} line ${i + 1}: not valid JSON (${(err as Error).message})`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (typeof entry?.kind !== "string" || typeof entry?.id !== "string") {
|
|
52
|
+
throw new Error(`corrupt chart index ${indexPath} line ${i + 1}`);
|
|
53
|
+
}
|
|
54
|
+
return entry;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function readChartOrNull(targetRoot: string): IndexedEntry[] | null {
|
|
59
|
+
try {
|
|
60
|
+
return readChart(targetRoot);
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function indexJsonl(entries: IndexedEntry[]): string {
|
|
67
|
+
return `${sortEntries(entries).map((e) => JSON.stringify(e)).join("\n")}\n`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Stage every file to a temp name first, then rename them all into place.
|
|
72
|
+
* A failure before the renames (validation, full disk, ...) leaves the
|
|
73
|
+
* previous chart untouched; staged temps are removed on failure. A failure
|
|
74
|
+
* during the renames rolls back: every replaced file's previous content is
|
|
75
|
+
* held aside as a backup until all renames are through, so the previous
|
|
76
|
+
* chart stays byte-identical — not just the machine index.
|
|
77
|
+
*/
|
|
78
|
+
export function writeFilesAtomically(dir: string, files: Map<string, string>): void {
|
|
79
|
+
const staged: Array<{ tmp: string; final: string }> = [];
|
|
80
|
+
try {
|
|
81
|
+
for (const [name, text] of files) {
|
|
82
|
+
const final = join(dir, name);
|
|
83
|
+
const tmp = `${final}.tmp-${randomBytes(6).toString("hex")}`;
|
|
84
|
+
writeFileSync(tmp, text);
|
|
85
|
+
staged.push({ tmp, final });
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
for (const { tmp } of staged) rmSync(tmp, { force: true });
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
// Preflight: every occupied target must be a regular file a backup can
|
|
92
|
+
// restore. Anything else (a directory squatting on a sheet name) fails
|
|
93
|
+
// before a single original is touched.
|
|
94
|
+
for (const { final } of staged) {
|
|
95
|
+
if (existsSync(final) && !statSync(final).isFile()) {
|
|
96
|
+
for (const { tmp } of staged) rmSync(tmp, { force: true });
|
|
97
|
+
throw new Error(`writeFilesAtomically: ${final} exists and is not a regular file`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const backups: Array<{ bak: string; final: string }> = [];
|
|
101
|
+
const placed = new Set<string>();
|
|
102
|
+
try {
|
|
103
|
+
for (const { tmp, final } of staged) {
|
|
104
|
+
if (existsSync(final)) {
|
|
105
|
+
const bak = `${final}.bak-${randomBytes(6).toString("hex")}`;
|
|
106
|
+
renameSync(final, bak);
|
|
107
|
+
backups.push({ bak, final });
|
|
108
|
+
}
|
|
109
|
+
renameSync(tmp, final);
|
|
110
|
+
placed.add(final);
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
for (const final of placed) rmSync(final, { force: true });
|
|
114
|
+
for (const { bak, final } of backups) renameSync(bak, final);
|
|
115
|
+
for (const { tmp, final } of staged) {
|
|
116
|
+
if (!placed.has(final)) rmSync(tmp, { force: true });
|
|
117
|
+
}
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
for (const { bak } of backups) rmSync(bak, { force: true });
|
|
121
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chart store: read/write of `<target>/.portolan/chart/` — one markdown
|
|
3
|
+
* sheet per vessel plus the machine index `index.jsonl` (authoritative for
|
|
4
|
+
* machines; sheets are rendered outputs). Writes are atomic by
|
|
5
|
+
* stage-to-temp + rename (design.md, decision 4): a write either persists
|
|
6
|
+
* completely or leaves the previous chart byte-identical. Each vessel entry
|
|
7
|
+
* is stamped with a source tree signature for staleness detection.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import type { ChartEntry, IndexedEntry, Notice } from "./types";
|
|
12
|
+
import { validateEntries } from "./validate";
|
|
13
|
+
import { renderSheets } from "./sheets";
|
|
14
|
+
import { treeSignature } from "./staleness";
|
|
15
|
+
import { diffNotices, renderNotices } from "./notices";
|
|
16
|
+
import {
|
|
17
|
+
INDEX_FILE,
|
|
18
|
+
NOTICES_FILE,
|
|
19
|
+
chartDir,
|
|
20
|
+
entryKey,
|
|
21
|
+
indexJsonl,
|
|
22
|
+
readChartOrNull,
|
|
23
|
+
sortEntries,
|
|
24
|
+
writeFilesAtomically,
|
|
25
|
+
} from "./chart-io";
|
|
26
|
+
|
|
27
|
+
export { INDEX_FILE, NOTICES_FILE, chartDir, readChart } from "./chart-io";
|
|
28
|
+
|
|
29
|
+
/** Result of a chart write: where the chart lives, the index, and the notices it produced. */
|
|
30
|
+
export interface WriteResult {
|
|
31
|
+
dir: string;
|
|
32
|
+
index: IndexedEntry[];
|
|
33
|
+
notices: Notice[];
|
|
34
|
+
noticesText: string;
|
|
35
|
+
/** Set when post-write cleanup failed: the write persisted, its cleanup did not. */
|
|
36
|
+
cleanupError?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface WriteOptions {
|
|
40
|
+
/**
|
|
41
|
+
* Accept a full-replace that drops more than a quarter of the existing
|
|
42
|
+
* entries (chart-write-shrink-guard). Default false: silent mass shrink
|
|
43
|
+
* is how a partial rewrite clobbers a whole chart (live incident,
|
|
44
|
+
* receipt r43).
|
|
45
|
+
*/
|
|
46
|
+
allowShrink?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Write the whole chart (full-replace semantics: entries absent from the
|
|
51
|
+
* batch are retired). Validates the batch first — a rejection or a late
|
|
52
|
+
* duplicate-id failure persists nothing. Vessel sheets are re-rendered from
|
|
53
|
+
* the entries; sheets of retired vessels are removed.
|
|
54
|
+
*/
|
|
55
|
+
export function writeChart(
|
|
56
|
+
targetRoot: string,
|
|
57
|
+
rawEntries: ChartEntry[],
|
|
58
|
+
options: WriteOptions = {},
|
|
59
|
+
): WriteResult {
|
|
60
|
+
// Round-trip rule: entries read back from the chart carry `stale` and
|
|
61
|
+
// `signature` metadata the store owns and re-stamps on every write, so a
|
|
62
|
+
// read → modify → write repair cycle must not be rejected for them.
|
|
63
|
+
const entries = rawEntries.map((entry) => {
|
|
64
|
+
const { stale: _stale, signature: _signature, ...clean } = entry as ChartEntry & {
|
|
65
|
+
stale?: unknown;
|
|
66
|
+
signature?: unknown;
|
|
67
|
+
};
|
|
68
|
+
return clean as ChartEntry;
|
|
69
|
+
});
|
|
70
|
+
if (entries.length === 0) {
|
|
71
|
+
throw new Error("writeChart: refusing to write an empty chart");
|
|
72
|
+
}
|
|
73
|
+
// Shrink guard (chart-write-shrink-guard): a full-replace that drops more
|
|
74
|
+
// than a quarter of the existing entries is refused unless explicitly
|
|
75
|
+
// allowed — a partial rewrite must not clobber a whole chart silently.
|
|
76
|
+
// The threshold is compared as a float: flooring it would admit a
|
|
77
|
+
// 74.9% shrink as "not below 75%".
|
|
78
|
+
const previous = readChartOrNull(targetRoot);
|
|
79
|
+
if (previous && !options.allowShrink) {
|
|
80
|
+
if (entries.length < previous.length * 0.75) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`writeChart refused: ${entries.length} entries would shrink the chart from ${previous.length} ` +
|
|
83
|
+
`(allowShrink to override a deliberate retire-heavy correction)`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
validateEntries(entries);
|
|
88
|
+
// Late batch check, after per-entry validation: duplicate ids would
|
|
89
|
+
// corrupt the index. Reaching this throw proves nothing was persisted.
|
|
90
|
+
const seen = new Set<string>();
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
const key = entryKey(entry);
|
|
93
|
+
if (seen.has(key)) {
|
|
94
|
+
throw new Error(`writeChart: duplicate chart entry id ${key}`);
|
|
95
|
+
}
|
|
96
|
+
seen.add(key);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const dir = chartDir(targetRoot);
|
|
100
|
+
const indexed = sortEntries(
|
|
101
|
+
entries.map((entry) => {
|
|
102
|
+
const base = { ...entry, stale: false } as IndexedEntry;
|
|
103
|
+
if (entry.kind === "vessel") {
|
|
104
|
+
base.signature = treeSignature(targetRoot, entry.paths);
|
|
105
|
+
}
|
|
106
|
+
return base;
|
|
107
|
+
})
|
|
108
|
+
);
|
|
109
|
+
const notices = diffNotices(previous ?? [], indexed);
|
|
110
|
+
const noticesText = renderNotices(notices);
|
|
111
|
+
const sheets = renderSheets(indexed);
|
|
112
|
+
|
|
113
|
+
mkdirSync(dir, { recursive: true });
|
|
114
|
+
const files = new Map<string, string>(sheets);
|
|
115
|
+
files.set(INDEX_FILE, indexJsonl(indexed));
|
|
116
|
+
if (notices.length > 0) files.set(NOTICES_FILE, noticesText);
|
|
117
|
+
writeFilesAtomically(dir, files);
|
|
118
|
+
|
|
119
|
+
// Cleanup runs after the atomic rename: the write has persisted, so a
|
|
120
|
+
// failing cleanup is reported in the result, never thrown — a throw here
|
|
121
|
+
// would surface a tool error for a write that in fact landed, and leave
|
|
122
|
+
// the caller assuming the previous chart.
|
|
123
|
+
let cleanupError: string | undefined;
|
|
124
|
+
try {
|
|
125
|
+
// Remove sheets the new chart no longer owns (retired vessels).
|
|
126
|
+
for (const name of readdirSync(dir)) {
|
|
127
|
+
if (name.endsWith(".md") && !sheets.has(name)) {
|
|
128
|
+
unlinkSync(join(dir, name));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// An empty report is removed: the notices file reflects the latest write.
|
|
132
|
+
if (notices.length === 0) rmSync(join(dir, NOTICES_FILE), { force: true });
|
|
133
|
+
} catch (err) {
|
|
134
|
+
cleanupError = err instanceof Error ? err.message : String(err);
|
|
135
|
+
}
|
|
136
|
+
return { dir, index: indexed, notices, noticesText, ...(cleanupError !== undefined ? { cleanupError } : {}) };
|
|
137
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* The Chart Room CLI — human/scheduler entries (the MCP tool `chart.render`
|
|
4
|
+
* is the single-province one; both call the same core functions).
|
|
5
|
+
*
|
|
6
|
+
* bun core/src/chartroom/cli.ts render --target <province root>
|
|
7
|
+
* One-file Chart Room export for one province.
|
|
8
|
+
*
|
|
9
|
+
* bun core/src/chartroom/cli.ts review --target <t1> [--target <t2> ...]
|
|
10
|
+
* Fleet review: a multi-province index page, written into the FIRST
|
|
11
|
+
* named target's .portolan/fleet-review.html ("the reviewing
|
|
12
|
+
* harbor"). Targets are read-only; nothing is discovered by scanning.
|
|
13
|
+
*
|
|
14
|
+
* Both commands are deterministic over unchanged inputs. Any failure (no
|
|
15
|
+
* chart, bad arguments) exits 1 with the error on stderr.
|
|
16
|
+
*/
|
|
17
|
+
import { parseArgs } from "node:util";
|
|
18
|
+
import { resolve } from "node:path";
|
|
19
|
+
import { renderChartRoom } from "./render";
|
|
20
|
+
import { buildFleetReview } from "./review";
|
|
21
|
+
|
|
22
|
+
const usage = `usage:
|
|
23
|
+
bun core/src/chartroom/cli.ts render --target <province root>
|
|
24
|
+
bun core/src/chartroom/cli.ts review --target <t1> [--target <t2> ...]`;
|
|
25
|
+
|
|
26
|
+
const { positionals, values } = parseArgs({
|
|
27
|
+
allowPositionals: true,
|
|
28
|
+
options: {
|
|
29
|
+
target: { type: "string", multiple: true, default: [] },
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const command = positionals[0];
|
|
34
|
+
if (
|
|
35
|
+
!command ||
|
|
36
|
+
(command !== "render" && command !== "review") ||
|
|
37
|
+
positionals.length > 1 ||
|
|
38
|
+
values.target.length === 0
|
|
39
|
+
) {
|
|
40
|
+
console.error(usage);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
// render takes exactly one province; silently ignoring the rest would
|
|
44
|
+
// pretend the extra targets were served.
|
|
45
|
+
if (command === "render" && values.target.length > 1) {
|
|
46
|
+
console.error("render takes one --target; to assemble several provinces use review");
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
if (command === "render") {
|
|
52
|
+
const result = renderChartRoom(resolve(values.target[0]!));
|
|
53
|
+
console.log(`chart-room.html written: ${result.path}`);
|
|
54
|
+
console.log(`entries: ${result.entries} — ${JSON.stringify(result.counts)}`);
|
|
55
|
+
} else {
|
|
56
|
+
const result = buildFleetReview(values.target.map((t) => resolve(t)));
|
|
57
|
+
console.log(`fleet-review.html written: ${result.path}`);
|
|
58
|
+
console.log(`provinces: ${result.provinces}`);
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error(String(err instanceof Error ? err.message : err));
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Chart Room renderer — the byproduct export of the Chart
|
|
3
|
+
* (openspec/changes/chart-room).
|
|
4
|
+
*
|
|
5
|
+
* Reads the machine index (and, when present, the Sailing Directions) and
|
|
6
|
+
* writes one self-contained `<target>/.portolan/chart-room.html`: nautical
|
|
7
|
+
* archipelago map + engineering layered graph over the same embedded data,
|
|
8
|
+
* deterministic (same chart in → same bytes out), zero runtime dependencies.
|
|
9
|
+
* The layout lives in the page; core owns only reading, inlining, and
|
|
10
|
+
* writing. Reads the Chart, writes exactly one file, never the storage.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
13
|
+
import { join, resolve, basename, dirname } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { readChart, chartDir } from "../chart-store";
|
|
16
|
+
import { writeFilesAtomically } from "../chart-io";
|
|
17
|
+
import type { IndexedEntry, NoticeAction } from "../types";
|
|
18
|
+
|
|
19
|
+
/** Where the artifact lands — inside the write perimeter, never the chart dir. */
|
|
20
|
+
export function chartRoomPath(targetRoot: string): string {
|
|
21
|
+
return resolve(targetRoot, ".portolan/chart-room.html");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One parsed notice line group from notices.txt. */
|
|
25
|
+
export interface ParsedNotice {
|
|
26
|
+
action: NoticeAction;
|
|
27
|
+
key: string;
|
|
28
|
+
note?: string;
|
|
29
|
+
anchors: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Dependency tangles: strongly connected components (size ≥ 2) of the
|
|
34
|
+
* fairway graph over vessel ids. Self-loops and singletons are not
|
|
35
|
+
* tangles. Deterministic — members sorted by id, groups by first member.
|
|
36
|
+
*/
|
|
37
|
+
export function findTangles(entries: IndexedEntry[]): string[][] {
|
|
38
|
+
const vesselIds = new Set(
|
|
39
|
+
entries.filter((e) => e.kind === "vessel").map((e) => e.id),
|
|
40
|
+
);
|
|
41
|
+
const adj = new Map<string, string[]>();
|
|
42
|
+
for (const e of entries) {
|
|
43
|
+
if (e.kind !== "fairway") continue;
|
|
44
|
+
const f = e as IndexedEntry & { from: string; to: string };
|
|
45
|
+
if (!vesselIds.has(f.from) || !vesselIds.has(f.to)) continue;
|
|
46
|
+
if (!adj.has(f.from)) adj.set(f.from, []);
|
|
47
|
+
adj.get(f.from)!.push(f.to);
|
|
48
|
+
}
|
|
49
|
+
// Tarjan SCC
|
|
50
|
+
let index = 0;
|
|
51
|
+
const idx = new Map<string, number>();
|
|
52
|
+
const low = new Map<string, number>();
|
|
53
|
+
const stack: string[] = [];
|
|
54
|
+
const onStack = new Set<string>();
|
|
55
|
+
const sccs: string[][] = [];
|
|
56
|
+
const strong = (v: string): void => {
|
|
57
|
+
idx.set(v, index);
|
|
58
|
+
low.set(v, index);
|
|
59
|
+
index += 1;
|
|
60
|
+
stack.push(v);
|
|
61
|
+
onStack.add(v);
|
|
62
|
+
for (const w of adj.get(v) ?? []) {
|
|
63
|
+
if (!idx.has(w)) {
|
|
64
|
+
strong(w);
|
|
65
|
+
low.set(v, Math.min(low.get(v)!, low.get(w)!));
|
|
66
|
+
} else if (onStack.has(w)) {
|
|
67
|
+
low.set(v, Math.min(low.get(v)!, idx.get(w)!));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (low.get(v) === idx.get(v)) {
|
|
71
|
+
const component: string[] = [];
|
|
72
|
+
let w: string;
|
|
73
|
+
do {
|
|
74
|
+
w = stack.pop()!;
|
|
75
|
+
onStack.delete(w);
|
|
76
|
+
component.push(w);
|
|
77
|
+
} while (w !== v);
|
|
78
|
+
sccs.push(component);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
for (const v of vesselIds) if (!idx.has(v)) strong(v);
|
|
82
|
+
return sccs
|
|
83
|
+
.filter((c) => c.length > 1)
|
|
84
|
+
.map((c) => c.sort())
|
|
85
|
+
.sort((a, b) => (a[0]! < b[0]! ? -1 : 1));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse the plain-text grammar produced by `renderNotices` (notices.ts):
|
|
90
|
+
* header + per-notice entry lines (label padded to 14 columns) followed by
|
|
91
|
+
* indented `anchor:` continuation lines. Absent or empty file → [].
|
|
92
|
+
*/
|
|
93
|
+
export function parseNotices(text: string): ParsedNotice[] {
|
|
94
|
+
const labels = new Set(["ADDED", "CORRECTED", "MARKED STALE", "RETIRED"]);
|
|
95
|
+
const actionOf = (word: string): NoticeAction | undefined => {
|
|
96
|
+
if (word === "ADDED") return "added";
|
|
97
|
+
if (word === "CORRECTED") return "corrected";
|
|
98
|
+
if (word === "MARKED STALE") return "markedStale";
|
|
99
|
+
if (word === "RETIRED") return "retired";
|
|
100
|
+
return undefined;
|
|
101
|
+
};
|
|
102
|
+
const notices: ParsedNotice[] = [];
|
|
103
|
+
for (const raw of text.split("\n")) {
|
|
104
|
+
if (!raw.trim()) continue;
|
|
105
|
+
const entryMatch = raw.match(/^(ADDED|CORRECTED|MARKED STALE|RETIRED)\s+(.*)$/);
|
|
106
|
+
if (entryMatch && labels.has(entryMatch[1]!)) {
|
|
107
|
+
const rest = entryMatch[2]!;
|
|
108
|
+
const dash = rest.indexOf(" — ");
|
|
109
|
+
const key = dash === -1 ? rest : rest.slice(0, dash);
|
|
110
|
+
const notice: ParsedNotice = { action: actionOf(entryMatch[1]!)!, key, anchors: [] };
|
|
111
|
+
if (dash !== -1) notice.note = rest.slice(dash + 3);
|
|
112
|
+
notices.push(notice);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const anchorMatch = raw.match(/^\s+anchor:\s*(.+)$/);
|
|
116
|
+
if (anchorMatch && notices.length > 0) {
|
|
117
|
+
notices[notices.length - 1]!.anchors.push(anchorMatch[1]!);
|
|
118
|
+
}
|
|
119
|
+
// anything else (e.g. the header line) is skipped
|
|
120
|
+
}
|
|
121
|
+
return notices;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Render the Sailing Directions (markdown) into the briefing panel HTML. */
|
|
125
|
+
export function inlineMd(md: string): string {
|
|
126
|
+
const esc = (s: string) =>
|
|
127
|
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
128
|
+
const out: string[] = [];
|
|
129
|
+
let inList = false;
|
|
130
|
+
for (const raw of md.split("\n")) {
|
|
131
|
+
const line = raw.trimEnd();
|
|
132
|
+
const inline = (s: string) =>
|
|
133
|
+
esc(s)
|
|
134
|
+
.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
|
|
135
|
+
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
136
|
+
const h = line.match(/^(#{1,3})\s+(.*)$/);
|
|
137
|
+
const li = line.match(/^[-*]\s+(.*)$/);
|
|
138
|
+
if (li) {
|
|
139
|
+
if (!inList) { out.push("<ul>"); inList = true; }
|
|
140
|
+
out.push(`<li>${inline(li[1]!)}</li>`);
|
|
141
|
+
} else {
|
|
142
|
+
if (inList) { out.push("</ul>"); inList = false; }
|
|
143
|
+
if (h) out.push(`<h3>${inline(h[2]!)}</h3>`);
|
|
144
|
+
else if (line.trim()) out.push(`<p>${inline(line)}</p>`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (inList) out.push("</ul>");
|
|
148
|
+
return out.join("\n");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* JSON for inlining into a <script> block: no `</script>` breakout, no
|
|
153
|
+
* U+2028/2029. The caller MUST substitute with a function replacement so
|
|
154
|
+
* `$`-sequences in chart notes (`$(hadoop classpath)`, `${...}`) reach the
|
|
155
|
+
* page verbatim — a plain string replace would interpret them.
|
|
156
|
+
*/
|
|
157
|
+
export function safeInlineJson(value: unknown): string {
|
|
158
|
+
return JSON.stringify(value)
|
|
159
|
+
.replace(/</g, "\\u003c")
|
|
160
|
+
.replace(/\u2028/g, "\\u2028")
|
|
161
|
+
.replace(/\u2029/g, "\\u2029");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function loadTemplate(): string {
|
|
165
|
+
const here = fileURLToPath(import.meta.url);
|
|
166
|
+
return readFileSync(resolve(here, "..", "template.html"), "utf8");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface ChartRoomResult {
|
|
170
|
+
path: string;
|
|
171
|
+
entries: number;
|
|
172
|
+
counts: Record<string, number>;
|
|
173
|
+
bytes: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Render the Chart Room for one province. Throws (loudly) when no chart. */
|
|
177
|
+
export function renderChartRoom(targetRoot: string): ChartRoomResult {
|
|
178
|
+
const entries: IndexedEntry[] = readChart(targetRoot); // loud when absent
|
|
179
|
+
const sdPath = resolve(targetRoot, ".portolan/sailing-directions.md");
|
|
180
|
+
const sd = existsSync(sdPath) ? readFileSync(sdPath, "utf8") : "";
|
|
181
|
+
const expedition = (sd.match(/Expedition (\S+)/) || [])[1] ?? "—";
|
|
182
|
+
|
|
183
|
+
const noticesPath = join(chartDir(targetRoot), "notices.txt");
|
|
184
|
+
const notices = existsSync(noticesPath)
|
|
185
|
+
? parseNotices(readFileSync(noticesPath, "utf8"))
|
|
186
|
+
: [];
|
|
187
|
+
|
|
188
|
+
const briefMd = sd.replace(/^# .*\n/, "");
|
|
189
|
+
const briefHtml = briefMd
|
|
190
|
+
? inlineMd(briefMd)
|
|
191
|
+
: "<p>No sailing directions on this province.</p>";
|
|
192
|
+
|
|
193
|
+
const meta = {
|
|
194
|
+
province: basename(resolve(targetRoot)),
|
|
195
|
+
targetPath: resolve(targetRoot),
|
|
196
|
+
expedition,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const html = loadTemplate()
|
|
200
|
+
.replace("__CHART_DATA__", () =>
|
|
201
|
+
safeInlineJson({ entries, notices, tangles: findTangles(entries) }))
|
|
202
|
+
.replace("__BRIEF_HTML__", () => JSON.stringify(briefHtml))
|
|
203
|
+
.replace("__META__", () => safeInlineJson(meta));
|
|
204
|
+
|
|
205
|
+
const counts: Record<string, number> = {};
|
|
206
|
+
for (const e of entries) counts[e.kind] = (counts[e.kind] ?? 0) + 1;
|
|
207
|
+
|
|
208
|
+
const path = chartRoomPath(targetRoot);
|
|
209
|
+
// Same stage-temp-then-rename discipline as the chart itself: a crash
|
|
210
|
+
// mid-write leaves the previous room intact, never a truncated file.
|
|
211
|
+
writeFilesAtomically(dirname(path), new Map([[basename(path), html]]));
|
|
212
|
+
return { path, entries: entries.length, counts, bytes: html.length };
|
|
213
|
+
}
|