@shortlink-org/portolan 0.2.1 → 0.2.3

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 (135) hide show
  1. package/README.md +7 -2
  2. package/cli/init.mjs +14 -6
  3. package/cli/init.test.mjs +8 -1
  4. package/cli/portolan.mjs +92 -13
  5. package/cli/portolan.test.mjs +47 -0
  6. package/package.json +7 -6
  7. package/plugins/extract-django/README.md +1 -1
  8. package/plugins/extract-django/extract_test.py +1 -1
  9. package/plugins/extract-django/openapi.py +4 -14
  10. package/plugins/extract-django/openapi_test.py +4 -4
  11. package/plugins/extract-go/options.schema.json +5 -0
  12. package/plugins/extract-project/options.schema.json +21 -0
  13. package/plugins/extract-ts/README.md +1 -1
  14. package/plugins/extract-ts/clients.ts +1 -1
  15. package/plugins/extract-ts/extract.test.ts +2 -2
  16. package/plugins/extract-ts/graphql.test.ts +2 -2
  17. package/plugins/extract-ts/openapi.test.ts +6 -7
  18. package/plugins/extract-ts/openapi.ts +3 -10
  19. package/plugins/portolan-go.wasm +0 -0
  20. package/public/404-cat-v1.webp +0 -0
  21. package/public/cat-about-v1.webp +0 -0
  22. package/public/cat-clear-skies-v1.webp +0 -0
  23. package/public/cat-no-changes-v1.webp +0 -0
  24. package/public/cat-onboarding-v1.webp +0 -0
  25. package/public/cat-trial-success-v1.webp +0 -0
  26. package/schema/portolan.schema.json +61 -0
  27. package/scripts/builtin-plugins.mjs +3 -2
  28. package/scripts/catalog-sources.mjs +14 -2
  29. package/scripts/catalog-sources.test.mjs +45 -0
  30. package/scripts/delivery-presets.mjs +207 -24
  31. package/scripts/diff.mjs +5 -1
  32. package/scripts/gen-likec4.mjs +242 -85
  33. package/scripts/gen-likec4.test.mjs +168 -14
  34. package/scripts/gen.mjs +5 -3
  35. package/scripts/host-plugins/fetch-git.mjs +22 -12
  36. package/scripts/host-plugins/fetch-git.test.mjs +25 -5
  37. package/scripts/local-api.mjs +330 -346
  38. package/scripts/local-api.test.mjs +245 -1
  39. package/scripts/local-discovery.mjs +378 -0
  40. package/scripts/manifest.mjs +42 -1
  41. package/scripts/manifest.test.mjs +24 -1
  42. package/scripts/plugin-host.mjs +14 -3
  43. package/scripts/plugin-host.test.mjs +8 -0
  44. package/scripts/run-builtin.mjs +4 -2
  45. package/scripts/schema.mjs +11 -0
  46. package/scripts/site-docs.mjs +2 -2
  47. package/src/app/CatalogApp.tsx +23 -8
  48. package/src/app/CommandPalette.tsx +7 -2
  49. package/src/app/Sidebar.tsx +13 -730
  50. package/src/app/SidebarFlowSections.tsx +251 -0
  51. package/src/app/SidebarFooter.tsx +160 -0
  52. package/src/app/SidebarTree.tsx +322 -0
  53. package/src/app/toast.tsx +11 -7
  54. package/src/catalog-index.ts +485 -0
  55. package/src/catalog-model.ts +1339 -0
  56. package/src/catalog-validation.ts +1570 -0
  57. package/src/catalog.test.ts +49 -0
  58. package/src/catalog.ts +6 -3124
  59. package/src/components/CatIllustration.tsx +30 -0
  60. package/src/components/CommitLink.tsx +154 -0
  61. package/src/components/EditorLink.tsx +15 -8
  62. package/src/components/GrpcMethodReference.tsx +93 -0
  63. package/src/components/Markdown.tsx +17 -0
  64. package/src/components/MethodRows.tsx +8 -1
  65. package/src/components/PageHeader.test.tsx +23 -0
  66. package/src/components/PageHeader.tsx +34 -0
  67. package/src/components/ProblemRow.tsx +3 -0
  68. package/src/components/ShapeRows.tsx +12 -0
  69. package/src/components/SourceDoc.tsx +27 -12
  70. package/src/components/SourcePreview.tsx +125 -37
  71. package/src/enrich.test.ts +209 -2
  72. package/src/enrich.ts +284 -15
  73. package/src/er/RedisSchema.tsx +86 -5
  74. package/src/flow/FlowTable.tsx +19 -4
  75. package/src/flow/StepDetail.tsx +461 -101
  76. package/src/flow/StepRail.tsx +36 -13
  77. package/src/flow/answers-response.test.ts +100 -0
  78. package/src/flow/answers.ts +11 -8
  79. package/src/flow/chapters.ts +2 -1
  80. package/src/flow/labels.ts +10 -0
  81. package/src/flow/mermaid.test.ts +87 -3
  82. package/src/flow/mermaid.ts +26 -14
  83. package/src/index.css +132 -17
  84. package/src/landing/LandingPage.tsx +16 -12
  85. package/src/lib/all-problems.ts +1 -1
  86. package/src/lib/api.test.ts +4 -6
  87. package/src/lib/build-info.test.ts +29 -0
  88. package/src/lib/build-info.ts +21 -0
  89. package/src/lib/data-model.ts +14 -0
  90. package/src/lib/derive.ts +1 -0
  91. package/src/lib/flow-tree.ts +1 -1
  92. package/src/lib/github-catalog.test.ts +51 -0
  93. package/src/lib/github-catalog.ts +69 -0
  94. package/src/lib/grpc-reference.test.ts +72 -0
  95. package/src/lib/grpc-reference.ts +117 -0
  96. package/src/lib/local-api.ts +36 -11
  97. package/src/lib/motion.test.ts +4 -2
  98. package/src/lib/motion.tsx +5 -4
  99. package/src/lib/product.ts +7 -0
  100. package/src/lib/proto-problems.test.ts +170 -3
  101. package/src/lib/proto-problems.ts +176 -4
  102. package/src/lib/queries.ts +16 -1
  103. package/src/lib/readme-assets.test.ts +19 -0
  104. package/src/lib/readme-assets.ts +34 -0
  105. package/src/lib/setup-info.test.ts +7 -0
  106. package/src/lib/setup-info.ts +5 -0
  107. package/src/lib/source-link.test.ts +106 -18
  108. package/src/lib/source-link.ts +22 -6
  109. package/src/merge.test.ts +101 -0
  110. package/src/merge.ts +65 -1
  111. package/src/pages/AdrDetail.tsx +3 -2
  112. package/src/pages/AdrIndex.tsx +10 -7
  113. package/src/pages/AggregatePage.tsx +34 -4
  114. package/src/pages/BlockPage.tsx +39 -32
  115. package/src/pages/Changes.tsx +7 -9
  116. package/src/pages/ContextMap.tsx +11 -2
  117. package/src/pages/ContextPage.tsx +11 -4
  118. package/src/pages/FlowIndex.tsx +16 -7
  119. package/src/pages/GraphPage.tsx +21 -7
  120. package/src/pages/Language.tsx +9 -9
  121. package/src/pages/NotFound.tsx +58 -15
  122. package/src/pages/Overview.tsx +6 -0
  123. package/src/pages/Problems.tsx +10 -8
  124. package/src/pages/RegistryIndex.tsx +10 -8
  125. package/src/pages/ServicePage.tsx +1 -1
  126. package/src/pages/Settings.tsx +257 -44
  127. package/src/pages/settings/AboutSettings.tsx +129 -0
  128. package/src/pages/settings/DeliverySettings.tsx +71 -15
  129. package/src/routes.test.ts +2 -0
  130. package/src/routes.ts +2 -1
  131. package/src/selection/DetailPanel.tsx +213 -113
  132. package/src/selection/pages.test.ts +14 -1
  133. package/src/selection/pages.ts +9 -3
  134. package/src/testing/setup.ts +14 -0
  135. package/vite.config.ts +3 -1
package/README.md CHANGED
@@ -201,8 +201,13 @@ for against what is on `PATH`. The Docker image contains all of them.
201
201
 
202
202
  While `portolan dev` is running, open **Settings → Delivery presets**. Portolan
203
203
  detects GitHub or GitLab from the repository's `origin`, previews the exact CI
204
- changes, and installs architecture checks and static catalog publishing in one
205
- step. Existing unmanaged workflow files are never overwritten.
204
+ changes, and generates only the jobs selected there. Architecture checks and
205
+ static catalog publishing are selected by default; pull-request architecture
206
+ diffs and GitHub SARIF annotations are opt-in. Check, review, and publication
207
+ live in separate GitHub workflows, while SARIF augments the review workflow, so
208
+ comment and code-scanning permissions are granted only when those capabilities
209
+ are enabled. Existing unmanaged workflow files are never overwritten, and
210
+ disabling a capability removes only files managed by Portolan.
206
211
 
207
212
  ### Run without installing Node or language toolchains
208
213
 
package/cli/init.mjs CHANGED
@@ -75,13 +75,14 @@ export async function init(workspace, { version, ask = defaultAnswers, log = con
75
75
  writeManifest(manifestPath, manifest);
76
76
  log("created portolan.json");
77
77
 
78
- const scripts = projectPackage ? await ask.scripts(Object.keys(SCRIPTS).filter((name) => !(name in (projectPackage.scripts ?? {})))) : false;
79
- if (scripts) {
78
+ const addScripts = projectPackage ? await ask.scripts(Object.keys(SCRIPTS).filter((name) => !(name in (projectPackage.scripts ?? {})))) : false;
79
+ if (addScripts) {
80
80
  const merged = { ...(projectPackage.scripts ?? {}) };
81
81
  for (const [name, command] of Object.entries(SCRIPTS)) if (!(name in merged)) merged[name] = command;
82
82
  writeFileSync(resolve(workspace, "package.json"), `${JSON.stringify({ ...projectPackage, scripts: merged }, null, 2)}\n`);
83
83
  log("added architecture scripts to package.json");
84
84
  }
85
+ const scriptsReady = Boolean(projectPackage) && Object.keys(SCRIPTS).every((name) => addScripts || name in (projectPackage.scripts ?? {}));
85
86
 
86
87
  const ignorePath = resolve(workspace, ".gitignore");
87
88
  const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : "";
@@ -90,10 +91,17 @@ export async function init(workspace, { version, ask = defaultAnswers, log = con
90
91
  }
91
92
 
92
93
  const generate = await ask.generate();
93
- ask.finish(generate);
94
+ ask.finish(generate, scriptsReady);
94
95
  return { manifest, projects, generate };
95
96
  }
96
97
 
98
+ export function nextStep(generate, scriptsReady) {
99
+ const command = scriptsReady
100
+ ? generate ? "npm run architecture" : "npm run architecture:gen && npm run architecture"
101
+ : generate ? "npx @shortlink-org/portolan dev" : "npx @shortlink-org/portolan generate && npx @shortlink-org/portolan dev";
102
+ return `${generate ? "Generating; then" : "Next"}: ${command}`;
103
+ }
104
+
97
105
  /** Every question answered with its default: the `--yes` and CI path. */
98
106
  export const defaultAnswers = {
99
107
  roots: (choices) => choices.filter((choice) => choice.selected).map((choice) => choice.path),
@@ -103,7 +111,7 @@ export const defaultAnswers = {
103
111
  write: () => true,
104
112
  scripts: (names) => names.length > 0,
105
113
  generate: () => false,
106
- finish: (generate) => { if (!generate) console.log("next: portolan generate && portolan dev"); },
114
+ finish: (generate, scriptsReady) => { if (!generate) console.log(nextStep(generate, scriptsReady)); },
107
115
  };
108
116
 
109
117
  /** The same questions, asked in a terminal with @clack/prompts. */
@@ -163,8 +171,8 @@ export function promptAnswers(version) {
163
171
  async generate() {
164
172
  return answer(await p.confirm({ message: "Run portolan generate now?", initialValue: true }));
165
173
  },
166
- finish(generate) {
167
- p.outro(generate ? "Generating; then: portolan dev" : "Next: portolan generate && portolan dev");
174
+ finish(generate, scriptsReady) {
175
+ p.outro(nextStep(generate, scriptsReady));
168
176
  },
169
177
  };
170
178
  }
package/cli/init.test.mjs CHANGED
@@ -4,7 +4,7 @@ import { dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { afterEach, beforeAll, describe, expect, it } from "vitest";
6
6
 
7
- import { InitError, defaultAnswers, init } from "./init.mjs";
7
+ import { InitError, defaultAnswers, init, nextStep } from "./init.mjs";
8
8
 
9
9
  const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
10
10
  const created = [];
@@ -36,6 +36,13 @@ const goService = {
36
36
  };
37
37
 
38
38
  describe("init with defaults", () => {
39
+ it("prints runnable next commands with and without package scripts", () => {
40
+ expect(nextStep(false, true)).toBe("Next: npm run architecture:gen && npm run architecture");
41
+ expect(nextStep(true, true)).toBe("Generating; then: npm run architecture");
42
+ expect(nextStep(false, false)).toBe("Next: npx @shortlink-org/portolan generate && npx @shortlink-org/portolan dev");
43
+ expect(nextStep(true, false)).toBe("Generating; then: npx @shortlink-org/portolan dev");
44
+ });
45
+
39
46
  it("reads what the repository contains into one project at the root", async () => {
40
47
  const dir = workspace({
41
48
  "package.json": '{"name":"@acme/order-service","scripts":{"test":"vitest"}}\n',
package/cli/portolan.mjs CHANGED
@@ -19,6 +19,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
19
19
  import { fileURLToPath } from "node:url";
20
20
 
21
21
  import { InitError, commandWorks, init as runInit, isInteractive, promptAnswers, toolchainFor } from "./init.mjs";
22
+ import { loadManifest, readManifest } from "../scripts/manifest.mjs";
22
23
 
23
24
  const installRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
24
25
  const packageJson = JSON.parse(readFileSync(resolve(installRoot, "package.json"), "utf8"));
@@ -54,7 +55,10 @@ export async function main(argv = process.argv.slice(2)) {
54
55
  return dev(workspace, parsed);
55
56
  case "diff":
56
57
  if (!parsed.positionals[0]) fail("diff requires a base branch, tag, or commit");
57
- return runScript("scripts/diff.mjs", parsed.positionals, workspace);
58
+ return runScript("scripts/diff.mjs", diffArgs(parsed), workspace);
59
+ case "comment":
60
+ if (!parsed.positionals[0]) fail("comment requires a markdown file");
61
+ return runScript("scripts/forge-comment.mjs", [parsed.positionals[0]], workspace);
58
62
  case "doctor":
59
63
  return doctor(workspace);
60
64
  default:
@@ -68,7 +72,7 @@ function parse(argv) {
68
72
  const arg = argv[index];
69
73
  if (arg === "--help" || arg === "-h") value.help = true;
70
74
  else if (arg === "--version" || arg === "-v") value.version = true;
71
- else if (["--cwd", "--output", "--base", "--host", "--port"].includes(arg)) {
75
+ else if (["--cwd", "--output", "--base", "--host", "--port", "--format", "--site", "--head"].includes(arg)) {
72
76
  const next = argv[++index];
73
77
  if (!next) fail(`${arg} requires a value`);
74
78
  value[arg.slice(2)] = next;
@@ -80,6 +84,14 @@ function parse(argv) {
80
84
  return value;
81
85
  }
82
86
 
87
+ function diffArgs(parsed) {
88
+ const args = [parsed.positionals[0]];
89
+ for (const name of ["format", "output", "site", "head"]) {
90
+ if (parsed[name]) args.push(`--${name}`, parsed[name]);
91
+ }
92
+ return args;
93
+ }
94
+
83
95
  function help() {
84
96
  console.log(`Portolan ${VERSION}
85
97
 
@@ -92,6 +104,7 @@ Commands:
92
104
  check fail when committed generated files are out of date
93
105
  build build the static site
94
106
  diff BASE describe architecture changes against BASE
107
+ comment FILE post or update a pull-request comment from Markdown
95
108
  doctor check the local runtime and project configuration
96
109
  version print the CLI version
97
110
 
@@ -99,6 +112,9 @@ Options:
99
112
  --cwd DIR project directory (default: current directory)
100
113
  --output DIR build output (default: dist)
101
114
  --base PATH deployed URL base (default: /)
115
+ --format TYPE diff format: markdown, json, or sarif
116
+ --site URL catalog URL linked from an architecture diff
117
+ --head BRANCH head branch linked from an architecture diff
102
118
  --host HOST dev server host (default: 127.0.0.1)
103
119
  --port PORT dev server port
104
120
  --yes, -y init without questions: take every detected default`);
@@ -118,10 +134,24 @@ async function init(workspace, options) {
118
134
 
119
135
  function doctor(workspace) {
120
136
  const manifestPath = resolve(workspace, "portolan.json");
121
- const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : null;
137
+ let manifest = null;
138
+ let manifestProblems = [];
139
+ if (existsSync(manifestPath)) {
140
+ try {
141
+ const loaded = loadManifest(manifestPath);
142
+ manifest = loaded.manifest;
143
+ manifestProblems = loaded.problems;
144
+ } catch (cause) {
145
+ manifestProblems = [cause instanceof Error ? cause.message : String(cause)];
146
+ }
147
+ }
122
148
  const checks = [
123
149
  ["Node.js >= 24", Number(process.versions.node.split(".")[0]) >= 24, process.version],
124
- ["portolan.json", Boolean(manifest), "required"],
150
+ [
151
+ "portolan.json",
152
+ Boolean(manifest) && manifestProblems.length === 0,
153
+ manifestProblems.length > 0 ? manifestProblems.join("; ") : "required",
154
+ ],
125
155
  ["Git", commandWorks("git", ["--version"]), "used for deterministic source stamps"],
126
156
  ];
127
157
 
@@ -157,7 +187,7 @@ async function build(workspace, options) {
157
187
  runNode(packageBin("vite", "bin/vite.js"), ["build", stage, "--config", resolve(stage, "vite.config.ts"), "--outDir", output, "--emptyOutDir"], workspace, env);
158
188
 
159
189
  const { siteDocs } = await import(resolve(installRoot, "scripts/site-docs.mjs"));
160
- const manifest = JSON.parse(readFileSync(resolve(workspace, "portolan.json"), "utf8"));
190
+ const manifest = readManifest(resolve(workspace, "portolan.json"));
161
191
  const written = siteDocs({ manifest, dist: output });
162
192
  copyFileSync(resolve(output, "index.html"), resolve(output, "404.html"));
163
193
  console.log(`site: ${relative(workspace, output) || "."}${written.length ? `; mounted ${written.join(", ")}` : ""}`);
@@ -175,10 +205,10 @@ async function dev(workspace, options) {
175
205
  });
176
206
  }
177
207
 
178
- async function prepareSite(workspace) {
208
+ export async function prepareSite(workspace) {
179
209
  const manifestPath = resolve(workspace, "portolan.json");
180
210
  if (!existsSync(manifestPath)) fail("portolan.json is missing; run portolan init first");
181
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
211
+ const manifest = readManifest(manifestPath);
182
212
  const stage = resolve(workspace, ".portolan", "site");
183
213
  rmSync(stage, { recursive: true, force: true });
184
214
  mkdirSync(stage, { recursive: true });
@@ -189,14 +219,18 @@ async function prepareSite(workspace) {
189
219
  for (const file of ["index.html", "vite.config.ts", "tsconfig.json"]) {
190
220
  copyFileSync(resolve(installRoot, file), resolve(stage, file));
191
221
  }
192
- writeFileSync(resolve(stage, "package.json"), '{"private":true,"type":"module"}\n');
222
+ writeFileSync(resolve(stage, "package.json"), `${JSON.stringify({ private: true, type: "module", version: VERSION })}\n`);
193
223
 
194
224
  const patternSets = [manifest.sources ?? [], ...(manifest.catalogs ?? []).map((profile) => profile.sources ?? [])];
195
225
  const allSources = new Set();
196
226
  for (const patterns of patternSets) {
197
227
  for (const path of await matchedFiles(workspace, patterns)) allSources.add(path);
198
228
  }
199
- if (allSources.size === 0) fail("no catalog source matches portolan.json; run portolan generate first");
229
+ const emptyWorkspace = Array.isArray(manifest.projects)
230
+ && manifest.projects.length === 0
231
+ && (manifest.extract ?? []).length === 0
232
+ && (manifest.verify ?? []).length === 0;
233
+ if (allSources.size === 0 && !emptyWorkspace) fail("no catalog source matches portolan.json; run portolan generate first");
200
234
 
201
235
  const flattened = new Map();
202
236
  let ordinal = 0;
@@ -204,16 +238,27 @@ async function prepareSite(workspace) {
204
238
  const name = `portolan/source-${String(++ordinal).padStart(4, "0")}.json`;
205
239
  flattened.set(source, name);
206
240
  copyIntoStage(workspace, stage, source, name);
207
- copyReferencedFiles(workspace, stage, JSON.parse(readFileSync(resolve(workspace, source), "utf8")));
241
+ const catalog = JSON.parse(readFileSync(resolve(workspace, source), "utf8"));
242
+ copyReferencedFiles(workspace, stage, catalog);
243
+ copyReadmeAssets(workspace, stage, catalog);
244
+ }
245
+ let stagedSources = [...flattened.values()];
246
+ if (allSources.size === 0) {
247
+ const name = "portolan/source-0000.json";
248
+ mkdirSync(dirname(resolve(stage, name)), { recursive: true });
249
+ writeFileSync(resolve(stage, name), `${JSON.stringify({ generatedAt: "1970-01-01T00:00:00Z", commit: "empty", contexts: [], defs: {}, flows: [], adrs: [] }, null, 2)}\n`);
250
+ stagedSources = [name];
208
251
  }
209
252
 
210
253
  const stagedManifest = {
211
254
  ...manifest,
212
- sources: [...flattened.values()],
255
+ sources: stagedSources,
213
256
  ...(manifest.catalogs ? {
214
257
  catalogs: await Promise.all(manifest.catalogs.map(async (profile) => ({
215
258
  ...profile,
216
- sources: (await matchedFiles(workspace, profile.sources ?? [])).map((path) => flattened.get(path)).filter(Boolean),
259
+ sources: allSources.size === 0
260
+ ? stagedSources
261
+ : (await matchedFiles(workspace, profile.sources ?? [])).map((path) => flattened.get(path)).filter(Boolean),
217
262
  }))),
218
263
  } : {}),
219
264
  };
@@ -258,6 +303,40 @@ function copyReferencedFiles(workspace, stage, value) {
258
303
  visit(value);
259
304
  }
260
305
 
306
+ function copyReadmeAssets(workspace, stage, catalog) {
307
+ for (const context of catalog.contexts ?? []) {
308
+ for (const service of context.services ?? []) {
309
+ if (typeof service.path !== "string" || typeof service.readme !== "string") continue;
310
+ const root = resolve(workspace, service.path);
311
+ if (!inside(workspace, root)) continue;
312
+ for (const target of markdownTargets(service.readme)) {
313
+ const source = resolveMarkdownTarget(root, target);
314
+ if (!source || !inside(root, source) || !existsSync(source) || !statSync(source).isFile()) continue;
315
+ const relativeSource = relative(workspace, source).split(sep).join("/");
316
+ copyIntoStage(workspace, stage, relativeSource, `public/portolan-assets/${relativeSource}`);
317
+ }
318
+ }
319
+ }
320
+ }
321
+
322
+ function markdownTargets(markdown) {
323
+ const targets = [];
324
+ const links = /!?\[[^\]]*\]\(\s*(?:<([^>\n]+)>|([^\s)\n]+))(?:\s+["'][^"']*["'])?\s*\)/g;
325
+ for (const match of markdown.matchAll(links)) targets.push(match[1] ?? match[2]);
326
+ const html = /<(?:img|source)\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
327
+ for (const match of markdown.matchAll(html)) targets.push(match[1]);
328
+ return targets.filter(Boolean);
329
+ }
330
+
331
+ function resolveMarkdownTarget(root, target) {
332
+ const value = String(target).trim();
333
+ if (!value || value.startsWith("#") || value.startsWith("/") || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)) return null;
334
+ const pathname = value.split(/[?#]/, 1)[0];
335
+ if (!pathname) return null;
336
+ try { return resolve(root, decodeURIComponent(pathname)); }
337
+ catch { return null; }
338
+ }
339
+
261
340
  function copyIntoStage(workspace, stage, source, target) {
262
341
  const from = resolve(workspace, source);
263
342
  const to = resolve(stage, target);
@@ -295,7 +374,7 @@ function prepareHost(workspace) {
295
374
  mkdirSync(host, { recursive: true });
296
375
  cpSync(resolve(installRoot, "scripts"), resolve(host, "scripts"), { recursive: true });
297
376
  cpSync(resolve(installRoot, "src"), resolve(host, "src"), { recursive: true });
298
- writeFileSync(resolve(host, "package.json"), '{"private":true,"type":"module"}\n');
377
+ writeFileSync(resolve(host, "package.json"), `${JSON.stringify({ private: true, type: "module", version: VERSION })}\n`);
299
378
  symlinkSync(dependencyRoot(), resolve(host, "node_modules"), process.platform === "win32" ? "junction" : "dir");
300
379
  return host;
301
380
  }
@@ -0,0 +1,47 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ import { prepareSite, VERSION } from "./portolan.mjs";
7
+
8
+ const roots = [];
9
+ afterEach(() => {
10
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
11
+ });
12
+
13
+ describe("site staging", () => {
14
+ it("stages a deterministic source for an intentional empty workspace", async () => {
15
+ const root = mkdtempSync(join(tmpdir(), "portolan-empty-site-"));
16
+ roots.push(root);
17
+ writeFileSync(join(root, "portolan.json"), `${JSON.stringify({ sources: ["portolan/*.json"], projects: [], extract: [], verify: [], generate: [] }, null, 2)}\n`);
18
+
19
+ const stage = await prepareSite(root);
20
+ const stagedPackage = JSON.parse(readFileSync(join(stage, "package.json"), "utf8"));
21
+ const stagedManifest = JSON.parse(readFileSync(join(stage, "portolan.json"), "utf8"));
22
+ expect(stagedPackage.version).toBe(VERSION);
23
+ expect(stagedManifest.sources).toEqual(["portolan/source-0000.json"]);
24
+ expect(existsSync(join(stage, "portolan/source-0000.json"))).toBe(true);
25
+ });
26
+
27
+ it("stages binary files referenced by a service README as public assets", async () => {
28
+ const root = mkdtempSync(join(tmpdir(), "portolan-readme-assets-"));
29
+ roots.push(root);
30
+ const project = "vendor/repos/acme/shop";
31
+ mkdirSync(join(root, project, "portolan"), { recursive: true });
32
+ mkdirSync(join(root, project, "docs"), { recursive: true });
33
+ const image = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0x00]);
34
+ writeFileSync(join(root, project, "docs/example.png"), image);
35
+ writeFileSync(join(root, project, "README.md"), "# Shop\n");
36
+ writeFileSync(join(root, project, "portolan/project.json"), `${JSON.stringify({
37
+ generatedAt: "2026-09-09T00:00:00Z",
38
+ commit: "abc1234",
39
+ contexts: [{ id: "shop", slug: "shop", name: "Shop", services: [{ id: "shop.api", slug: "api", name: "API", repo: "github.com/acme/shop", path: project, readme: "![example](./docs/example.png)", provides: [], consumes: [], aggregates: [] }] }],
40
+ defs: {}, flows: [], adrs: [],
41
+ }, null, 2)}\n`);
42
+ writeFileSync(join(root, "portolan.json"), `${JSON.stringify({ sources: [`${project}/portolan/*.json`] }, null, 2)}\n`);
43
+
44
+ const stage = await prepareSite(root);
45
+ expect(readFileSync(join(stage, "public/portolan-assets", project, "docs/example.png"))).toEqual(image);
46
+ });
47
+ });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@shortlink-org/portolan",
3
3
  "description": "Generate a navigable architecture catalog from code and specifications.",
4
4
  "private": false,
5
- "version": "0.2.1",
5
+ "version": "0.2.3",
6
6
  "type": "module",
7
7
  "license": "MIT",
8
8
  "homepage": "https://github.com/shortlink-org/portolan#readme",
@@ -67,19 +67,20 @@
67
67
  "prepack": "npm run plugins:build"
68
68
  },
69
69
  "dependencies": {
70
- "@tailwindcss/vite": "^4.0.0",
71
- "@vitejs/plugin-react": "^6.1.1",
72
- "@ai-sdk/openai-compatible": "3.0.44",
73
- "@ai-sdk/react": "4.0.96",
70
+ "@ai-sdk/openai-compatible": "3.0.45",
71
+ "@ai-sdk/react": "4.0.98",
74
72
  "@asyncapi/react-component": "^3.1.8",
75
73
  "@clack/prompts": "^1.8.0",
74
+ "@floating-ui/react": "^0.27.20",
76
75
  "@headlessui/react": "^2.2.10",
77
76
  "@scalar/api-reference-react": "^0.9.66",
77
+ "@tailwindcss/vite": "^4.0.0",
78
78
  "@tanstack/react-query": "^5.102.8",
79
79
  "@tanstack/react-table": "^9.2.4",
80
80
  "@tanstack/react-virtual": "^3.14.10",
81
+ "@vitejs/plugin-react": "^6.1.1",
81
82
  "@xyflow/react": "^12.11.5",
82
- "ai": "7.0.93",
83
+ "ai": "7.0.95",
83
84
  "ajv": "^8.20.0",
84
85
  "elkjs": "^0.12.0",
85
86
  "html-to-image": "^1.11.13",
@@ -244,7 +244,7 @@ without it the wrapper is shown as an operation inside the current service.
244
244
  vendored from. The code names the verb and the route —
245
245
  `self._http.post("/v1/quotes")`, httpx or requests alike — and the document
246
246
  says which operation answers there, so the call is recorded under the id the
247
- callee's own extractor gives it: `pricing.v1.Quotes/createQuote`, spelled by
247
+ callee's own extractor gives it: `pricing.v1/createQuote`, spelled by
248
248
  the same rules as `plugins/openapi`. The peer is the manifest's `peers` entry
249
249
  for that api id; without one the lane is `unknown` and the step unresolved.
250
250
  Without a document there is no id to share, and the call is recorded against
@@ -265,7 +265,7 @@ class Reading(unittest.TestCase):
265
265
  self.assertEqual(wire["InvoiceIssued"], {"name": "billing.InvoiceIssued", "channel": "shop.billing.invoice"})
266
266
 
267
267
  def test_a_client_call_is_the_id_the_callee_would_give_it(self):
268
- self.assertEqual([c["id"] for c in self.service["consumes"]], ["pricing.v1.Quotes/createQuote"])
268
+ self.assertEqual([c["id"] for c in self.service["consumes"]], ["pricing.v1/createQuote"])
269
269
  self.assertEqual(self.service["consumes"][0]["peer"], "shop.pricing")
270
270
 
271
271
  def test_the_http_contract_points_at_the_generated_openapi_document(self):
@@ -2,7 +2,7 @@
2
2
 
3
3
  The same rules as `plugins/openapi/ids.go`, spelled a second time so that a
4
4
  Django caller and the service that answers spell one call the same way:
5
- `auth.v1.Sessions/validateSession` on both sides, or the call would never
5
+ `auth.v1/validateSession` on both sides, or the call would never
6
6
  resolve to the method.
7
7
 
8
8
  The reader underneath is a small one. A document is a mapping of mappings with
@@ -16,7 +16,6 @@ from __future__ import annotations
16
16
 
17
17
  import json
18
18
  import os
19
- import re
20
19
  from dataclasses import dataclass, field
21
20
  from typing import Any, Dict, List, Optional
22
21
 
@@ -30,18 +29,9 @@ def api_id(title: str, version: str) -> str:
30
29
  return name + ".v" + major if major else name
31
30
 
32
31
 
33
- def title_of(name: str) -> str:
34
- """users becomes Users, price_list becomes PriceList: it sits in an id
35
- beside a proto-shaped service name."""
36
- out = ""
37
- for word in re.split(r"[_\-\s]+", name):
38
- if word:
39
- out += word[0].upper() + word[1:]
40
- return out
41
-
42
-
43
- def interface_id(api: str, tag: str) -> str:
44
- return api + "." + title_of(tag) if tag else api
32
+ def interface_id(api: str, _tag: str) -> str:
33
+ """Tags organise operations inside a contract; they do not create interfaces."""
34
+ return api
45
35
 
46
36
 
47
37
  @dataclass
@@ -20,9 +20,9 @@ class Ids(unittest.TestCase):
20
20
  self.assertEqual(openapi.api_id("Price List", "2.1.0"), "price-list.v2")
21
21
  self.assertEqual(openapi.api_id("", ""), "api")
22
22
 
23
- def test_an_interface_is_the_api_and_the_first_tag(self):
24
- self.assertEqual(openapi.interface_id("auth.v1", "sessions"), "auth.v1.Sessions")
25
- self.assertEqual(openapi.interface_id("auth.v1", "price_list"), "auth.v1.PriceList")
23
+ def test_tags_stay_inside_the_document_interface(self):
24
+ self.assertEqual(openapi.interface_id("auth.v1", "sessions"), "auth.v1")
25
+ self.assertEqual(openapi.interface_id("auth.v1", "price_list"), "auth.v1")
26
26
  self.assertEqual(openapi.interface_id("auth.v1", ""), "auth.v1")
27
27
 
28
28
  def test_a_parameter_is_compared_by_position(self):
@@ -39,7 +39,7 @@ class Read(unittest.TestCase):
39
39
  self.assertEqual([o.id for o in spec.operations], ["createQuote"])
40
40
  found = spec.find("POST", "/v1/quotes")
41
41
  self.assertIsNotNone(found)
42
- self.assertEqual(found.call_id(spec.api), "pricing.v1.Quotes/createQuote")
42
+ self.assertEqual(found.call_id(spec.api), "pricing.v1/createQuote")
43
43
 
44
44
  def test_a_route_the_document_does_not_declare_finds_nothing(self):
45
45
  spec = openapi.read(DOCUMENT)
@@ -29,6 +29,11 @@
29
29
  "type": "string",
30
30
  "description": "What the service is called on the page. Defaults to the title of its README, and failing that to the slug, titled."
31
31
  },
32
+ "scope": {
33
+ "type": "string",
34
+ "description": "Deployable name in a shared Go module. Limits owned source to internal/<scope> while allowing calls to imported application packages to be followed.",
35
+ "examples": ["api", "billing"]
36
+ },
32
37
  "repo": {
33
38
  "type": "string",
34
39
  "description": "Where the source lives. Defaults to the module path in go.mod.",
@@ -18,6 +18,10 @@
18
18
  "enum": ["bounded-context", "system", "product", "team", "namespace"],
19
19
  "description": "Semantic role of the top-level group. Defaults to system for this neutral extractor."
20
20
  },
21
+ "classification": {
22
+ "enum": ["core", "supporting", "generic"],
23
+ "description": "DDD classification of a bounded context when the group represents one."
24
+ },
21
25
  "component": {
22
26
  "type": "string",
23
27
  "description": "Slug of the component. Defaults to the input directory name."
@@ -39,6 +43,23 @@
39
43
  "type": "string",
40
44
  "description": "Source repository identity. Defaults to a Go module, package.json repository, Cargo package, or Maven coordinates when present."
41
45
  },
46
+ "components": {
47
+ "type": "array",
48
+ "minItems": 1,
49
+ "items": {
50
+ "type": "object",
51
+ "additionalProperties": false,
52
+ "required": ["slug"],
53
+ "properties": {
54
+ "slug": { "type": "string", "minLength": 1 },
55
+ "name": { "type": "string", "minLength": 1 },
56
+ "kind": {
57
+ "enum": ["service", "application", "webapp", "worker", "job", "function", "cli", "library", "data-pipeline"]
58
+ }
59
+ }
60
+ },
61
+ "description": "Independently deployable runtime components found inside one repository. When present, the singular component fields are ignored."
62
+ },
42
63
  "out": {
43
64
  "type": "string",
44
65
  "description": "Name of the fragment file inside the step output directory.",
@@ -221,7 +221,7 @@ bus; a field read off one is not.
221
221
  the path in the code - `client.GET("/v1/sessions/current", …)` - and the
222
222
  document vendored beside `gen/types.ts` says which operation answers on that
223
223
  route and which interface it belongs to, spelled by `plugins/openapi` so the
224
- call and the method on the other side share one id: `auth.v1.Sessions/validateSession`.
224
+ call and the method on the other side share one id: `auth.v1/validateSession`.
225
225
  **gRPC peer.** A Connect-ES client is created from a service descriptor whose
226
226
  `typeName` is the proto service, `shop.v1.Pricing`, and a call on it is the
227
227
  method in the descriptor's own case: `client.getQuote(…)` is
@@ -18,7 +18,7 @@ import type { WarningSink } from "./domain.ts";
18
18
 
19
19
  /** One call the adapter makes, in the catalog's terms. */
20
20
  export interface RpcHop {
21
- /** "auth.v1.Sessions/validateSession" or "shop.v1.Pricing/GetQuote" */
21
+ /** "auth.v1/validateSession" or "shop.v1.Pricing/GetQuote" */
22
22
  id: string;
23
23
  /** What the manifest's peers map is keyed by: the api id or the proto package. */
24
24
  pkg: string;
@@ -114,7 +114,7 @@ describe("the service", () => {
114
114
 
115
115
  it("records what it calls, named the way the callee names the method", () => {
116
116
  expect(svc.consumes.map((c: { id: string; peer: string; status: string }) => `${c.id}→${c.peer}:${c.status}`)).toEqual([
117
- "auth.v1.Sessions/validateSession→auth.auth:declared",
117
+ "auth.v1/validateSession→auth.auth:declared",
118
118
  "shop.v1.Pricing/GetQuote→shop.pricing:declared",
119
119
  ]);
120
120
  });
@@ -146,7 +146,7 @@ describe("the flows", () => {
146
146
  ]);
147
147
  expect(checkout.participants.map((p) => `${p.id}/${p.kind}`)).toContain("auth.auth/service");
148
148
  const rpc = checkout.steps[1] as Step;
149
- expect(rpc.ref).toBe("auth.v1.Sessions/validateSession");
149
+ expect(rpc.ref).toBe("auth.v1/validateSession");
150
150
  });
151
151
 
152
152
  it("opens a policy on the bus and draws its choices", () => {
@@ -45,13 +45,13 @@ describe("a service read through its resolvers", () => {
45
45
  it("follows the port on the context to the peer behind it", () => {
46
46
  const [, second] = flow("bff-query-viewer").steps as Step[];
47
47
 
48
- expect(second).toMatchObject({ from: "storefront.bff", to: "auth.auth", ref: "auth.v1.Sessions/validateSession" });
48
+ expect(second).toMatchObject({ from: "storefront.bff", to: "auth.auth", ref: "auth.v1/validateSession" });
49
49
  });
50
50
 
51
51
  it("names the call among what the service consumes", () => {
52
52
  const { consumes } = fragment().contexts[0]!.services[0]!;
53
53
 
54
- expect(consumes).toEqual([{ id: "auth.v1.Sessions/validateSession", peer: "auth.auth", status: "declared", source: `${ROOT}/src/infrastructure/auth/gen/openapi.yaml` }]);
54
+ expect(consumes).toEqual([{ id: "auth.v1/validateSession", peer: "auth.auth", status: "declared", source: `${ROOT}/src/infrastructure/auth/gen/openapi.yaml` }]);
55
55
  });
56
56
 
57
57
  // A resolver that answers out of its arguments reaches nothing, and the flow
@@ -2,7 +2,7 @@ import { mkdtempSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { describe, expect, it } from "vitest";
5
- import { apiID, callID, documentApiID, findOperation, interfaceID, readSpec, tagTitle } from "./openapi.ts";
5
+ import { apiID, callID, documentApiID, findOperation, interfaceID, readSpec } from "./openapi.ts";
6
6
 
7
7
  // The same cases plugins/openapi/ids_test.go holds the Go side to.
8
8
  describe("openapi ids", () => {
@@ -14,9 +14,8 @@ describe("openapi ids", () => {
14
14
  expect(documentApiID(" stripe.v1 ", "Stripe API", "2026-08-26.dahlia")).toBe("stripe.v1");
15
15
  expect(documentApiID("", "Stripe API", "2026-08-26.dahlia")).toBe("stripe-api.v2026-08-26");
16
16
  });
17
- it("titles tags and builds interface ids", () => {
18
- expect(tagTitle("price_list")).toBe("PriceList");
19
- expect(interfaceID("auth.v1", "sessions")).toBe("auth.v1.Sessions");
17
+ it("keeps tags inside the document's interface", () => {
18
+ expect(interfaceID("auth.v1", "sessions")).toBe("auth.v1");
20
19
  expect(interfaceID("auth.v1", "")).toBe("auth.v1");
21
20
  });
22
21
  });
@@ -55,9 +54,9 @@ describe("readSpec and findOperation", () => {
55
54
  });
56
55
  it("finds a route however the parameter is spelled", () => {
57
56
  const login = findOperation(spec, "post", "/v1/sessions")!;
58
- expect(callID(spec, login)).toBe("auth.v1.Sessions/login");
59
- expect(callID(spec, findOperation(spec, "GET", "/v1/users/{userId}")!)).toBe("auth.v1.Users/getUser");
60
- expect(callID(spec, findOperation(spec, "GET", "/v1/users/${id}")!)).toBe("auth.v1.Users/getUser");
57
+ expect(callID(spec, login)).toBe("auth.v1/login");
58
+ expect(callID(spec, findOperation(spec, "GET", "/v1/users/{userId}")!)).toBe("auth.v1/getUser");
59
+ expect(callID(spec, findOperation(spec, "GET", "/v1/users/${id}")!)).toBe("auth.v1/getUser");
61
60
  expect(callID(spec, findOperation(spec, "GET", "/v1/health")!)).toBe("auth.v1/GET /v1/health");
62
61
  expect(findOperation(spec, "DELETE", "/v1/sessions")).toBeUndefined();
63
62
  });
@@ -25,16 +25,9 @@ export function documentApiID(declared: string, title: string, version: string):
25
25
  }
26
26
 
27
27
  /** users → Users, price_list → PriceList. */
28
- export function tagTitle(name: string): string {
29
- return name
30
- .split(/[_\- ]+/)
31
- .filter(Boolean)
32
- .map((w) => (w[0]! >= "a" && w[0]! <= "z" ? w[0]!.toUpperCase() + w.slice(1) : w))
33
- .join("");
34
- }
35
-
36
- export function interfaceID(api: string, tag: string): string {
37
- return tag ? `${api}.${tagTitle(tag)}` : api;
28
+ /** Tags organise operations inside an OpenAPI contract; they do not create interfaces. */
29
+ export function interfaceID(api: string, _tag: string): string {
30
+ return api;
38
31
  }
39
32
 
40
33
  export const VERBS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
Binary file
Binary file
Binary file