@shortlink-org/portolan 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +7 -2
  2. package/cli/portolan.mjs +36 -6
  3. package/package.json +4 -4
  4. package/plugins/portolan-go.wasm +0 -0
  5. package/scripts/builtin-plugins.mjs +3 -2
  6. package/scripts/catalog-sources.mjs +2 -1
  7. package/scripts/catalog-sources.test.mjs +11 -1
  8. package/scripts/delivery-presets.mjs +207 -24
  9. package/scripts/diff.mjs +5 -1
  10. package/scripts/local-api.mjs +26 -377
  11. package/scripts/local-api.test.mjs +50 -0
  12. package/scripts/local-discovery.mjs +378 -0
  13. package/scripts/manifest.mjs +42 -1
  14. package/scripts/manifest.test.mjs +24 -1
  15. package/scripts/run-builtin.mjs +4 -2
  16. package/scripts/schema.mjs +4 -0
  17. package/scripts/site-docs.mjs +2 -2
  18. package/src/app/CatalogApp.tsx +4 -4
  19. package/src/app/Sidebar.tsx +13 -730
  20. package/src/app/SidebarFlowSections.tsx +251 -0
  21. package/src/app/SidebarFooter.tsx +160 -0
  22. package/src/app/SidebarTree.tsx +322 -0
  23. package/src/catalog-index.ts +485 -0
  24. package/src/catalog-model.ts +1339 -0
  25. package/src/catalog-validation.ts +1570 -0
  26. package/src/catalog.test.ts +16 -0
  27. package/src/catalog.ts +6 -3306
  28. package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
  29. package/src/components/ProblemRow.tsx +3 -0
  30. package/src/components/SourcePreview.tsx +1 -1
  31. package/src/index.css +0 -17
  32. package/src/landing/LandingPage.tsx +4 -4
  33. package/src/lib/all-problems.ts +1 -1
  34. package/src/lib/derive.ts +1 -0
  35. package/src/lib/local-api.ts +19 -7
  36. package/src/lib/motion.test.ts +4 -2
  37. package/src/lib/motion.tsx +5 -4
  38. package/src/lib/proto-problems.test.ts +170 -3
  39. package/src/lib/proto-problems.ts +176 -4
  40. package/src/merge.test.ts +46 -0
  41. package/src/merge.ts +35 -1
  42. package/src/pages/Settings.tsx +1 -126
  43. package/src/pages/settings/AboutSettings.tsx +129 -0
  44. package/src/pages/settings/DeliverySettings.tsx +71 -15
  45. package/src/selection/pages.test.ts +14 -1
  46. package/src/selection/pages.ts +9 -3
  47. package/vite.config.ts +3 -1
@@ -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 = JSON.parse(before);
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 = JSON.parse(before);
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 = JSON.parse(undo.before);
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 manifestText = readFileSync(join(workspace, "portolan.json"), "utf8");
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(JSON.parse(manifestText), report, createHash("sha256").update(manifestText).digest("hex"));
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 = JSON.parse(readFileSync(join(workspace, "portolan.json"), "utf8"));
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
- return send(res, 200, publicDeliveryPreset(planDeliveryPreset(workspace, url.searchParams.get("provider"))));
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 = JSON.parse(readFileSync(join(workspace, "portolan.json"), "utf8"));
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",