@shortlink-org/portolan 0.2.2 → 0.2.4
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/README.md +7 -2
- package/cli/portolan.mjs +36 -6
- package/package.json +4 -4
- package/plugins/README.md +5 -0
- package/plugins/extract-python-kafka/README.md +6 -0
- package/plugins/extract-python-kafka/extract.py +2 -2
- package/plugins/extract-python-kafka/extract_test.py +17 -1
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +12 -1
- package/plugins/pyplugin/kafka.py +74 -3
- package/scripts/builtin-plugins.mjs +3 -2
- package/scripts/catalog-sources.mjs +2 -1
- package/scripts/catalog-sources.test.mjs +11 -1
- package/scripts/delivery-presets.mjs +207 -24
- package/scripts/diff.mjs +5 -1
- package/scripts/gen-likec4.mjs +78 -20
- package/scripts/gen-likec4.test.mjs +25 -2
- package/scripts/local-api.mjs +26 -377
- package/scripts/local-api.test.mjs +50 -0
- package/scripts/local-discovery.mjs +378 -0
- package/scripts/manifest.mjs +42 -1
- package/scripts/manifest.test.mjs +24 -1
- package/scripts/run-builtin.mjs +4 -2
- package/scripts/schema.mjs +4 -0
- package/scripts/site-docs.mjs +2 -2
- package/src/app/Breadcrumbs.test.ts +4 -0
- package/src/app/Breadcrumbs.tsx +1 -0
- package/src/app/CatalogApp.tsx +4 -4
- package/src/app/Sidebar.tsx +13 -730
- package/src/app/SidebarFlowSections.tsx +251 -0
- package/src/app/SidebarFooter.tsx +160 -0
- package/src/app/SidebarTree.tsx +322 -0
- package/src/catalog-index.ts +485 -0
- package/src/catalog-model.ts +1360 -0
- package/src/catalog-stores.test.ts +17 -0
- package/src/catalog-validation.ts +1611 -0
- package/src/catalog.test.ts +29 -2
- package/src/catalog.ts +6 -3306
- package/src/components/ChannelRows.messagepack.test.tsx +28 -0
- package/src/components/ChannelRows.test.tsx +54 -0
- package/src/components/ChannelRows.tsx +57 -10
- package/src/components/LifecycleDiagram.tsx +28 -12
- package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
- package/src/components/ProblemRow.tsx +7 -0
- package/src/components/SourcePreview.tsx +1 -1
- package/src/components/WhatLinksHere.tsx +6 -4
- package/src/enrich.test.ts +4 -5
- package/src/flow/StepDetail.tsx +98 -54
- package/src/flow/answers.test.ts +18 -1
- package/src/flow/answers.ts +37 -8
- package/src/index.css +0 -17
- package/src/landing/LandingPage.tsx +4 -4
- package/src/lib/all-problems.ts +1 -1
- package/src/lib/backlinks.test.ts +16 -1
- package/src/lib/backlinks.ts +20 -0
- package/src/lib/catalog-diff.test.ts +18 -0
- package/src/lib/catalog-diff.ts +19 -1
- package/src/lib/derive.ts +2 -0
- package/src/lib/kafka-ui.test.ts +87 -0
- package/src/lib/kafka-ui.ts +105 -0
- package/src/lib/local-api.ts +19 -7
- package/src/lib/motion.test.ts +4 -2
- package/src/lib/motion.tsx +5 -4
- package/src/lib/proto-problems.test.ts +170 -3
- package/src/lib/proto-problems.ts +176 -4
- package/src/lib/wire-problems.test.ts +21 -0
- package/src/lib/wire-problems.ts +62 -1
- package/src/likec4/FlowView.tsx +2 -6
- package/src/likec4/flow-edges.test.ts +64 -1
- package/src/likec4/flow-edges.ts +43 -7
- package/src/likec4/view-index.ts +8 -2
- package/src/merge.test.ts +69 -0
- package/src/merge.ts +52 -2
- package/src/pages/CatalogFailure.tsx +2 -2
- package/src/pages/Settings.tsx +12 -129
- package/src/pages/settings/AboutSettings.tsx +129 -0
- package/src/pages/settings/DeliverySettings.tsx +71 -15
- package/src/pages/settings/IntegrationsSettings.tsx +117 -0
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +46 -1
- package/src/selection/pages.test.ts +14 -1
- package/src/selection/pages.ts +9 -3
- package/vite.config.ts +3 -1
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import {
|
|
2
|
+
lstatSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
realpathSync,
|
|
6
|
+
statSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { basename, join, posix, relative, resolve, sep } from "node:path";
|
|
9
|
+
|
|
10
|
+
const SKIP = new Set([".git", ".portolan", "build", "dist", "node_modules", "target", "vendor"]);
|
|
11
|
+
const MAX_FILES = 12_000;
|
|
12
|
+
const MAX_COMPONENTS = 100;
|
|
13
|
+
const MAX_SOURCE_BYTES = 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
function safeRoot(workspace, input) {
|
|
16
|
+
if (typeof input !== "string" || !input.trim()) throw new Error("Project path is required.");
|
|
17
|
+
const clean = input.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
18
|
+
if (clean.startsWith("/") || clean.split("/").includes("..")) throw new Error("Project path must stay inside this repository.");
|
|
19
|
+
const workspaceReal = realpathSync(workspace);
|
|
20
|
+
const targetReal = realpathSync(resolve(workspaceReal, clean));
|
|
21
|
+
if (targetReal !== workspaceReal && !targetReal.startsWith(`${workspaceReal}${sep}`)) {
|
|
22
|
+
throw new Error("Project path resolves outside this repository.");
|
|
23
|
+
}
|
|
24
|
+
const root = relative(workspaceReal, targetReal).replaceAll(sep, "/") || ".";
|
|
25
|
+
return { root, absolute: targetReal };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A UTF-8 source file inside the served workspace, never outside it. */
|
|
29
|
+
export function readLocalSource(workspace, input) {
|
|
30
|
+
if (typeof input !== "string" || !input.trim()) throw new Error("Source path is required.");
|
|
31
|
+
const clean = input.trim().replaceAll("\\", "/").replace(/^\.\//, "");
|
|
32
|
+
if (clean.includes("\0") || clean.startsWith("/") || clean.split("/").includes("..")) {
|
|
33
|
+
throw new Error("Source path must stay inside this repository.");
|
|
34
|
+
}
|
|
35
|
+
const workspaceReal = realpathSync(workspace);
|
|
36
|
+
const targetReal = realpathSync(resolve(workspaceReal, clean));
|
|
37
|
+
if (targetReal !== workspaceReal && !targetReal.startsWith(`${workspaceReal}${sep}`)) {
|
|
38
|
+
throw new Error("Source path resolves outside this repository.");
|
|
39
|
+
}
|
|
40
|
+
const stat = statSync(targetReal);
|
|
41
|
+
if (!stat.isFile()) throw new Error("Source path is not a file.");
|
|
42
|
+
if (stat.size > MAX_SOURCE_BYTES) throw new Error("Source file is larger than the 1 MB preview limit.");
|
|
43
|
+
const bytes = readFileSync(targetReal);
|
|
44
|
+
if (bytes.includes(0)) throw new Error("Source file is binary.");
|
|
45
|
+
let content;
|
|
46
|
+
try {
|
|
47
|
+
content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error("Source file is not UTF-8 text.");
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
path: relative(workspaceReal, targetReal).replaceAll(sep, "/"),
|
|
53
|
+
content,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function walk(root) {
|
|
58
|
+
const files = new Set();
|
|
59
|
+
const pending = [{ absolute: root, relative: "" }];
|
|
60
|
+
while (pending.length && files.size < MAX_FILES) {
|
|
61
|
+
const current = pending.pop();
|
|
62
|
+
for (const entry of readdirSync(current.absolute, { withFileTypes: true })) {
|
|
63
|
+
if (SKIP.has(entry.name) || (entry.isDirectory() && entry.name.startsWith("."))) continue;
|
|
64
|
+
const name = current.relative ? `${current.relative}/${entry.name}` : entry.name;
|
|
65
|
+
const absolute = join(current.absolute, entry.name);
|
|
66
|
+
const stat = lstatSync(absolute);
|
|
67
|
+
if (stat.isSymbolicLink()) continue;
|
|
68
|
+
if (stat.isDirectory()) pending.push({ absolute, relative: name });
|
|
69
|
+
else if (stat.isFile()) files.add(name);
|
|
70
|
+
if (files.size >= MAX_FILES) break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return files;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function slug(value) {
|
|
77
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function matches(files, pattern) {
|
|
81
|
+
return [...files].filter((name) => pattern.test(name)).sort((a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function compactDirectories(paths) {
|
|
85
|
+
const directories = [...new Set(paths.map((name) => posix.dirname(name)))].sort((a, b) => a.length - b.length);
|
|
86
|
+
return directories.filter((dir, index) => !directories.some((parent, other) => other < index && (dir === parent || dir.startsWith(`${parent}/`))));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const COMPONENT_MARKERS = new Map([
|
|
90
|
+
["go.mod", "Go"],
|
|
91
|
+
["package.json", "Node.js"],
|
|
92
|
+
["Cargo.toml", "Rust"],
|
|
93
|
+
["pom.xml", "Java"],
|
|
94
|
+
["build.gradle", "Java"],
|
|
95
|
+
["build.gradle.kts", "Kotlin"],
|
|
96
|
+
["manage.py", "Django"],
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
function componentCandidates(files) {
|
|
100
|
+
const roots = new Map();
|
|
101
|
+
for (const name of files) {
|
|
102
|
+
const marker = posix.basename(name);
|
|
103
|
+
const technology = COMPONENT_MARKERS.get(marker);
|
|
104
|
+
if (!technology) continue;
|
|
105
|
+
const path = posix.dirname(name);
|
|
106
|
+
const key = path === "." ? "." : path;
|
|
107
|
+
if (key.split("/").some((segment) => ["fixture", "fixtures", "test", "tests", "testdata"].includes(segment.toLowerCase()))) continue;
|
|
108
|
+
const found = roots.get(key) ?? { path: key, markers: new Set(), technologies: new Set() };
|
|
109
|
+
found.markers.add(marker);
|
|
110
|
+
found.technologies.add(technology);
|
|
111
|
+
roots.set(key, found);
|
|
112
|
+
}
|
|
113
|
+
return [...roots.values()]
|
|
114
|
+
.sort((a, b) => a.path === "." ? -1 : b.path === "." ? 1 : a.path.localeCompare(b.path))
|
|
115
|
+
.slice(0, MAX_COMPONENTS)
|
|
116
|
+
.map((candidate) => {
|
|
117
|
+
const base = candidate.path === "." ? "repository root" : posix.basename(candidate.path);
|
|
118
|
+
return {
|
|
119
|
+
path: candidate.path,
|
|
120
|
+
name: base.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" ") || base,
|
|
121
|
+
markers: [...candidate.markers].sort(),
|
|
122
|
+
technologies: [...candidate.technologies].sort(),
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function titleFromSlug(value) {
|
|
128
|
+
const acronyms = new Set(["api", "cli", "grpc", "http"]);
|
|
129
|
+
return value.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((part) => acronyms.has(part.toLowerCase()) ? part.toUpperCase() : part[0]?.toUpperCase() + part.slice(1)).join(" ") || value;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// cmd/* is a convention, not an architectural boundary by itself: many Go
|
|
133
|
+
// repositories keep migrations and administrative tools there. A runnable is
|
|
134
|
+
// promoted to a deployable only when build/deployment evidence independently
|
|
135
|
+
// names the same entrypoint.
|
|
136
|
+
function goDeployables(root, files) {
|
|
137
|
+
if (!files.has("go.mod")) return [];
|
|
138
|
+
const mains = new Map();
|
|
139
|
+
for (const name of matches(files, /^cmd\/[^/]+\/[^/]+\.go$/)) {
|
|
140
|
+
let source = "";
|
|
141
|
+
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
142
|
+
if (!/^\s*package\s+main\b/m.test(source) || !/\bfunc\s+main\s*\(/.test(source)) continue;
|
|
143
|
+
const component = name.split("/")[1];
|
|
144
|
+
const found = mains.get(component) ?? [];
|
|
145
|
+
found.push(name);
|
|
146
|
+
mains.set(component, found);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const buildFiles = matches(files, /(^|\/)(?:Dockerfile|[^/]+\.Dockerfile|Makefile|[^/]*compose[^/]*\.ya?ml|[^/]+\.ya?ml)$/i);
|
|
150
|
+
const out = [];
|
|
151
|
+
for (const [component, entrypoints] of [...mains].sort(([a], [b]) => a.localeCompare(b))) {
|
|
152
|
+
const escaped = component.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
153
|
+
const target = new RegExp(`(?:^|[\\s"'=])(?:\\./)?cmd/${escaped}(?=$|[\\s"'])`, "m");
|
|
154
|
+
const corroboration = [];
|
|
155
|
+
for (const name of buildFiles) {
|
|
156
|
+
let source = "";
|
|
157
|
+
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
158
|
+
if (target.test(source)) corroboration.push(name);
|
|
159
|
+
}
|
|
160
|
+
out.push({
|
|
161
|
+
slug: slug(component),
|
|
162
|
+
name: titleFromSlug(component),
|
|
163
|
+
path: `cmd/${component}`,
|
|
164
|
+
kind: "service",
|
|
165
|
+
confidence: corroboration.length ? "high" : "medium",
|
|
166
|
+
evidence: [...entrypoints, ...corroboration],
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function detected(plugin, candidates, options = {}, label = candidates[0], ambiguous = false, selected = true) {
|
|
173
|
+
if (!candidates.length) return null;
|
|
174
|
+
return {
|
|
175
|
+
plugin,
|
|
176
|
+
confidence: ambiguous && candidates.length > 1 ? "medium" : "high",
|
|
177
|
+
evidence: label,
|
|
178
|
+
candidates,
|
|
179
|
+
options,
|
|
180
|
+
selected,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function compatibleAdrs(root, candidates) {
|
|
185
|
+
return candidates.filter((name) => {
|
|
186
|
+
let source = "";
|
|
187
|
+
try { source = readFileSync(join(root, name), "utf8"); } catch { return false; }
|
|
188
|
+
return /^#\s+[^\n]+\.\d{4}\s+[—-]/m.test(source) && /^-\s+\*\*Status:\*\*/mi.test(source) && /^-\s+\*\*Date:\*\*/mi.test(source);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function goDomainEvidence(root, files) {
|
|
193
|
+
const layout = /^internal\/(?:domain\/([^/]+)|([^/]+)\/domain)\/[^/]+\.go$/i;
|
|
194
|
+
const candidates = matches(files, layout);
|
|
195
|
+
for (const name of candidates) {
|
|
196
|
+
const match = layout.exec(name);
|
|
197
|
+
if (!match) continue;
|
|
198
|
+
const aggregate = match[1] ?? match[2];
|
|
199
|
+
const packageName = aggregate.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
200
|
+
const rootName = aggregate
|
|
201
|
+
.split(/[^a-zA-Z0-9]+|_/)
|
|
202
|
+
.filter(Boolean)
|
|
203
|
+
.map((part) => part[0]?.toUpperCase() + part.slice(1))
|
|
204
|
+
.join("");
|
|
205
|
+
let source = "";
|
|
206
|
+
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
207
|
+
const packagePattern = new RegExp(`\\bpackage\\s+(?:${packageName}|domain)\\b`);
|
|
208
|
+
const rootPattern = new RegExp(`\\btype\\s+${rootName}\\s+struct\\s*\\{`);
|
|
209
|
+
if (packagePattern.test(source) && rootPattern.test(source)) return name;
|
|
210
|
+
}
|
|
211
|
+
return "";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function laidOutDomainEvidence(root, files, language) {
|
|
215
|
+
const extension = language === "typescript" ? "ts" : language === "rust" ? "rs" : "java";
|
|
216
|
+
const prefix = language === "java" ? /(?:^|\/)domain\/([^/]+)\/[^/]+\.java$/i : /^src\/domain\/([^/]+)\/[^/]+\.(?:ts|rs)$/i;
|
|
217
|
+
for (const name of matches(files, prefix)) {
|
|
218
|
+
if (!name.endsWith(`.${extension}`)) continue;
|
|
219
|
+
const match = prefix.exec(name);
|
|
220
|
+
if (!match) continue;
|
|
221
|
+
const rootName = match[1].split(/[^a-zA-Z0-9]+|_/).filter(Boolean).map((part) => part[0]?.toUpperCase() + part.slice(1)).join("");
|
|
222
|
+
let source = "";
|
|
223
|
+
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
224
|
+
const claim = language === "typescript"
|
|
225
|
+
? new RegExp(`\\b(?:export\\s+)?class\\s+${rootName}\\b`)
|
|
226
|
+
: language === "rust"
|
|
227
|
+
? new RegExp(`\\bpub\\s+struct\\s+${rootName}\\b`)
|
|
228
|
+
: /@AggregateRoot\b/.test(source) || new RegExp(`\\bclass\\s+${rootName}\\b`).test(source);
|
|
229
|
+
if (claim instanceof RegExp ? claim.test(source) : claim) return name;
|
|
230
|
+
}
|
|
231
|
+
return "";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function goHTTPClientEvidence(root, files) {
|
|
235
|
+
for (const name of matches(files, /\.go$/).filter((name) => !name.endsWith("_test.go"))) {
|
|
236
|
+
let source = "";
|
|
237
|
+
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
238
|
+
if (
|
|
239
|
+
/\bhttp\.(?:NewRequest(?:WithContext)?|Get|Post|PostForm|Head)\s*\(/.test(source)
|
|
240
|
+
|| /ClientWithResponses(?:Interface)?\b/.test(source)
|
|
241
|
+
|| /github\.com\/hooklift\/gowsdl\/soap/.test(source)
|
|
242
|
+
) return name;
|
|
243
|
+
}
|
|
244
|
+
return "";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function goSOAPClientEvidence(root, files) {
|
|
248
|
+
for (const name of matches(files, /\.go$/).filter((name) => !name.endsWith("_test.go"))) {
|
|
249
|
+
let source = "";
|
|
250
|
+
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
251
|
+
if (
|
|
252
|
+
/github\.com\/hooklift\/gowsdl\/soap/.test(source)
|
|
253
|
+
|| /\bCallContext\s*\([^,]+,\s*[^,]*(?:soap)?action/i.test(source)
|
|
254
|
+
|| /(?:Header\.)?(?:Add|Set)\s*\(\s*["'](?:SOAPAction|Content-Type)["']/i.test(source)
|
|
255
|
+
) return name;
|
|
256
|
+
}
|
|
257
|
+
return "";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function goRedisEvidence(root, files) {
|
|
261
|
+
const redisImport = /github\.com\/(?:redis\/go-redis(?:\/v\d+)?|go-redis\/redis(?:\/v\d+)?|redis\/rueidis|gomodule\/redigo\/redis)(?=\")/g;
|
|
262
|
+
for (const name of matches(files, /\.go$/).filter((name) => !name.endsWith("_test.go"))) {
|
|
263
|
+
let source = "";
|
|
264
|
+
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
265
|
+
const imports = [...source.matchAll(redisImport)].map((match) => match[0]);
|
|
266
|
+
if (!imports.length) continue;
|
|
267
|
+
|
|
268
|
+
const aliases = new Set();
|
|
269
|
+
for (const importPath of imports) {
|
|
270
|
+
const escaped = importPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
271
|
+
const declaration = new RegExp(`(?:^|\\n)\\s*(?:import\\s+)?(?:([A-Za-z_][A-Za-z0-9_]*)\\s+)?\"${escaped}\"`, "m").exec(source);
|
|
272
|
+
const alias = declaration?.[1];
|
|
273
|
+
if (alias && alias !== "_" && alias !== ".") aliases.add(alias);
|
|
274
|
+
else aliases.add(importPath.includes("rueidis") ? "rueidis" : "redis");
|
|
275
|
+
}
|
|
276
|
+
if ([...aliases].some((alias) => new RegExp(`\\b${alias}\\.(?:NewClient|NewClusterClient|NewFailoverClient|NewFailoverClusterClient|Dial|DialURL)\\s*\\(`).test(source))) {
|
|
277
|
+
return name;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return "";
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function detectionsFor(root, files) {
|
|
284
|
+
let goMod = "";
|
|
285
|
+
if (files.has("go.mod")) {
|
|
286
|
+
try { goMod = readFileSync(join(root, "go.mod"), "utf8"); } catch {}
|
|
287
|
+
}
|
|
288
|
+
const openapi = matches(files, /(^|\/)(openapi|swagger)[^/]*\.(ya?ml|json)$/i);
|
|
289
|
+
const wsdls = matches(files, /\.wsdl$/i);
|
|
290
|
+
const asyncapi = matches(files, /(^|\/)asyncapi[^/]*\.(ya?ml|json)$/i);
|
|
291
|
+
const graphql = matches(files, /\.graphqls?$/i);
|
|
292
|
+
const protos = matches(files, /\.proto$/i);
|
|
293
|
+
const sql = matches(files, /(^|\/)(migrations?|repository)(\/|.*\/).*\.sql$/i);
|
|
294
|
+
const adrs = matches(files, /(^|\/)(docs\/adr|adr)\/.*\.md$/i);
|
|
295
|
+
const supportedAdrs = compatibleAdrs(root, adrs);
|
|
296
|
+
const glossaries = matches(files, /(^|\/)glossary\.md$/i);
|
|
297
|
+
// The app module is the one file a Celery project always has; the tasks
|
|
298
|
+
// and the calls that enqueue them are found from there.
|
|
299
|
+
const celery = matches(files, /(^|\/)celery\.py$/);
|
|
300
|
+
const sqlRoots = [...new Set(sql.map((name) => {
|
|
301
|
+
const segments = name.split("/");
|
|
302
|
+
const repository = segments.findIndex((part) => /^(repository|repositories)$/i.test(part));
|
|
303
|
+
return repository >= 0 ? segments.slice(0, repository + 1).join("/") : "";
|
|
304
|
+
}).filter(Boolean))];
|
|
305
|
+
const featureSql = sqlRoots.some((name) => /^internal\/[^/]+\/infrastructure\/repository$/i.test(name));
|
|
306
|
+
const sqlRoot = sqlRoots.length === 1 && !featureSql ? sqlRoots[0] : "";
|
|
307
|
+
const graphqlDirs = compactDirectories(graphql);
|
|
308
|
+
const protoDirs = compactDirectories(protos);
|
|
309
|
+
const projectMarkers = ["go.mod", "package.json", "Cargo.toml", "pom.xml", "build.gradle", "build.gradle.kts", "manage.py", "Dockerfile", "README.md"].filter((name) => files.has(name));
|
|
310
|
+
const projectEvidence = projectMarkers.length ? projectMarkers : [[...files].sort()[0]].filter(Boolean);
|
|
311
|
+
const goDomain = files.has("go.mod") ? goDomainEvidence(root, files) : "";
|
|
312
|
+
const goHTTPClient = files.has("go.mod") ? goHTTPClientEvidence(root, files) : "";
|
|
313
|
+
const goSOAPClient = files.has("go.mod") ? goSOAPClientEvidence(root, files) : "";
|
|
314
|
+
const goRedis = files.has("go.mod") ? goRedisEvidence(root, files) : "";
|
|
315
|
+
const tsDomain = files.has("package.json") ? laidOutDomainEvidence(root, files, "typescript") : "";
|
|
316
|
+
const rustDomain = files.has("Cargo.toml") ? laidOutDomainEvidence(root, files, "rust") : "";
|
|
317
|
+
const javaDomain = ["pom.xml", "build.gradle", "build.gradle.kts"].some((name) => files.has(name)) ? laidOutDomainEvidence(root, files, "java") : "";
|
|
318
|
+
return [
|
|
319
|
+
detected("project", projectEvidence, {}, projectEvidence.join(", ")),
|
|
320
|
+
detected("go-domain", goDomain ? [goDomain] : [], {}, goDomain),
|
|
321
|
+
detected("ts-domain", tsDomain ? [tsDomain] : [], {}, tsDomain),
|
|
322
|
+
detected("rust-domain", rustDomain ? [rustDomain] : [], {}, rustDomain),
|
|
323
|
+
detected("java-domain", javaDomain ? [javaDomain] : [], {}, javaDomain),
|
|
324
|
+
detected("django-domain", files.has("manage.py") && matches(files, /(^|\/)models(?:\/[^/]+)?\.py$/i).length ? ["manage.py"] : []),
|
|
325
|
+
detected("celery", celery, {}, celery[0]),
|
|
326
|
+
detected("openapi", openapi, openapi[0] ? { spec: openapi[0] } : {}, openapi[0], true),
|
|
327
|
+
detected(
|
|
328
|
+
"wsdl",
|
|
329
|
+
wsdls,
|
|
330
|
+
{
|
|
331
|
+
...(wsdls.length === 1 ? { spec: wsdls[0] } : {}),
|
|
332
|
+
...(goSOAPClient ? { mode: "external" } : {}),
|
|
333
|
+
},
|
|
334
|
+
wsdls.length === 1 ? wsdls[0] : `${wsdls.length} WSDL documents`,
|
|
335
|
+
true,
|
|
336
|
+
),
|
|
337
|
+
detected("http-clients", goHTTPClient ? [goHTTPClient] : [], {}, goHTTPClient),
|
|
338
|
+
detected("redis", goRedis ? [goRedis] : [], {}, goRedis),
|
|
339
|
+
detected("river", goMod.includes("github.com/riverqueue/river") ? ["go.mod"] : [], {}, "go.mod · github.com/riverqueue/river"),
|
|
340
|
+
detected("watermill", goMod.includes("github.com/ThreeDotsLabs/watermill") ? ["go.mod"] : [], {}, "go.mod · github.com/ThreeDotsLabs/watermill"),
|
|
341
|
+
detected("asyncapi", asyncapi, asyncapi[0] ? { spec: asyncapi[0] } : {}, asyncapi[0], true),
|
|
342
|
+
detected("graphql", graphql, graphqlDirs[0] ? { schema: graphqlDirs.length === 1 ? graphqlDirs[0] : graphql[0] } : {}, graphqlDirs.length === 1 ? graphqlDirs[0] : graphql[0]),
|
|
343
|
+
detected("proto", protos, protoDirs.length ? { paths: protoDirs } : {}, protoDirs.join(", ")),
|
|
344
|
+
detected("sql", sql, sqlRoot ? { repositories: sqlRoot } : {}, sqlRoot || (sqlRoots.length > 1 ? `${sqlRoots.length} repository packages` : sql[0])),
|
|
345
|
+
detected(
|
|
346
|
+
"adr",
|
|
347
|
+
adrs,
|
|
348
|
+
supportedAdrs[0] ? { files: [`${posix.dirname(supportedAdrs[0])}/*.md`] } : {},
|
|
349
|
+
supportedAdrs[0] ? `${posix.dirname(supportedAdrs[0])}/*.md` : `${posix.dirname(adrs[0] ?? "docs/adr/x.md")}/*.md (format not recognized)`,
|
|
350
|
+
!supportedAdrs.length,
|
|
351
|
+
supportedAdrs.length > 0,
|
|
352
|
+
),
|
|
353
|
+
detected("glossary", glossaries, glossaries.length ? { files: glossaries } : {}, glossaries.join(", ")),
|
|
354
|
+
].filter(Boolean);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function discoverProject(workspace, input) {
|
|
358
|
+
const { root, absolute } = safeRoot(workspace, input);
|
|
359
|
+
const files = walk(absolute);
|
|
360
|
+
const detections = detectionsFor(absolute, files);
|
|
361
|
+
const components = componentCandidates(files);
|
|
362
|
+
const deployables = goDeployables(absolute, files);
|
|
363
|
+
return {
|
|
364
|
+
root,
|
|
365
|
+
filesScanned: files.size,
|
|
366
|
+
truncated: files.size >= MAX_FILES,
|
|
367
|
+
components,
|
|
368
|
+
componentsTruncated: components.length >= MAX_COMPONENTS,
|
|
369
|
+
deployables,
|
|
370
|
+
defaults: projectDefaults(basename(absolute)),
|
|
371
|
+
detections,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function projectDefaults(value) {
|
|
376
|
+
const id = slug(value) || "service";
|
|
377
|
+
return { id, name: id.split("-").map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" "), group: id, component: id, context: id, service: id };
|
|
378
|
+
}
|
package/scripts/manifest.mjs
CHANGED
|
@@ -25,7 +25,19 @@ const schemaFile = () => process.env.PORTOLAN_SCHEMA || "schema/portolan.schema.
|
|
|
25
25
|
* that has not run `npm run schema` yet should still be able to generate.
|
|
26
26
|
*/
|
|
27
27
|
export function loadManifest(path = "portolan.json") {
|
|
28
|
-
|
|
28
|
+
return parseManifest(readFileSync(path, "utf8"), path);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parses and validates manifest text. This is for manifests that do not live
|
|
33
|
+
* in the working tree, such as the copy read from another git revision.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} text
|
|
36
|
+
* @param {string} path
|
|
37
|
+
* @returns {{manifest: object, problems: string[]}}
|
|
38
|
+
*/
|
|
39
|
+
export function parseManifest(text, path = "portolan.json") {
|
|
40
|
+
const manifest = JSON.parse(text);
|
|
29
41
|
|
|
30
42
|
let schema;
|
|
31
43
|
try {
|
|
@@ -42,6 +54,35 @@ export function loadManifest(path = "portolan.json") {
|
|
|
42
54
|
return { manifest, problems: explain(validate.errors ?? [], manifest, schema, path) };
|
|
43
55
|
}
|
|
44
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The runtime entry point: a caller either gets a schema-valid manifest or a
|
|
59
|
+
* single actionable error. Code that wants to present every problem itself
|
|
60
|
+
* can keep using loadManifest/parseManifest.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} path
|
|
63
|
+
* @returns {object}
|
|
64
|
+
*/
|
|
65
|
+
export function readManifest(path = "portolan.json") {
|
|
66
|
+
return requireValidManifest(loadManifest(path));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @param {string} text @param {string} [path] */
|
|
70
|
+
export function readManifestText(text, path = "portolan.json") {
|
|
71
|
+
return requireValidManifest(parseManifest(text, path));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @param {{manifest: object, problems: string[]}} loaded */
|
|
75
|
+
export function requireValidManifest(loaded) {
|
|
76
|
+
if (loaded.problems.length > 0) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`portolan.json does not match schema/portolan.schema.json:\n${loaded.problems
|
|
79
|
+
.map((problem) => ` - ${problem}`)
|
|
80
|
+
.join("\n")}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return loaded.manifest;
|
|
84
|
+
}
|
|
85
|
+
|
|
45
86
|
/**
|
|
46
87
|
* Turns ajv's errors into lines somebody can act on.
|
|
47
88
|
*
|
|
@@ -8,7 +8,14 @@ import { join } from "node:path";
|
|
|
8
8
|
|
|
9
9
|
import { describe, expect, it } from "vitest";
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
loadManifest,
|
|
13
|
+
parseManifest,
|
|
14
|
+
readManifest,
|
|
15
|
+
readManifestText,
|
|
16
|
+
requireValidManifest,
|
|
17
|
+
stepKeys,
|
|
18
|
+
} from "./manifest.mjs";
|
|
12
19
|
|
|
13
20
|
const dir = mkdtempSync(join(tmpdir(), "portolan-manifest-"));
|
|
14
21
|
|
|
@@ -108,6 +115,22 @@ describe("the manifest schema", () => {
|
|
|
108
115
|
expect(problems).toHaveLength(1);
|
|
109
116
|
expect(problems[0]).toContain('extract/0: "out" is missing');
|
|
110
117
|
});
|
|
118
|
+
|
|
119
|
+
it("parses manifest text through the same validator", () => {
|
|
120
|
+
expect(parseManifest(JSON.stringify(good), "from-git:portolan.json").problems).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("gives runtime callers only a valid manifest", () => {
|
|
124
|
+
const path = join(dir, "good.json");
|
|
125
|
+
writeFileSync(path, JSON.stringify(good));
|
|
126
|
+
expect(readManifest(path)).toEqual(good);
|
|
127
|
+
expect(readManifestText(JSON.stringify(good), "memory:portolan.json")).toEqual(good);
|
|
128
|
+
|
|
129
|
+
expect(() => requireValidManifest({
|
|
130
|
+
manifest: {},
|
|
131
|
+
problems: ['portolan.json: "sources" is missing'],
|
|
132
|
+
})).toThrow('portolan.json: "sources" is missing');
|
|
133
|
+
});
|
|
111
134
|
});
|
|
112
135
|
|
|
113
136
|
describe("stepKeys", () => {
|
package/scripts/run-builtin.mjs
CHANGED
|
@@ -7,14 +7,16 @@
|
|
|
7
7
|
// host (portolan.0008).
|
|
8
8
|
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
|
-
import { existsSync
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
11
|
import { dirname, resolve } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
|
|
14
|
+
import { readManifest } from "./manifest.mjs";
|
|
15
|
+
|
|
14
16
|
const installRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
17
|
const workspace = process.cwd();
|
|
16
18
|
const name = process.argv[2];
|
|
17
|
-
const manifest =
|
|
19
|
+
const manifest = readManifest(resolve(installRoot, "portolan.json"));
|
|
18
20
|
const plugin = (manifest.plugins ?? []).find((candidate) => candidate.name === name);
|
|
19
21
|
|
|
20
22
|
if (!plugin?.process) {
|
package/scripts/schema.mjs
CHANGED
|
@@ -23,6 +23,10 @@ import { describePlugin } from "./plugin-host.mjs";
|
|
|
23
23
|
const OUT = "schema/portolan.schema.json";
|
|
24
24
|
|
|
25
25
|
const check = process.argv.includes("--check");
|
|
26
|
+
// Bootstrap exception: this command produces the schema that loadManifest
|
|
27
|
+
// validates against, so requiring the previous schema here would make a new
|
|
28
|
+
// manifest field impossible to introduce. Every consumer of the composed
|
|
29
|
+
// schema goes through scripts/manifest.mjs.
|
|
26
30
|
const manifest = JSON.parse(readFileSync("portolan.json", "utf8"));
|
|
27
31
|
|
|
28
32
|
const described = new Map();
|
package/scripts/site-docs.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { cpSync, existsSync, readFileSync, statSync, writeFileSync } from "node:
|
|
|
26
26
|
import { extname, isAbsolute, join, normalize, posix, resolve, sep } from "node:path";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
|
|
29
|
-
import { loadManifest } from "./manifest.mjs";
|
|
29
|
+
import { loadManifest, readManifest } from "./manifest.mjs";
|
|
30
30
|
|
|
31
31
|
const LLMS = "llms.txt";
|
|
32
32
|
const LLMS_FULL = "llms-full.txt";
|
|
@@ -261,7 +261,7 @@ export function siteDocsPlugin(workspace = process.cwd()) {
|
|
|
261
261
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
262
262
|
let manifest;
|
|
263
263
|
try {
|
|
264
|
-
manifest =
|
|
264
|
+
manifest = readManifest(join(workspace, "portolan.json"));
|
|
265
265
|
} catch {
|
|
266
266
|
return next();
|
|
267
267
|
}
|
|
@@ -31,6 +31,10 @@ describe("crumbsFor", () => {
|
|
|
31
31
|
{ label: "settings", to: "/settings" },
|
|
32
32
|
{ label: "delivery", to: "/settings/delivery" },
|
|
33
33
|
]);
|
|
34
|
+
expect(crumbsFor("/settings/integrations")).toEqual([
|
|
35
|
+
{ label: "settings", to: "/settings" },
|
|
36
|
+
{ label: "integrations", to: "/settings/integrations" },
|
|
37
|
+
]);
|
|
34
38
|
});
|
|
35
39
|
|
|
36
40
|
it("reads 'data' as a literal, not as an aggregate", () => {
|
package/src/app/Breadcrumbs.tsx
CHANGED
package/src/app/CatalogApp.tsx
CHANGED
|
@@ -355,9 +355,9 @@ function Shell() {
|
|
|
355
355
|
{narrow ? (
|
|
356
356
|
<>
|
|
357
357
|
{/* `key` on the route content is what makes the page transition
|
|
358
|
-
fire: a new pathname is a new element.
|
|
359
|
-
|
|
360
|
-
<AnimatePresence mode="
|
|
358
|
+
fire: a new pathname is a new element. `popLayout` lets the old
|
|
359
|
+
page lift away while the new one rises, without a blank frame. */}
|
|
360
|
+
<AnimatePresence mode="popLayout" initial={false}>
|
|
361
361
|
<m.main
|
|
362
362
|
key={pathname}
|
|
363
363
|
{...page}
|
|
@@ -398,7 +398,7 @@ function Shell() {
|
|
|
398
398
|
<Panel id="main" className="h-full min-w-0" onResize={settle}>
|
|
399
399
|
{/* The detail rail rides along with every page that draws a
|
|
400
400
|
diagram, so a selection made anywhere has somewhere to be read. */}
|
|
401
|
-
<AnimatePresence mode="
|
|
401
|
+
<AnimatePresence mode="popLayout" initial={false}>
|
|
402
402
|
<m.main
|
|
403
403
|
key={pathname}
|
|
404
404
|
{...page}
|