@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
package/scripts/local-api.mjs
CHANGED
|
@@ -18,17 +18,23 @@ import { tmpdir } from "node:os";
|
|
|
18
18
|
import { createServer as createNetServer } from "node:net";
|
|
19
19
|
import { basename, dirname, join, posix, relative, resolve, sep } from "node:path";
|
|
20
20
|
|
|
21
|
-
import { loadManifest } from "./manifest.mjs";
|
|
21
|
+
import { loadManifest, readManifest, readManifestText } from "./manifest.mjs";
|
|
22
22
|
import { builtinPluginNames } from "./builtin-plugins.mjs";
|
|
23
23
|
import { installDeliveryPreset, planDeliveryPreset, publicDeliveryPreset } from "./delivery-presets.mjs";
|
|
24
|
+
import {
|
|
25
|
+
discoverProject,
|
|
26
|
+
matches,
|
|
27
|
+
projectDefaults,
|
|
28
|
+
readLocalSource,
|
|
29
|
+
slug,
|
|
30
|
+
walk,
|
|
31
|
+
} from "./local-discovery.mjs";
|
|
32
|
+
|
|
33
|
+
export { discoverProject, readLocalSource } from "./local-discovery.mjs";
|
|
24
34
|
|
|
25
35
|
export const LOCAL_API_PREFIX = "/__portolan";
|
|
26
36
|
export const GENERATOR_EVENT_PREFIX = "::portolan-event::";
|
|
27
37
|
|
|
28
|
-
const SKIP = new Set([".git", ".portolan", "build", "dist", "node_modules", "target", "vendor"]);
|
|
29
|
-
const MAX_FILES = 12_000;
|
|
30
|
-
const MAX_COMPONENTS = 100;
|
|
31
|
-
const MAX_SOURCE_BYTES = 1024 * 1024;
|
|
32
38
|
const jobs = new Map();
|
|
33
39
|
const removalUndos = new Map();
|
|
34
40
|
const repositoryCredentials = new Map();
|
|
@@ -44,370 +50,6 @@ export function localApiPath(pathname, base = "/") {
|
|
|
44
50
|
return pathname;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
function safeRoot(workspace, input) {
|
|
48
|
-
if (typeof input !== "string" || !input.trim()) throw new Error("Project path is required.");
|
|
49
|
-
const clean = input.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
50
|
-
if (clean.startsWith("/") || clean.split("/").includes("..")) throw new Error("Project path must stay inside this repository.");
|
|
51
|
-
const workspaceReal = realpathSync(workspace);
|
|
52
|
-
const targetReal = realpathSync(resolve(workspaceReal, clean));
|
|
53
|
-
if (targetReal !== workspaceReal && !targetReal.startsWith(`${workspaceReal}${sep}`)) {
|
|
54
|
-
throw new Error("Project path resolves outside this repository.");
|
|
55
|
-
}
|
|
56
|
-
const root = relative(workspaceReal, targetReal).replaceAll(sep, "/") || ".";
|
|
57
|
-
return { root, absolute: targetReal };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** A UTF-8 source file inside the served workspace, never outside it. */
|
|
61
|
-
export function readLocalSource(workspace, input) {
|
|
62
|
-
if (typeof input !== "string" || !input.trim()) throw new Error("Source path is required.");
|
|
63
|
-
const clean = input.trim().replaceAll("\\", "/").replace(/^\.\//, "");
|
|
64
|
-
if (clean.includes("\0") || clean.startsWith("/") || clean.split("/").includes("..")) {
|
|
65
|
-
throw new Error("Source path must stay inside this repository.");
|
|
66
|
-
}
|
|
67
|
-
const workspaceReal = realpathSync(workspace);
|
|
68
|
-
const targetReal = realpathSync(resolve(workspaceReal, clean));
|
|
69
|
-
if (targetReal !== workspaceReal && !targetReal.startsWith(`${workspaceReal}${sep}`)) {
|
|
70
|
-
throw new Error("Source path resolves outside this repository.");
|
|
71
|
-
}
|
|
72
|
-
const stat = statSync(targetReal);
|
|
73
|
-
if (!stat.isFile()) throw new Error("Source path is not a file.");
|
|
74
|
-
if (stat.size > MAX_SOURCE_BYTES) throw new Error("Source file is larger than the 1 MB preview limit.");
|
|
75
|
-
const bytes = readFileSync(targetReal);
|
|
76
|
-
if (bytes.includes(0)) throw new Error("Source file is binary.");
|
|
77
|
-
let content;
|
|
78
|
-
try {
|
|
79
|
-
content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
80
|
-
} catch {
|
|
81
|
-
throw new Error("Source file is not UTF-8 text.");
|
|
82
|
-
}
|
|
83
|
-
return {
|
|
84
|
-
path: relative(workspaceReal, targetReal).replaceAll(sep, "/"),
|
|
85
|
-
content,
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function walk(root) {
|
|
90
|
-
const files = new Set();
|
|
91
|
-
const pending = [{ absolute: root, relative: "" }];
|
|
92
|
-
while (pending.length && files.size < MAX_FILES) {
|
|
93
|
-
const current = pending.pop();
|
|
94
|
-
for (const entry of readdirSync(current.absolute, { withFileTypes: true })) {
|
|
95
|
-
if (SKIP.has(entry.name) || (entry.isDirectory() && entry.name.startsWith("."))) continue;
|
|
96
|
-
const name = current.relative ? `${current.relative}/${entry.name}` : entry.name;
|
|
97
|
-
const absolute = join(current.absolute, entry.name);
|
|
98
|
-
const stat = lstatSync(absolute);
|
|
99
|
-
if (stat.isSymbolicLink()) continue;
|
|
100
|
-
if (stat.isDirectory()) pending.push({ absolute, relative: name });
|
|
101
|
-
else if (stat.isFile()) files.add(name);
|
|
102
|
-
if (files.size >= MAX_FILES) break;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return files;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function slug(value) {
|
|
109
|
-
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function matches(files, pattern) {
|
|
113
|
-
return [...files].filter((name) => pattern.test(name)).sort((a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b));
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function compactDirectories(paths) {
|
|
117
|
-
const directories = [...new Set(paths.map((name) => posix.dirname(name)))].sort((a, b) => a.length - b.length);
|
|
118
|
-
return directories.filter((dir, index) => !directories.some((parent, other) => other < index && (dir === parent || dir.startsWith(`${parent}/`))));
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const COMPONENT_MARKERS = new Map([
|
|
122
|
-
["go.mod", "Go"],
|
|
123
|
-
["package.json", "Node.js"],
|
|
124
|
-
["Cargo.toml", "Rust"],
|
|
125
|
-
["pom.xml", "Java"],
|
|
126
|
-
["build.gradle", "Java"],
|
|
127
|
-
["build.gradle.kts", "Kotlin"],
|
|
128
|
-
["manage.py", "Django"],
|
|
129
|
-
]);
|
|
130
|
-
|
|
131
|
-
function componentCandidates(files) {
|
|
132
|
-
const roots = new Map();
|
|
133
|
-
for (const name of files) {
|
|
134
|
-
const marker = posix.basename(name);
|
|
135
|
-
const technology = COMPONENT_MARKERS.get(marker);
|
|
136
|
-
if (!technology) continue;
|
|
137
|
-
const path = posix.dirname(name);
|
|
138
|
-
const key = path === "." ? "." : path;
|
|
139
|
-
if (key.split("/").some((segment) => ["fixture", "fixtures", "test", "tests", "testdata"].includes(segment.toLowerCase()))) continue;
|
|
140
|
-
const found = roots.get(key) ?? { path: key, markers: new Set(), technologies: new Set() };
|
|
141
|
-
found.markers.add(marker);
|
|
142
|
-
found.technologies.add(technology);
|
|
143
|
-
roots.set(key, found);
|
|
144
|
-
}
|
|
145
|
-
return [...roots.values()]
|
|
146
|
-
.sort((a, b) => a.path === "." ? -1 : b.path === "." ? 1 : a.path.localeCompare(b.path))
|
|
147
|
-
.slice(0, MAX_COMPONENTS)
|
|
148
|
-
.map((candidate) => {
|
|
149
|
-
const base = candidate.path === "." ? "repository root" : posix.basename(candidate.path);
|
|
150
|
-
return {
|
|
151
|
-
path: candidate.path,
|
|
152
|
-
name: base.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" ") || base,
|
|
153
|
-
markers: [...candidate.markers].sort(),
|
|
154
|
-
technologies: [...candidate.technologies].sort(),
|
|
155
|
-
};
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function titleFromSlug(value) {
|
|
160
|
-
const acronyms = new Set(["api", "cli", "grpc", "http"]);
|
|
161
|
-
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;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// cmd/* is a convention, not an architectural boundary by itself: many Go
|
|
165
|
-
// repositories keep migrations and administrative tools there. A runnable is
|
|
166
|
-
// promoted to a deployable only when build/deployment evidence independently
|
|
167
|
-
// names the same entrypoint.
|
|
168
|
-
function goDeployables(root, files) {
|
|
169
|
-
if (!files.has("go.mod")) return [];
|
|
170
|
-
const mains = new Map();
|
|
171
|
-
for (const name of matches(files, /^cmd\/[^/]+\/[^/]+\.go$/)) {
|
|
172
|
-
let source = "";
|
|
173
|
-
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
174
|
-
if (!/^\s*package\s+main\b/m.test(source) || !/\bfunc\s+main\s*\(/.test(source)) continue;
|
|
175
|
-
const component = name.split("/")[1];
|
|
176
|
-
const found = mains.get(component) ?? [];
|
|
177
|
-
found.push(name);
|
|
178
|
-
mains.set(component, found);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const buildFiles = matches(files, /(^|\/)(?:Dockerfile|[^/]+\.Dockerfile|Makefile|[^/]*compose[^/]*\.ya?ml|[^/]+\.ya?ml)$/i);
|
|
182
|
-
const out = [];
|
|
183
|
-
for (const [component, entrypoints] of [...mains].sort(([a], [b]) => a.localeCompare(b))) {
|
|
184
|
-
const escaped = component.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
185
|
-
const target = new RegExp(`(?:^|[\\s"'=])(?:\\./)?cmd/${escaped}(?=$|[\\s"'])`, "m");
|
|
186
|
-
const corroboration = [];
|
|
187
|
-
for (const name of buildFiles) {
|
|
188
|
-
let source = "";
|
|
189
|
-
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
190
|
-
if (target.test(source)) corroboration.push(name);
|
|
191
|
-
}
|
|
192
|
-
out.push({
|
|
193
|
-
slug: slug(component),
|
|
194
|
-
name: titleFromSlug(component),
|
|
195
|
-
path: `cmd/${component}`,
|
|
196
|
-
kind: "service",
|
|
197
|
-
confidence: corroboration.length ? "high" : "medium",
|
|
198
|
-
evidence: [...entrypoints, ...corroboration],
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
return out;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function detected(plugin, candidates, options = {}, label = candidates[0], ambiguous = false, selected = true) {
|
|
205
|
-
if (!candidates.length) return null;
|
|
206
|
-
return {
|
|
207
|
-
plugin,
|
|
208
|
-
confidence: ambiguous && candidates.length > 1 ? "medium" : "high",
|
|
209
|
-
evidence: label,
|
|
210
|
-
candidates,
|
|
211
|
-
options,
|
|
212
|
-
selected,
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function compatibleAdrs(root, candidates) {
|
|
217
|
-
return candidates.filter((name) => {
|
|
218
|
-
let source = "";
|
|
219
|
-
try { source = readFileSync(join(root, name), "utf8"); } catch { return false; }
|
|
220
|
-
return /^#\s+[^\n]+\.\d{4}\s+[—-]/m.test(source) && /^-\s+\*\*Status:\*\*/mi.test(source) && /^-\s+\*\*Date:\*\*/mi.test(source);
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function goDomainEvidence(root, files) {
|
|
225
|
-
const layout = /^internal\/(?:domain\/([^/]+)|([^/]+)\/domain)\/[^/]+\.go$/i;
|
|
226
|
-
const candidates = matches(files, layout);
|
|
227
|
-
for (const name of candidates) {
|
|
228
|
-
const match = layout.exec(name);
|
|
229
|
-
if (!match) continue;
|
|
230
|
-
const aggregate = match[1] ?? match[2];
|
|
231
|
-
const packageName = aggregate.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
232
|
-
const rootName = aggregate
|
|
233
|
-
.split(/[^a-zA-Z0-9]+|_/)
|
|
234
|
-
.filter(Boolean)
|
|
235
|
-
.map((part) => part[0]?.toUpperCase() + part.slice(1))
|
|
236
|
-
.join("");
|
|
237
|
-
let source = "";
|
|
238
|
-
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
239
|
-
const packagePattern = new RegExp(`\\bpackage\\s+(?:${packageName}|domain)\\b`);
|
|
240
|
-
const rootPattern = new RegExp(`\\btype\\s+${rootName}\\s+struct\\s*\\{`);
|
|
241
|
-
if (packagePattern.test(source) && rootPattern.test(source)) return name;
|
|
242
|
-
}
|
|
243
|
-
return "";
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function laidOutDomainEvidence(root, files, language) {
|
|
247
|
-
const extension = language === "typescript" ? "ts" : language === "rust" ? "rs" : "java";
|
|
248
|
-
const prefix = language === "java" ? /(?:^|\/)domain\/([^/]+)\/[^/]+\.java$/i : /^src\/domain\/([^/]+)\/[^/]+\.(?:ts|rs)$/i;
|
|
249
|
-
for (const name of matches(files, prefix)) {
|
|
250
|
-
if (!name.endsWith(`.${extension}`)) continue;
|
|
251
|
-
const match = prefix.exec(name);
|
|
252
|
-
if (!match) continue;
|
|
253
|
-
const rootName = match[1].split(/[^a-zA-Z0-9]+|_/).filter(Boolean).map((part) => part[0]?.toUpperCase() + part.slice(1)).join("");
|
|
254
|
-
let source = "";
|
|
255
|
-
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
256
|
-
const claim = language === "typescript"
|
|
257
|
-
? new RegExp(`\\b(?:export\\s+)?class\\s+${rootName}\\b`)
|
|
258
|
-
: language === "rust"
|
|
259
|
-
? new RegExp(`\\bpub\\s+struct\\s+${rootName}\\b`)
|
|
260
|
-
: /@AggregateRoot\b/.test(source) || new RegExp(`\\bclass\\s+${rootName}\\b`).test(source);
|
|
261
|
-
if (claim instanceof RegExp ? claim.test(source) : claim) return name;
|
|
262
|
-
}
|
|
263
|
-
return "";
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function goHTTPClientEvidence(root, files) {
|
|
267
|
-
for (const name of matches(files, /\.go$/).filter((name) => !name.endsWith("_test.go"))) {
|
|
268
|
-
let source = "";
|
|
269
|
-
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
270
|
-
if (
|
|
271
|
-
/\bhttp\.(?:NewRequest(?:WithContext)?|Get|Post|PostForm|Head)\s*\(/.test(source)
|
|
272
|
-
|| /ClientWithResponses(?:Interface)?\b/.test(source)
|
|
273
|
-
|| /github\.com\/hooklift\/gowsdl\/soap/.test(source)
|
|
274
|
-
) return name;
|
|
275
|
-
}
|
|
276
|
-
return "";
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function goSOAPClientEvidence(root, files) {
|
|
280
|
-
for (const name of matches(files, /\.go$/).filter((name) => !name.endsWith("_test.go"))) {
|
|
281
|
-
let source = "";
|
|
282
|
-
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
283
|
-
if (
|
|
284
|
-
/github\.com\/hooklift\/gowsdl\/soap/.test(source)
|
|
285
|
-
|| /\bCallContext\s*\([^,]+,\s*[^,]*(?:soap)?action/i.test(source)
|
|
286
|
-
|| /(?:Header\.)?(?:Add|Set)\s*\(\s*["'](?:SOAPAction|Content-Type)["']/i.test(source)
|
|
287
|
-
) return name;
|
|
288
|
-
}
|
|
289
|
-
return "";
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function goRedisEvidence(root, files) {
|
|
293
|
-
const redisImport = /github\.com\/(?:redis\/go-redis(?:\/v\d+)?|go-redis\/redis(?:\/v\d+)?|redis\/rueidis|gomodule\/redigo\/redis)(?=\")/g;
|
|
294
|
-
for (const name of matches(files, /\.go$/).filter((name) => !name.endsWith("_test.go"))) {
|
|
295
|
-
let source = "";
|
|
296
|
-
try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
|
|
297
|
-
const imports = [...source.matchAll(redisImport)].map((match) => match[0]);
|
|
298
|
-
if (!imports.length) continue;
|
|
299
|
-
|
|
300
|
-
const aliases = new Set();
|
|
301
|
-
for (const importPath of imports) {
|
|
302
|
-
const escaped = importPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
303
|
-
const declaration = new RegExp(`(?:^|\\n)\\s*(?:import\\s+)?(?:([A-Za-z_][A-Za-z0-9_]*)\\s+)?\"${escaped}\"`, "m").exec(source);
|
|
304
|
-
const alias = declaration?.[1];
|
|
305
|
-
if (alias && alias !== "_" && alias !== ".") aliases.add(alias);
|
|
306
|
-
else aliases.add(importPath.includes("rueidis") ? "rueidis" : "redis");
|
|
307
|
-
}
|
|
308
|
-
if ([...aliases].some((alias) => new RegExp(`\\b${alias}\\.(?:NewClient|NewClusterClient|NewFailoverClient|NewFailoverClusterClient|Dial|DialURL)\\s*\\(`).test(source))) {
|
|
309
|
-
return name;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
return "";
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function detectionsFor(root, files) {
|
|
316
|
-
let goMod = "";
|
|
317
|
-
if (files.has("go.mod")) {
|
|
318
|
-
try { goMod = readFileSync(join(root, "go.mod"), "utf8"); } catch {}
|
|
319
|
-
}
|
|
320
|
-
const openapi = matches(files, /(^|\/)(openapi|swagger)[^/]*\.(ya?ml|json)$/i);
|
|
321
|
-
const wsdls = matches(files, /\.wsdl$/i);
|
|
322
|
-
const asyncapi = matches(files, /(^|\/)asyncapi[^/]*\.(ya?ml|json)$/i);
|
|
323
|
-
const graphql = matches(files, /\.graphqls?$/i);
|
|
324
|
-
const protos = matches(files, /\.proto$/i);
|
|
325
|
-
const sql = matches(files, /(^|\/)(migrations?|repository)(\/|.*\/).*\.sql$/i);
|
|
326
|
-
const adrs = matches(files, /(^|\/)(docs\/adr|adr)\/.*\.md$/i);
|
|
327
|
-
const supportedAdrs = compatibleAdrs(root, adrs);
|
|
328
|
-
const glossaries = matches(files, /(^|\/)glossary\.md$/i);
|
|
329
|
-
// The app module is the one file a Celery project always has; the tasks
|
|
330
|
-
// and the calls that enqueue them are found from there.
|
|
331
|
-
const celery = matches(files, /(^|\/)celery\.py$/);
|
|
332
|
-
const sqlRoots = [...new Set(sql.map((name) => {
|
|
333
|
-
const segments = name.split("/");
|
|
334
|
-
const repository = segments.findIndex((part) => /^(repository|repositories)$/i.test(part));
|
|
335
|
-
return repository >= 0 ? segments.slice(0, repository + 1).join("/") : "";
|
|
336
|
-
}).filter(Boolean))];
|
|
337
|
-
const featureSql = sqlRoots.some((name) => /^internal\/[^/]+\/infrastructure\/repository$/i.test(name));
|
|
338
|
-
const sqlRoot = sqlRoots.length === 1 && !featureSql ? sqlRoots[0] : "";
|
|
339
|
-
const graphqlDirs = compactDirectories(graphql);
|
|
340
|
-
const protoDirs = compactDirectories(protos);
|
|
341
|
-
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));
|
|
342
|
-
const projectEvidence = projectMarkers.length ? projectMarkers : [[...files].sort()[0]].filter(Boolean);
|
|
343
|
-
const goDomain = files.has("go.mod") ? goDomainEvidence(root, files) : "";
|
|
344
|
-
const goHTTPClient = files.has("go.mod") ? goHTTPClientEvidence(root, files) : "";
|
|
345
|
-
const goSOAPClient = files.has("go.mod") ? goSOAPClientEvidence(root, files) : "";
|
|
346
|
-
const goRedis = files.has("go.mod") ? goRedisEvidence(root, files) : "";
|
|
347
|
-
const tsDomain = files.has("package.json") ? laidOutDomainEvidence(root, files, "typescript") : "";
|
|
348
|
-
const rustDomain = files.has("Cargo.toml") ? laidOutDomainEvidence(root, files, "rust") : "";
|
|
349
|
-
const javaDomain = ["pom.xml", "build.gradle", "build.gradle.kts"].some((name) => files.has(name)) ? laidOutDomainEvidence(root, files, "java") : "";
|
|
350
|
-
return [
|
|
351
|
-
detected("project", projectEvidence, {}, projectEvidence.join(", ")),
|
|
352
|
-
detected("go-domain", goDomain ? [goDomain] : [], {}, goDomain),
|
|
353
|
-
detected("ts-domain", tsDomain ? [tsDomain] : [], {}, tsDomain),
|
|
354
|
-
detected("rust-domain", rustDomain ? [rustDomain] : [], {}, rustDomain),
|
|
355
|
-
detected("java-domain", javaDomain ? [javaDomain] : [], {}, javaDomain),
|
|
356
|
-
detected("django-domain", files.has("manage.py") && matches(files, /(^|\/)models(?:\/[^/]+)?\.py$/i).length ? ["manage.py"] : []),
|
|
357
|
-
detected("celery", celery, {}, celery[0]),
|
|
358
|
-
detected("openapi", openapi, openapi[0] ? { spec: openapi[0] } : {}, openapi[0], true),
|
|
359
|
-
detected(
|
|
360
|
-
"wsdl",
|
|
361
|
-
wsdls,
|
|
362
|
-
{
|
|
363
|
-
...(wsdls.length === 1 ? { spec: wsdls[0] } : {}),
|
|
364
|
-
...(goSOAPClient ? { mode: "external" } : {}),
|
|
365
|
-
},
|
|
366
|
-
wsdls.length === 1 ? wsdls[0] : `${wsdls.length} WSDL documents`,
|
|
367
|
-
true,
|
|
368
|
-
),
|
|
369
|
-
detected("http-clients", goHTTPClient ? [goHTTPClient] : [], {}, goHTTPClient),
|
|
370
|
-
detected("redis", goRedis ? [goRedis] : [], {}, goRedis),
|
|
371
|
-
detected("river", goMod.includes("github.com/riverqueue/river") ? ["go.mod"] : [], {}, "go.mod · github.com/riverqueue/river"),
|
|
372
|
-
detected("watermill", goMod.includes("github.com/ThreeDotsLabs/watermill") ? ["go.mod"] : [], {}, "go.mod · github.com/ThreeDotsLabs/watermill"),
|
|
373
|
-
detected("asyncapi", asyncapi, asyncapi[0] ? { spec: asyncapi[0] } : {}, asyncapi[0], true),
|
|
374
|
-
detected("graphql", graphql, graphqlDirs[0] ? { schema: graphqlDirs.length === 1 ? graphqlDirs[0] : graphql[0] } : {}, graphqlDirs.length === 1 ? graphqlDirs[0] : graphql[0]),
|
|
375
|
-
detected("proto", protos, protoDirs.length ? { paths: protoDirs } : {}, protoDirs.join(", ")),
|
|
376
|
-
detected("sql", sql, sqlRoot ? { repositories: sqlRoot } : {}, sqlRoot || (sqlRoots.length > 1 ? `${sqlRoots.length} repository packages` : sql[0])),
|
|
377
|
-
detected(
|
|
378
|
-
"adr",
|
|
379
|
-
adrs,
|
|
380
|
-
supportedAdrs[0] ? { files: [`${posix.dirname(supportedAdrs[0])}/*.md`] } : {},
|
|
381
|
-
supportedAdrs[0] ? `${posix.dirname(supportedAdrs[0])}/*.md` : `${posix.dirname(adrs[0] ?? "docs/adr/x.md")}/*.md (format not recognized)`,
|
|
382
|
-
!supportedAdrs.length,
|
|
383
|
-
supportedAdrs.length > 0,
|
|
384
|
-
),
|
|
385
|
-
detected("glossary", glossaries, glossaries.length ? { files: glossaries } : {}, glossaries.join(", ")),
|
|
386
|
-
].filter(Boolean);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
export function discoverProject(workspace, input) {
|
|
390
|
-
const { root, absolute } = safeRoot(workspace, input);
|
|
391
|
-
const files = walk(absolute);
|
|
392
|
-
const detections = detectionsFor(absolute, files);
|
|
393
|
-
const components = componentCandidates(files);
|
|
394
|
-
const deployables = goDeployables(absolute, files);
|
|
395
|
-
return {
|
|
396
|
-
root,
|
|
397
|
-
filesScanned: files.size,
|
|
398
|
-
truncated: files.size >= MAX_FILES,
|
|
399
|
-
components,
|
|
400
|
-
componentsTruncated: components.length >= MAX_COMPONENTS,
|
|
401
|
-
deployables,
|
|
402
|
-
defaults: projectDefaults(basename(absolute)),
|
|
403
|
-
detections,
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function projectDefaults(value) {
|
|
408
|
-
const id = slug(value) || "service";
|
|
409
|
-
return { id, name: id.split("-").map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" "), group: id, component: id, context: id, service: id };
|
|
410
|
-
}
|
|
411
53
|
|
|
412
54
|
export function externalProjectDefaults(repository, sourcePath = "") {
|
|
413
55
|
const repo = repositoryParts(repository);
|
|
@@ -978,7 +620,7 @@ export function writeManifest(path, manifest) {
|
|
|
978
620
|
export function writeProject(workspace, request) {
|
|
979
621
|
const manifestPath = join(workspace, "portolan.json");
|
|
980
622
|
const before = readFileSync(manifestPath, "utf8");
|
|
981
|
-
const manifest =
|
|
623
|
+
const manifest = readManifestText(before, manifestPath);
|
|
982
624
|
const { base, plan, starter } = projectRequestPlan(workspace, manifest, request);
|
|
983
625
|
writeManifest(manifestPath, manifestWithProject(base, plan));
|
|
984
626
|
const undoToken = rememberManifestUndo(workspace, before, readFileSync(manifestPath, "utf8"));
|
|
@@ -1010,7 +652,7 @@ function backupGeneratedSlice(workspace, output) {
|
|
|
1010
652
|
export function removeProject(workspace, projectId) {
|
|
1011
653
|
const manifestPath = join(workspace, "portolan.json");
|
|
1012
654
|
const before = readFileSync(manifestPath, "utf8");
|
|
1013
|
-
const manifest =
|
|
655
|
+
const manifest = readManifestText(before, manifestPath);
|
|
1014
656
|
const result = manifestWithoutProject(manifest, projectId);
|
|
1015
657
|
const generated = backupGeneratedSlice(workspace, result.projectOut);
|
|
1016
658
|
try {
|
|
@@ -1038,7 +680,7 @@ export function undoProjectRemoval(workspace, undoToken) {
|
|
|
1038
680
|
const manifestPath = join(workspace, "portolan.json");
|
|
1039
681
|
const current = readFileSync(manifestPath, "utf8");
|
|
1040
682
|
if (createHash("sha256").update(current).digest("hex") !== undo.afterSha256) throw new Error("portolan.json changed after the removal; undo would overwrite newer work.");
|
|
1041
|
-
const manifest =
|
|
683
|
+
const manifest = readManifestText(undo.before, manifestPath);
|
|
1042
684
|
writeManifest(manifestPath, manifest);
|
|
1043
685
|
if (undo.generated) {
|
|
1044
686
|
rmSync(undo.generated.target, { recursive: true, force: true });
|
|
@@ -1051,10 +693,11 @@ export function undoProjectRemoval(workspace, undoToken) {
|
|
|
1051
693
|
}
|
|
1052
694
|
|
|
1053
695
|
function setup(workspace, publicSetupFrom) {
|
|
1054
|
-
const
|
|
696
|
+
const manifestPath = join(workspace, "portolan.json");
|
|
697
|
+
const manifestText = readFileSync(manifestPath, "utf8");
|
|
1055
698
|
let report;
|
|
1056
699
|
try { report = JSON.parse(readFileSync(join(workspace, ".portolan/build-report.json"), "utf8")); } catch {}
|
|
1057
|
-
return publicSetupFrom(
|
|
700
|
+
return publicSetupFrom(readManifestText(manifestText, manifestPath), report, createHash("sha256").update(manifestText).digest("hex"));
|
|
1058
701
|
}
|
|
1059
702
|
|
|
1060
703
|
function send(res, status, value) {
|
|
@@ -1278,7 +921,7 @@ export function summarizeProjectTrial(snapshot, plan, events) {
|
|
|
1278
921
|
}
|
|
1279
922
|
|
|
1280
923
|
function prepareProjectTrial(workspace, request) {
|
|
1281
|
-
const manifest =
|
|
924
|
+
const manifest = readManifest(join(workspace, "portolan.json"));
|
|
1282
925
|
const { plan } = projectRequestPlan(workspace, manifest, request);
|
|
1283
926
|
const fingerprint = workspaceFingerprint(workspace);
|
|
1284
927
|
const snapshot = snapshotWorkspace(workspace);
|
|
@@ -1435,7 +1078,13 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
|
|
|
1435
1078
|
return send(res, 200, { local: true, workspace: realpathSync(workspace), setup: setup(workspace, publicSetupFrom), activeRun: active ? { id: active.id, mode: active.mode } : null });
|
|
1436
1079
|
}
|
|
1437
1080
|
if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/delivery-presets`) {
|
|
1438
|
-
|
|
1081
|
+
const features = url.searchParams.has("features")
|
|
1082
|
+
? url.searchParams.get("features").split(",").filter(Boolean)
|
|
1083
|
+
: undefined;
|
|
1084
|
+
return send(res, 200, publicDeliveryPreset(planDeliveryPreset(workspace, {
|
|
1085
|
+
provider: url.searchParams.get("provider") || undefined,
|
|
1086
|
+
features,
|
|
1087
|
+
})));
|
|
1439
1088
|
}
|
|
1440
1089
|
const eventMatch = url.pathname.match(/^\/__portolan\/runs\/([^/]+)\/events$/);
|
|
1441
1090
|
if (req.method === "GET" && eventMatch) {
|
|
@@ -1486,7 +1135,7 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
|
|
|
1486
1135
|
return send(res, 200, { runId: trial.id, status: "disposed" });
|
|
1487
1136
|
}
|
|
1488
1137
|
if (url.pathname === `${LOCAL_API_PREFIX}/projects/preview`) {
|
|
1489
|
-
const manifest =
|
|
1138
|
+
const manifest = readManifest(join(workspace, "portolan.json"));
|
|
1490
1139
|
return send(res, 200, projectRequestPlan(workspace, manifest, input).plan);
|
|
1491
1140
|
}
|
|
1492
1141
|
if (url.pathname === `${LOCAL_API_PREFIX}/projects`) {
|
|
@@ -72,6 +72,44 @@ describe("local project setup", () => {
|
|
|
72
72
|
expect(installDeliveryPreset(root, { provider: "github", revision: installed.revision }).written).toEqual([]);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
it("generates only the selected GitHub jobs and keeps elevated permissions isolated", () => {
|
|
76
|
+
const root = gitWorkspace("github");
|
|
77
|
+
const preview = planDeliveryPreset(root, { provider: "github", features: ["check", "diff", "sarif"] });
|
|
78
|
+
expect(preview.features.filter((feature) => feature.selected).map((feature) => feature.id)).toEqual(["check", "diff", "sarif"]);
|
|
79
|
+
expect(preview.files.map((file) => file.path)).toEqual([
|
|
80
|
+
".github/workflows/portolan-check.yml",
|
|
81
|
+
".github/workflows/portolan-review.yml",
|
|
82
|
+
]);
|
|
83
|
+
const installed = installDeliveryPreset(root, {
|
|
84
|
+
provider: "github",
|
|
85
|
+
features: ["check", "diff", "sarif"],
|
|
86
|
+
revision: preview.revision,
|
|
87
|
+
});
|
|
88
|
+
expect(installed.status).toBe("installed");
|
|
89
|
+
const check = readFileSync(join(root, ".github/workflows/portolan-check.yml"), "utf8");
|
|
90
|
+
const review = readFileSync(join(root, ".github/workflows/portolan-review.yml"), "utf8");
|
|
91
|
+
expect(check).not.toContain("pull-requests: write");
|
|
92
|
+
expect(review).toContain("command: diff");
|
|
93
|
+
expect(review).toContain("pull-requests: write");
|
|
94
|
+
expect(review).toContain("security-events: write");
|
|
95
|
+
expect(review).toContain("github/codeql-action/upload-sarif@v4");
|
|
96
|
+
expect(existsSync(join(root, ".github/workflows/portolan-pages.yml"))).toBe(false);
|
|
97
|
+
expect(planDeliveryPreset(root).features.filter((feature) => feature.selected).map((feature) => feature.id)).toEqual(["check", "diff", "sarif"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("removes a disabled GitHub job only when Portolan owns the file", () => {
|
|
101
|
+
const root = gitWorkspace("github");
|
|
102
|
+
const initial = planDeliveryPreset(root, { provider: "github", features: ["check", "diff"] });
|
|
103
|
+
installDeliveryPreset(root, { provider: "github", features: ["check", "diff"], revision: initial.revision });
|
|
104
|
+
const withoutDiff = planDeliveryPreset(root, { provider: "github", features: ["check"] });
|
|
105
|
+
expect(withoutDiff.files.find((file) => file.path.endsWith("portolan-review.yml"))?.status).toBe("removed");
|
|
106
|
+
installDeliveryPreset(root, { provider: "github", features: ["check"], revision: withoutDiff.revision });
|
|
107
|
+
expect(existsSync(join(root, ".github/workflows/portolan-review.yml"))).toBe(false);
|
|
108
|
+
|
|
109
|
+
writeFileSync(join(root, ".github/workflows/portolan-review.yml"), "name: Mine\n");
|
|
110
|
+
expect(planDeliveryPreset(root, { provider: "github", features: ["check"] }).files.some((file) => file.path.endsWith("portolan-review.yml"))).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
75
113
|
it("refuses to overwrite an unmanaged GitHub workflow or apply a stale preview", () => {
|
|
76
114
|
const root = gitWorkspace("github");
|
|
77
115
|
mkdirSync(join(root, ".github/workflows"), { recursive: true });
|
|
@@ -112,6 +150,18 @@ describe("local project setup", () => {
|
|
|
112
150
|
expect(existsSync(join(root, ".github"))).toBe(false);
|
|
113
151
|
});
|
|
114
152
|
|
|
153
|
+
it("builds the GitLab managed region from selected jobs", () => {
|
|
154
|
+
const root = gitWorkspace("gitlab");
|
|
155
|
+
const preview = planDeliveryPreset(root, { provider: "gitlab", features: ["diff"] });
|
|
156
|
+
expect(preview.files[0].diff).toContain('+"portolan:review":');
|
|
157
|
+
expect(preview.files[0].diff).not.toContain('+"portolan:check":');
|
|
158
|
+
expect(preview.files[0].diff).not.toContain('+"portolan:pages":');
|
|
159
|
+
const installed = installDeliveryPreset(root, { provider: "gitlab", features: ["diff"], revision: preview.revision });
|
|
160
|
+
expect(planDeliveryPreset(root).features.filter((feature) => feature.selected).map((feature) => feature.id)).toEqual(["diff"]);
|
|
161
|
+
expect(installed.status).toBe("installed");
|
|
162
|
+
expect(() => planDeliveryPreset(root, { provider: "gitlab", features: ["sarif"] })).toThrow(/only for GitHub/);
|
|
163
|
+
});
|
|
164
|
+
|
|
115
165
|
it("classifies repository authentication, authorization, and timeout failures", () => {
|
|
116
166
|
expect(classifyRepositoryFailure(new Error("fatal: Authentication failed"), "GitHub")).toMatchObject({
|
|
117
167
|
code: "repository_auth_required", status: 401, retryable: true, provider: "GitHub",
|