@shortlink-org/portolan 0.2.1 → 0.2.2

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 (107) hide show
  1. package/cli/init.mjs +14 -6
  2. package/cli/init.test.mjs +8 -1
  3. package/cli/portolan.mjs +56 -7
  4. package/cli/portolan.test.mjs +47 -0
  5. package/package.json +7 -6
  6. package/plugins/extract-django/README.md +1 -1
  7. package/plugins/extract-django/extract_test.py +1 -1
  8. package/plugins/extract-django/openapi.py +4 -14
  9. package/plugins/extract-django/openapi_test.py +4 -4
  10. package/plugins/extract-go/options.schema.json +5 -0
  11. package/plugins/extract-project/options.schema.json +21 -0
  12. package/plugins/extract-ts/README.md +1 -1
  13. package/plugins/extract-ts/clients.ts +1 -1
  14. package/plugins/extract-ts/extract.test.ts +2 -2
  15. package/plugins/extract-ts/graphql.test.ts +2 -2
  16. package/plugins/extract-ts/openapi.test.ts +6 -7
  17. package/plugins/extract-ts/openapi.ts +3 -10
  18. package/plugins/portolan-go.wasm +0 -0
  19. package/public/404-cat-v1.webp +0 -0
  20. package/public/cat-about-v1.webp +0 -0
  21. package/public/cat-clear-skies-v1.webp +0 -0
  22. package/public/cat-no-changes-v1.webp +0 -0
  23. package/public/cat-onboarding-v1.webp +0 -0
  24. package/public/cat-trial-success-v1.webp +0 -0
  25. package/schema/portolan.schema.json +61 -0
  26. package/scripts/catalog-sources.mjs +12 -1
  27. package/scripts/catalog-sources.test.mjs +35 -0
  28. package/scripts/gen-likec4.mjs +242 -85
  29. package/scripts/gen-likec4.test.mjs +168 -14
  30. package/scripts/gen.mjs +5 -3
  31. package/scripts/host-plugins/fetch-git.mjs +22 -12
  32. package/scripts/host-plugins/fetch-git.test.mjs +25 -5
  33. package/scripts/local-api.mjs +363 -28
  34. package/scripts/local-api.test.mjs +195 -1
  35. package/scripts/plugin-host.mjs +14 -3
  36. package/scripts/plugin-host.test.mjs +8 -0
  37. package/scripts/schema.mjs +7 -0
  38. package/src/app/CatalogApp.tsx +19 -4
  39. package/src/app/CommandPalette.tsx +7 -2
  40. package/src/app/toast.tsx +11 -7
  41. package/src/catalog.test.ts +33 -0
  42. package/src/catalog.ts +190 -8
  43. package/src/components/CatIllustration.tsx +30 -0
  44. package/src/components/CommitLink.tsx +154 -0
  45. package/src/components/EditorLink.tsx +15 -8
  46. package/src/components/GrpcMethodReference.tsx +93 -0
  47. package/src/components/Markdown.tsx +17 -0
  48. package/src/components/MethodRows.tsx +8 -1
  49. package/src/components/PageHeader.test.ts +24 -0
  50. package/src/components/PageHeader.tsx +34 -0
  51. package/src/components/ShapeRows.tsx +12 -0
  52. package/src/components/SourceDoc.tsx +27 -12
  53. package/src/components/SourcePreview.tsx +124 -36
  54. package/src/enrich.test.ts +209 -2
  55. package/src/enrich.ts +284 -15
  56. package/src/er/RedisSchema.tsx +86 -5
  57. package/src/flow/FlowTable.tsx +19 -4
  58. package/src/flow/StepDetail.tsx +461 -101
  59. package/src/flow/StepRail.tsx +36 -13
  60. package/src/flow/answers-response.test.ts +100 -0
  61. package/src/flow/answers.ts +11 -8
  62. package/src/flow/chapters.ts +2 -1
  63. package/src/flow/labels.ts +10 -0
  64. package/src/flow/mermaid.test.ts +87 -3
  65. package/src/flow/mermaid.ts +26 -14
  66. package/src/index.css +132 -0
  67. package/src/landing/LandingPage.tsx +12 -8
  68. package/src/lib/api.test.ts +4 -6
  69. package/src/lib/build-info.test.ts +29 -0
  70. package/src/lib/build-info.ts +21 -0
  71. package/src/lib/data-model.ts +14 -0
  72. package/src/lib/flow-tree.ts +1 -1
  73. package/src/lib/github-catalog.test.ts +51 -0
  74. package/src/lib/github-catalog.ts +69 -0
  75. package/src/lib/grpc-reference.test.ts +72 -0
  76. package/src/lib/grpc-reference.ts +117 -0
  77. package/src/lib/local-api.ts +17 -4
  78. package/src/lib/product.ts +7 -0
  79. package/src/lib/queries.ts +16 -1
  80. package/src/lib/readme-assets.test.ts +19 -0
  81. package/src/lib/readme-assets.ts +34 -0
  82. package/src/lib/setup-info.test.ts +7 -0
  83. package/src/lib/setup-info.ts +5 -0
  84. package/src/lib/source-link.test.ts +106 -18
  85. package/src/lib/source-link.ts +22 -6
  86. package/src/merge.test.ts +55 -0
  87. package/src/merge.ts +30 -0
  88. package/src/pages/AdrDetail.tsx +3 -2
  89. package/src/pages/AdrIndex.tsx +10 -7
  90. package/src/pages/AggregatePage.tsx +34 -4
  91. package/src/pages/BlockPage.tsx +39 -32
  92. package/src/pages/Changes.tsx +7 -9
  93. package/src/pages/ContextMap.tsx +11 -2
  94. package/src/pages/ContextPage.tsx +11 -4
  95. package/src/pages/FlowIndex.tsx +16 -7
  96. package/src/pages/GraphPage.tsx +21 -7
  97. package/src/pages/Language.tsx +9 -9
  98. package/src/pages/NotFound.tsx +58 -15
  99. package/src/pages/Overview.tsx +6 -0
  100. package/src/pages/Problems.tsx +10 -8
  101. package/src/pages/RegistryIndex.tsx +10 -8
  102. package/src/pages/ServicePage.tsx +1 -1
  103. package/src/pages/Settings.tsx +382 -44
  104. package/src/routes.test.ts +2 -0
  105. package/src/routes.ts +2 -1
  106. package/src/selection/DetailPanel.tsx +213 -113
  107. package/src/testing/setup.ts +14 -0
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
@@ -175,7 +175,7 @@ async function dev(workspace, options) {
175
175
  });
176
176
  }
177
177
 
178
- async function prepareSite(workspace) {
178
+ export async function prepareSite(workspace) {
179
179
  const manifestPath = resolve(workspace, "portolan.json");
180
180
  if (!existsSync(manifestPath)) fail("portolan.json is missing; run portolan init first");
181
181
  const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
@@ -189,14 +189,18 @@ async function prepareSite(workspace) {
189
189
  for (const file of ["index.html", "vite.config.ts", "tsconfig.json"]) {
190
190
  copyFileSync(resolve(installRoot, file), resolve(stage, file));
191
191
  }
192
- writeFileSync(resolve(stage, "package.json"), '{"private":true,"type":"module"}\n');
192
+ writeFileSync(resolve(stage, "package.json"), `${JSON.stringify({ private: true, type: "module", version: VERSION })}\n`);
193
193
 
194
194
  const patternSets = [manifest.sources ?? [], ...(manifest.catalogs ?? []).map((profile) => profile.sources ?? [])];
195
195
  const allSources = new Set();
196
196
  for (const patterns of patternSets) {
197
197
  for (const path of await matchedFiles(workspace, patterns)) allSources.add(path);
198
198
  }
199
- if (allSources.size === 0) fail("no catalog source matches portolan.json; run portolan generate first");
199
+ const emptyWorkspace = Array.isArray(manifest.projects)
200
+ && manifest.projects.length === 0
201
+ && (manifest.extract ?? []).length === 0
202
+ && (manifest.verify ?? []).length === 0;
203
+ if (allSources.size === 0 && !emptyWorkspace) fail("no catalog source matches portolan.json; run portolan generate first");
200
204
 
201
205
  const flattened = new Map();
202
206
  let ordinal = 0;
@@ -204,16 +208,27 @@ async function prepareSite(workspace) {
204
208
  const name = `portolan/source-${String(++ordinal).padStart(4, "0")}.json`;
205
209
  flattened.set(source, name);
206
210
  copyIntoStage(workspace, stage, source, name);
207
- copyReferencedFiles(workspace, stage, JSON.parse(readFileSync(resolve(workspace, source), "utf8")));
211
+ const catalog = JSON.parse(readFileSync(resolve(workspace, source), "utf8"));
212
+ copyReferencedFiles(workspace, stage, catalog);
213
+ copyReadmeAssets(workspace, stage, catalog);
214
+ }
215
+ let stagedSources = [...flattened.values()];
216
+ if (allSources.size === 0) {
217
+ const name = "portolan/source-0000.json";
218
+ mkdirSync(dirname(resolve(stage, name)), { recursive: true });
219
+ writeFileSync(resolve(stage, name), `${JSON.stringify({ generatedAt: "1970-01-01T00:00:00Z", commit: "empty", contexts: [], defs: {}, flows: [], adrs: [] }, null, 2)}\n`);
220
+ stagedSources = [name];
208
221
  }
209
222
 
210
223
  const stagedManifest = {
211
224
  ...manifest,
212
- sources: [...flattened.values()],
225
+ sources: stagedSources,
213
226
  ...(manifest.catalogs ? {
214
227
  catalogs: await Promise.all(manifest.catalogs.map(async (profile) => ({
215
228
  ...profile,
216
- sources: (await matchedFiles(workspace, profile.sources ?? [])).map((path) => flattened.get(path)).filter(Boolean),
229
+ sources: allSources.size === 0
230
+ ? stagedSources
231
+ : (await matchedFiles(workspace, profile.sources ?? [])).map((path) => flattened.get(path)).filter(Boolean),
217
232
  }))),
218
233
  } : {}),
219
234
  };
@@ -258,6 +273,40 @@ function copyReferencedFiles(workspace, stage, value) {
258
273
  visit(value);
259
274
  }
260
275
 
276
+ function copyReadmeAssets(workspace, stage, catalog) {
277
+ for (const context of catalog.contexts ?? []) {
278
+ for (const service of context.services ?? []) {
279
+ if (typeof service.path !== "string" || typeof service.readme !== "string") continue;
280
+ const root = resolve(workspace, service.path);
281
+ if (!inside(workspace, root)) continue;
282
+ for (const target of markdownTargets(service.readme)) {
283
+ const source = resolveMarkdownTarget(root, target);
284
+ if (!source || !inside(root, source) || !existsSync(source) || !statSync(source).isFile()) continue;
285
+ const relativeSource = relative(workspace, source).split(sep).join("/");
286
+ copyIntoStage(workspace, stage, relativeSource, `public/portolan-assets/${relativeSource}`);
287
+ }
288
+ }
289
+ }
290
+ }
291
+
292
+ function markdownTargets(markdown) {
293
+ const targets = [];
294
+ const links = /!?\[[^\]]*\]\(\s*(?:<([^>\n]+)>|([^\s)\n]+))(?:\s+["'][^"']*["'])?\s*\)/g;
295
+ for (const match of markdown.matchAll(links)) targets.push(match[1] ?? match[2]);
296
+ const html = /<(?:img|source)\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
297
+ for (const match of markdown.matchAll(html)) targets.push(match[1]);
298
+ return targets.filter(Boolean);
299
+ }
300
+
301
+ function resolveMarkdownTarget(root, target) {
302
+ const value = String(target).trim();
303
+ if (!value || value.startsWith("#") || value.startsWith("/") || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)) return null;
304
+ const pathname = value.split(/[?#]/, 1)[0];
305
+ if (!pathname) return null;
306
+ try { return resolve(root, decodeURIComponent(pathname)); }
307
+ catch { return null; }
308
+ }
309
+
261
310
  function copyIntoStage(workspace, stage, source, target) {
262
311
  const from = resolve(workspace, source);
263
312
  const to = resolve(stage, target);
@@ -295,7 +344,7 @@ function prepareHost(workspace) {
295
344
  mkdirSync(host, { recursive: true });
296
345
  cpSync(resolve(installRoot, "scripts"), resolve(host, "scripts"), { recursive: true });
297
346
  cpSync(resolve(installRoot, "src"), resolve(host, "src"), { recursive: true });
298
- writeFileSync(resolve(host, "package.json"), '{"private":true,"type":"module"}\n');
347
+ writeFileSync(resolve(host, "package.json"), `${JSON.stringify({ private: true, type: "module", version: VERSION })}\n`);
299
348
  symlinkSync(dependencyRoot(), resolve(host, "node_modules"), process.platform === "win32" ? "junction" : "dir");
300
349
  return host;
301
350
  }
@@ -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.2",
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.97",
74
72
  "@asyncapi/react-component": "^3.1.8",
75
73
  "@clack/prompts": "^1.8.0",
74
+ "@floating-ui/react": "^0.27.0",
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.94",
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
Binary file
Binary file
Binary file
Binary file
@@ -888,6 +888,16 @@
888
888
  "minLength": 1,
889
889
  "description": "Component slug inside the group."
890
890
  },
891
+ "components": {
892
+ "type": "array",
893
+ "minItems": 2,
894
+ "uniqueItems": true,
895
+ "items": {
896
+ "type": "string",
897
+ "minLength": 1
898
+ },
899
+ "description": "Deployable component slugs discovered inside one repository-backed project."
900
+ },
891
901
  "groupKind": {
892
902
  "enum": [
893
903
  "bounded-context",
@@ -1025,6 +1035,14 @@
1025
1035
  ],
1026
1036
  "description": "Semantic role of the top-level group. Defaults to system for this neutral extractor."
1027
1037
  },
1038
+ "classification": {
1039
+ "enum": [
1040
+ "core",
1041
+ "supporting",
1042
+ "generic"
1043
+ ],
1044
+ "description": "DDD classification of a bounded context when the group represents one."
1045
+ },
1028
1046
  "component": {
1029
1047
  "type": "string",
1030
1048
  "description": "Slug of the component. Defaults to the input directory name."
@@ -1058,6 +1076,41 @@
1058
1076
  "type": "string",
1059
1077
  "description": "Source repository identity. Defaults to a Go module, package.json repository, Cargo package, or Maven coordinates when present."
1060
1078
  },
1079
+ "components": {
1080
+ "type": "array",
1081
+ "minItems": 1,
1082
+ "items": {
1083
+ "type": "object",
1084
+ "additionalProperties": false,
1085
+ "required": [
1086
+ "slug"
1087
+ ],
1088
+ "properties": {
1089
+ "slug": {
1090
+ "type": "string",
1091
+ "minLength": 1
1092
+ },
1093
+ "name": {
1094
+ "type": "string",
1095
+ "minLength": 1
1096
+ },
1097
+ "kind": {
1098
+ "enum": [
1099
+ "service",
1100
+ "application",
1101
+ "webapp",
1102
+ "worker",
1103
+ "job",
1104
+ "function",
1105
+ "cli",
1106
+ "library",
1107
+ "data-pipeline"
1108
+ ]
1109
+ }
1110
+ }
1111
+ },
1112
+ "description": "Independently deployable runtime components found inside one repository. When present, the singular component fields are ignored."
1113
+ },
1061
1114
  "out": {
1062
1115
  "type": "string",
1063
1116
  "description": "Name of the fragment file inside the step output directory.",
@@ -1133,6 +1186,14 @@
1133
1186
  "type": "string",
1134
1187
  "description": "What the service is called on the page. Defaults to the title of its README, and failing that to the slug, titled."
1135
1188
  },
1189
+ "scope": {
1190
+ "type": "string",
1191
+ "description": "Deployable name in a shared Go module. Limits owned source to internal/<scope> while allowing calls to imported application packages to be followed.",
1192
+ "examples": [
1193
+ "api",
1194
+ "billing"
1195
+ ]
1196
+ },
1136
1197
  "repo": {
1137
1198
  "type": "string",
1138
1199
  "description": "Where the source lives. Defaults to the module path in go.mod.",