@shortlink-org/portolan 0.2.0 → 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.
- package/cli/init.mjs +14 -6
- package/cli/init.test.mjs +8 -1
- package/cli/portolan.mjs +56 -7
- package/cli/portolan.test.mjs +47 -0
- package/package.json +7 -6
- package/plugins/extract-django/README.md +1 -1
- package/plugins/extract-django/extract_test.py +1 -1
- package/plugins/extract-django/openapi.py +4 -14
- package/plugins/extract-django/openapi_test.py +4 -4
- package/plugins/extract-go/options.schema.json +5 -0
- package/plugins/extract-project/options.schema.json +21 -0
- package/plugins/extract-ts/README.md +1 -1
- package/plugins/extract-ts/clients.ts +1 -1
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/graphql.test.ts +2 -2
- package/plugins/extract-ts/openapi.test.ts +6 -7
- package/plugins/extract-ts/openapi.ts +3 -10
- package/plugins/portolan-go.wasm +0 -0
- package/public/404-cat-v1.webp +0 -0
- package/public/cat-about-v1.webp +0 -0
- package/public/cat-clear-skies-v1.webp +0 -0
- package/public/cat-no-changes-v1.webp +0 -0
- package/public/cat-onboarding-v1.webp +0 -0
- package/public/cat-trial-success-v1.webp +0 -0
- package/schema/portolan.schema.json +61 -0
- package/scripts/catalog-sources.mjs +12 -1
- package/scripts/catalog-sources.test.mjs +35 -0
- package/scripts/gen-likec4.mjs +242 -85
- package/scripts/gen-likec4.test.mjs +168 -14
- package/scripts/gen.mjs +5 -3
- package/scripts/host-plugins/fetch-git.mjs +22 -12
- package/scripts/host-plugins/fetch-git.test.mjs +25 -5
- package/scripts/local-api.mjs +368 -34
- package/scripts/local-api.test.mjs +197 -2
- package/scripts/package-smoke.mjs +19 -2
- package/scripts/plugin-host.mjs +14 -3
- package/scripts/plugin-host.test.mjs +8 -0
- package/scripts/schema.mjs +7 -0
- package/src/app/CatalogApp.tsx +19 -4
- package/src/app/CommandPalette.tsx +7 -2
- package/src/app/toast.tsx +11 -7
- package/src/catalog.test.ts +33 -0
- package/src/catalog.ts +190 -8
- package/src/components/CatIllustration.tsx +30 -0
- package/src/components/CommitLink.tsx +154 -0
- package/src/components/EditorLink.tsx +15 -8
- package/src/components/GrpcMethodReference.tsx +93 -0
- package/src/components/Markdown.tsx +17 -0
- package/src/components/MethodRows.tsx +8 -1
- package/src/components/PageHeader.test.ts +24 -0
- package/src/components/PageHeader.tsx +34 -0
- package/src/components/ShapeRows.tsx +12 -0
- package/src/components/SourceDoc.tsx +27 -12
- package/src/components/SourcePreview.tsx +124 -36
- package/src/enrich.test.ts +209 -2
- package/src/enrich.ts +284 -15
- package/src/er/RedisSchema.tsx +86 -5
- package/src/flow/FlowTable.tsx +19 -4
- package/src/flow/StepDetail.tsx +461 -101
- package/src/flow/StepRail.tsx +36 -13
- package/src/flow/answers-response.test.ts +100 -0
- package/src/flow/answers.ts +11 -8
- package/src/flow/chapters.ts +2 -1
- package/src/flow/labels.ts +10 -0
- package/src/flow/mermaid.test.ts +87 -3
- package/src/flow/mermaid.ts +26 -14
- package/src/index.css +132 -0
- package/src/landing/LandingPage.tsx +12 -8
- package/src/lib/api.test.ts +4 -6
- package/src/lib/build-info.test.ts +29 -0
- package/src/lib/build-info.ts +21 -0
- package/src/lib/data-model.ts +14 -0
- package/src/lib/flow-tree.ts +1 -1
- package/src/lib/github-catalog.test.ts +51 -0
- package/src/lib/github-catalog.ts +69 -0
- package/src/lib/grpc-reference.test.ts +72 -0
- package/src/lib/grpc-reference.ts +117 -0
- package/src/lib/local-api.ts +17 -4
- package/src/lib/product.ts +7 -0
- package/src/lib/queries.ts +16 -1
- package/src/lib/readme-assets.test.ts +19 -0
- package/src/lib/readme-assets.ts +34 -0
- package/src/lib/setup-info.test.ts +7 -0
- package/src/lib/setup-info.ts +5 -0
- package/src/lib/source-link.test.ts +106 -18
- package/src/lib/source-link.ts +22 -6
- package/src/merge.test.ts +55 -0
- package/src/merge.ts +30 -0
- package/src/pages/AdrDetail.tsx +3 -2
- package/src/pages/AdrIndex.tsx +10 -7
- package/src/pages/AggregatePage.tsx +34 -4
- package/src/pages/BlockPage.tsx +39 -32
- package/src/pages/Changes.tsx +7 -9
- package/src/pages/ContextMap.tsx +11 -2
- package/src/pages/ContextPage.tsx +11 -4
- package/src/pages/FlowIndex.tsx +16 -7
- package/src/pages/GraphPage.tsx +21 -7
- package/src/pages/Language.tsx +9 -9
- package/src/pages/NotFound.tsx +58 -15
- package/src/pages/Overview.tsx +6 -0
- package/src/pages/Problems.tsx +10 -8
- package/src/pages/RegistryIndex.tsx +10 -8
- package/src/pages/ServicePage.tsx +1 -1
- package/src/pages/Settings.tsx +382 -44
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +213 -113
- package/src/testing/setup.ts +14 -0
- package/vite.config.ts +5 -1
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
|
|
79
|
-
if (
|
|
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(
|
|
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
|
|
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"),
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
225
|
+
sources: stagedSources,
|
|
213
226
|
...(manifest.catalogs ? {
|
|
214
227
|
catalogs: await Promise.all(manifest.catalogs.map(async (profile) => ({
|
|
215
228
|
...profile,
|
|
216
|
-
sources:
|
|
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"),
|
|
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: "", 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.
|
|
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
|
-
"@
|
|
71
|
-
"@
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
34
|
-
"""
|
|
35
|
-
|
|
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
|
|
24
|
-
self.assertEqual(openapi.interface_id("auth.v1", "sessions"), "auth.v1
|
|
25
|
-
self.assertEqual(openapi.interface_id("auth.v1", "price_list"), "auth.v1
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
18
|
-
expect(
|
|
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
|
|
59
|
-
expect(callID(spec, findOperation(spec, "GET", "/v1/users/{userId}")!)).toBe("auth.v1
|
|
60
|
-
expect(callID(spec, findOperation(spec, "GET", "/v1/users/${id}")!)).toBe("auth.v1
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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"];
|
package/plugins/portolan-go.wasm
CHANGED
|
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.",
|