@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
package/README.md CHANGED
@@ -201,8 +201,13 @@ for against what is on `PATH`. The Docker image contains all of them.
201
201
 
202
202
  While `portolan dev` is running, open **Settings → Delivery presets**. Portolan
203
203
  detects GitHub or GitLab from the repository's `origin`, previews the exact CI
204
- changes, and installs architecture checks and static catalog publishing in one
205
- step. Existing unmanaged workflow files are never overwritten.
204
+ changes, and generates only the jobs selected there. Architecture checks and
205
+ static catalog publishing are selected by default; pull-request architecture
206
+ diffs and GitHub SARIF annotations are opt-in. Check, review, and publication
207
+ live in separate GitHub workflows, while SARIF augments the review workflow, so
208
+ comment and code-scanning permissions are granted only when those capabilities
209
+ are enabled. Existing unmanaged workflow files are never overwritten, and
210
+ disabling a capability removes only files managed by Portolan.
206
211
 
207
212
  ### Run without installing Node or language toolchains
208
213
 
package/cli/portolan.mjs CHANGED
@@ -19,6 +19,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
19
19
  import { fileURLToPath } from "node:url";
20
20
 
21
21
  import { InitError, commandWorks, init as runInit, isInteractive, promptAnswers, toolchainFor } from "./init.mjs";
22
+ import { loadManifest, readManifest } from "../scripts/manifest.mjs";
22
23
 
23
24
  const installRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
24
25
  const packageJson = JSON.parse(readFileSync(resolve(installRoot, "package.json"), "utf8"));
@@ -54,7 +55,10 @@ export async function main(argv = process.argv.slice(2)) {
54
55
  return dev(workspace, parsed);
55
56
  case "diff":
56
57
  if (!parsed.positionals[0]) fail("diff requires a base branch, tag, or commit");
57
- return runScript("scripts/diff.mjs", parsed.positionals, workspace);
58
+ return runScript("scripts/diff.mjs", diffArgs(parsed), workspace);
59
+ case "comment":
60
+ if (!parsed.positionals[0]) fail("comment requires a markdown file");
61
+ return runScript("scripts/forge-comment.mjs", [parsed.positionals[0]], workspace);
58
62
  case "doctor":
59
63
  return doctor(workspace);
60
64
  default:
@@ -68,7 +72,7 @@ function parse(argv) {
68
72
  const arg = argv[index];
69
73
  if (arg === "--help" || arg === "-h") value.help = true;
70
74
  else if (arg === "--version" || arg === "-v") value.version = true;
71
- else if (["--cwd", "--output", "--base", "--host", "--port"].includes(arg)) {
75
+ else if (["--cwd", "--output", "--base", "--host", "--port", "--format", "--site", "--head"].includes(arg)) {
72
76
  const next = argv[++index];
73
77
  if (!next) fail(`${arg} requires a value`);
74
78
  value[arg.slice(2)] = next;
@@ -80,6 +84,14 @@ function parse(argv) {
80
84
  return value;
81
85
  }
82
86
 
87
+ function diffArgs(parsed) {
88
+ const args = [parsed.positionals[0]];
89
+ for (const name of ["format", "output", "site", "head"]) {
90
+ if (parsed[name]) args.push(`--${name}`, parsed[name]);
91
+ }
92
+ return args;
93
+ }
94
+
83
95
  function help() {
84
96
  console.log(`Portolan ${VERSION}
85
97
 
@@ -92,6 +104,7 @@ Commands:
92
104
  check fail when committed generated files are out of date
93
105
  build build the static site
94
106
  diff BASE describe architecture changes against BASE
107
+ comment FILE post or update a pull-request comment from Markdown
95
108
  doctor check the local runtime and project configuration
96
109
  version print the CLI version
97
110
 
@@ -99,6 +112,9 @@ Options:
99
112
  --cwd DIR project directory (default: current directory)
100
113
  --output DIR build output (default: dist)
101
114
  --base PATH deployed URL base (default: /)
115
+ --format TYPE diff format: markdown, json, or sarif
116
+ --site URL catalog URL linked from an architecture diff
117
+ --head BRANCH head branch linked from an architecture diff
102
118
  --host HOST dev server host (default: 127.0.0.1)
103
119
  --port PORT dev server port
104
120
  --yes, -y init without questions: take every detected default`);
@@ -118,10 +134,24 @@ async function init(workspace, options) {
118
134
 
119
135
  function doctor(workspace) {
120
136
  const manifestPath = resolve(workspace, "portolan.json");
121
- const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : null;
137
+ let manifest = null;
138
+ let manifestProblems = [];
139
+ if (existsSync(manifestPath)) {
140
+ try {
141
+ const loaded = loadManifest(manifestPath);
142
+ manifest = loaded.manifest;
143
+ manifestProblems = loaded.problems;
144
+ } catch (cause) {
145
+ manifestProblems = [cause instanceof Error ? cause.message : String(cause)];
146
+ }
147
+ }
122
148
  const checks = [
123
149
  ["Node.js >= 24", Number(process.versions.node.split(".")[0]) >= 24, process.version],
124
- ["portolan.json", Boolean(manifest), "required"],
150
+ [
151
+ "portolan.json",
152
+ Boolean(manifest) && manifestProblems.length === 0,
153
+ manifestProblems.length > 0 ? manifestProblems.join("; ") : "required",
154
+ ],
125
155
  ["Git", commandWorks("git", ["--version"]), "used for deterministic source stamps"],
126
156
  ];
127
157
 
@@ -157,7 +187,7 @@ async function build(workspace, options) {
157
187
  runNode(packageBin("vite", "bin/vite.js"), ["build", stage, "--config", resolve(stage, "vite.config.ts"), "--outDir", output, "--emptyOutDir"], workspace, env);
158
188
 
159
189
  const { siteDocs } = await import(resolve(installRoot, "scripts/site-docs.mjs"));
160
- const manifest = JSON.parse(readFileSync(resolve(workspace, "portolan.json"), "utf8"));
190
+ const manifest = readManifest(resolve(workspace, "portolan.json"));
161
191
  const written = siteDocs({ manifest, dist: output });
162
192
  copyFileSync(resolve(output, "index.html"), resolve(output, "404.html"));
163
193
  console.log(`site: ${relative(workspace, output) || "."}${written.length ? `; mounted ${written.join(", ")}` : ""}`);
@@ -178,7 +208,7 @@ async function dev(workspace, options) {
178
208
  export async function prepareSite(workspace) {
179
209
  const manifestPath = resolve(workspace, "portolan.json");
180
210
  if (!existsSync(manifestPath)) fail("portolan.json is missing; run portolan init first");
181
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
211
+ const manifest = readManifest(manifestPath);
182
212
  const stage = resolve(workspace, ".portolan", "site");
183
213
  rmSync(stage, { recursive: true, force: true });
184
214
  mkdirSync(stage, { recursive: true });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@shortlink-org/portolan",
3
3
  "description": "Generate a navigable architecture catalog from code and specifications.",
4
4
  "private": false,
5
- "version": "0.2.2",
5
+ "version": "0.2.3",
6
6
  "type": "module",
7
7
  "license": "MIT",
8
8
  "homepage": "https://github.com/shortlink-org/portolan#readme",
@@ -68,10 +68,10 @@
68
68
  },
69
69
  "dependencies": {
70
70
  "@ai-sdk/openai-compatible": "3.0.45",
71
- "@ai-sdk/react": "4.0.97",
71
+ "@ai-sdk/react": "4.0.98",
72
72
  "@asyncapi/react-component": "^3.1.8",
73
73
  "@clack/prompts": "^1.8.0",
74
- "@floating-ui/react": "^0.27.0",
74
+ "@floating-ui/react": "^0.27.20",
75
75
  "@headlessui/react": "^2.2.10",
76
76
  "@scalar/api-reference-react": "^0.9.66",
77
77
  "@tailwindcss/vite": "^4.0.0",
@@ -80,7 +80,7 @@
80
80
  "@tanstack/react-virtual": "^3.14.10",
81
81
  "@vitejs/plugin-react": "^6.1.1",
82
82
  "@xyflow/react": "^12.11.5",
83
- "ai": "7.0.94",
83
+ "ai": "7.0.95",
84
84
  "ajv": "^8.20.0",
85
85
  "elkjs": "^0.12.0",
86
86
  "html-to-image": "^1.11.13",
Binary file
@@ -4,10 +4,11 @@
4
4
  // package happens to run them. Custom plugins may still be declared in the
5
5
  // manifest with `process` or `wasm`; those declarations always win.
6
6
 
7
- import { readFileSync } from "node:fs";
8
7
  import { dirname, resolve } from "node:path";
9
8
  import { fileURLToPath } from "node:url";
10
9
 
10
+ import { readManifest } from "./manifest.mjs";
11
+
11
12
  export const INSTALL_ROOT = process.env.PORTOLAN_INSTALL_ROOT
12
13
  ? resolve(process.env.PORTOLAN_INSTALL_ROOT)
13
14
  : resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -16,7 +17,7 @@ let definitions;
16
17
 
17
18
  function shippedDefinitions() {
18
19
  if (definitions) return definitions;
19
- const manifest = JSON.parse(readFileSync(resolve(INSTALL_ROOT, "portolan.json"), "utf8"));
20
+ const manifest = readManifest(resolve(INSTALL_ROOT, "portolan.json"));
20
21
  definitions = new Map((manifest.plugins ?? []).map((plugin) => [plugin.name, plugin]));
21
22
  return definitions;
22
23
  }
@@ -14,6 +14,7 @@ import { validateCatalog } from "../src/catalog.ts";
14
14
  import { filterCatalogForProfile } from "../src/catalog-profile.ts";
15
15
  import { enrichCatalog } from "../src/enrich.ts";
16
16
  import { mergeCatalogs } from "../src/merge.ts";
17
+ import { readManifest } from "./manifest.mjs";
17
18
 
18
19
  /**
19
20
  * `exclude` names source files to leave out, as the manifest would spell
@@ -22,7 +23,7 @@ import { mergeCatalogs } from "../src/merge.ts";
22
23
  * what it wrote last time would count as what it saw this time.
23
24
  */
24
25
  export async function loadCatalog(manifestPath = "portolan.json", { exclude = [], profile } = {}) {
25
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
26
+ const manifest = readManifest(manifestPath);
26
27
  const excluded = new Set(exclude.map((path) => normalize(path)));
27
28
  const selected = profile
28
29
  ? (manifest.catalogs ?? []).find((candidate) => candidate.id === profile)
@@ -29,7 +29,17 @@ describe("catalog sources", () => {
29
29
 
30
30
  it("still reports missing sources for a configured project", async () => {
31
31
  const { path, missing } = manifest({ sources: [], projects: [] });
32
- writeFileSync(path, `${JSON.stringify({ sources: [missing], projects: [{ id: "billing" }], extract: [] }, null, 2)}\n`);
32
+ writeFileSync(path, `${JSON.stringify({
33
+ sources: [missing],
34
+ projects: [{
35
+ id: "billing",
36
+ name: "Billing",
37
+ root: "services/billing",
38
+ context: "shop",
39
+ service: "billing",
40
+ }],
41
+ extract: [],
42
+ }, null, 2)}\n`);
33
43
  await expect(loadCatalog(path)).rejects.toThrow(/no catalog matched/);
34
44
  });
35
45
  });
@@ -22,6 +22,26 @@ const GENERATED = "# Generated by Portolan. Preview changes in Settings before u
22
22
  const GITLAB_START = "# >>> Portolan delivery preset >>>";
23
23
  const GITLAB_END = "# <<< Portolan delivery preset <<<";
24
24
  const PROVIDERS = new Set(["github", "gitlab"]);
25
+ const FEATURE_IDS = ["check", "diff", "sarif", "pages"];
26
+ const DEFAULT_FEATURES = ["check", "pages"];
27
+ const FEATURE_DETAILS = {
28
+ check: {
29
+ label: "Architecture check",
30
+ description: "Fail when committed architecture files are stale.",
31
+ },
32
+ diff: {
33
+ label: "Pull-request architecture diff",
34
+ description: "Post a sticky architecture summary where review happens.",
35
+ },
36
+ sarif: {
37
+ label: "SARIF annotations",
38
+ description: "Annotate changed architecture in GitHub's checks and code-scanning views.",
39
+ },
40
+ pages: {
41
+ label: "Static catalog publishing",
42
+ description: "Build and publish the catalog after changes reach the default branch.",
43
+ },
44
+ };
25
45
 
26
46
  function exists(path) {
27
47
  try { lstatSync(path); return true; } catch { return false; }
@@ -153,9 +173,58 @@ jobs:
153
173
  `;
154
174
  }
155
175
 
156
- function gitlabBlock() {
157
- return `${GITLAB_START}
158
- "portolan:check":
176
+ function githubDiff({ pages, sarif }) {
177
+ const site = pages
178
+ ? "\n site: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}"
179
+ : "";
180
+ const sarifInput = sarif ? "\n sarif-output: .portolan/diff.sarif" : "";
181
+ const sarifPermission = sarif ? "\n security-events: write # annotations in the checks tab" : "";
182
+ const upload = sarif
183
+ ? `
184
+ - name: Annotate the checks tab
185
+ uses: github/codeql-action/upload-sarif@v4
186
+ with:
187
+ sarif_file: .portolan/diff.sarif
188
+ category: architecture-diff
189
+ # Fork pull requests have a read-only token; the Markdown summary still works.
190
+ continue-on-error: true
191
+ `
192
+ : "";
193
+ return `${GENERATED}
194
+ name: Portolan architecture diff
195
+
196
+ on:
197
+ pull_request:
198
+
199
+ permissions:
200
+ contents: read
201
+ pull-requests: write${sarifPermission}
202
+
203
+ concurrency:
204
+ group: portolan-diff-\${{ github.ref }}
205
+ cancel-in-progress: true
206
+
207
+ jobs:
208
+ describe:
209
+ runs-on: ubuntu-latest
210
+ steps:
211
+ - uses: actions/checkout@v7
212
+ with:
213
+ fetch-depth: 0
214
+ - uses: shortlink-org/portolan@${VERSION}
215
+ env:
216
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
217
+ with:
218
+ command: diff
219
+ version: ${VERSION}
220
+ base-ref: origin/\${{ github.base_ref }}
221
+ head-ref: \${{ github.head_ref }}
222
+ comment: true${site}${sarifInput}
223
+ ${upload}`;
224
+ }
225
+
226
+ function gitlabCheck() {
227
+ return `"portolan:check":
159
228
  stage: .pre
160
229
  image:
161
230
  name: ghcr.io/shortlink-org/portolan:${VERSION}
@@ -167,8 +236,30 @@ function gitlabBlock() {
167
236
  rules:
168
237
  - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
169
238
  - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
239
+ `;
240
+ }
170
241
 
171
- "portolan:pages":
242
+ function gitlabDiff({ pages }) {
243
+ const site = pages ? ' --site "$CI_PAGES_URL"' : "";
244
+ return `"portolan:review":
245
+ stage: .pre
246
+ image:
247
+ name: ghcr.io/shortlink-org/portolan:${VERSION}
248
+ entrypoint: [""]
249
+ variables:
250
+ GIT_DEPTH: "0"
251
+ script:
252
+ - portolan diff "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --head "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"${site} --output .portolan/diff.md
253
+ - cat .portolan/diff.md
254
+ - portolan comment .portolan/diff.md
255
+ allow_failure: true
256
+ rules:
257
+ - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
258
+ `;
259
+ }
260
+
261
+ function gitlabPages() {
262
+ return `"portolan:pages":
172
263
  stage: .post
173
264
  image:
174
265
  name: ghcr.io/shortlink-org/portolan:${VERSION}
@@ -182,11 +273,19 @@ function gitlabBlock() {
182
273
  publish: dist
183
274
  rules:
184
275
  - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
185
- ${GITLAB_END}
186
276
  `;
187
277
  }
188
278
 
189
- function mergeGitlab(existing) {
279
+ function gitlabBlock(features) {
280
+ const jobs = [];
281
+ if (features.has("check")) jobs.push(gitlabCheck());
282
+ if (features.has("diff")) jobs.push(gitlabDiff({ pages: features.has("pages") }));
283
+ if (features.has("pages")) jobs.push(gitlabPages());
284
+ if (jobs.length === 0) return "";
285
+ return `${GITLAB_START}\n${jobs.join("\n")}${GITLAB_END}\n`;
286
+ }
287
+
288
+ function mergeGitlab(existing, features) {
190
289
  const start = existing.indexOf(GITLAB_START);
191
290
  const end = existing.indexOf(GITLAB_END);
192
291
  if ((start === -1) !== (end === -1) || (start !== -1 && end < start)) {
@@ -194,12 +293,17 @@ function mergeGitlab(existing) {
194
293
  }
195
294
  if (start !== -1) {
196
295
  const after = end + GITLAB_END.length;
197
- return { content: `${existing.slice(0, start)}${gitlabBlock().trimEnd()}${existing.slice(after)}`.replace(/\n*$/, "\n") };
296
+ const replacement = gitlabBlock(features).trimEnd();
297
+ const content = `${existing.slice(0, start)}${replacement}${existing.slice(after)}`
298
+ .replace(/\n{3,}/g, "\n\n")
299
+ .replace(/^\n+|\n+$/g, "");
300
+ return { content: content ? `${content}\n` : "" };
198
301
  }
199
- if (/^[ \t]*["']?portolan:(?:check|pages)["']?\s*:/m.test(existing)) {
302
+ if (/^[ \t]*["']?portolan:(?:check|review|pages)["']?\s*:/m.test(existing)) {
200
303
  return { conflict: "This pipeline already declares a Portolan job outside the managed region." };
201
304
  }
202
- return { content: `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${gitlabBlock()}` };
305
+ const block = gitlabBlock(features);
306
+ return { content: block ? `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${block}` : existing };
203
307
  }
204
308
 
205
309
  function textDiff(path, before, after) {
@@ -251,6 +355,16 @@ function readTarget(workspace, path) {
251
355
  function githubFile(workspace, path, content) {
252
356
  const current = readTarget(workspace, path);
253
357
  const managed = current.safe && current.content.startsWith(GENERATED);
358
+ if (content === null) {
359
+ if (!current.exists || !managed) return null;
360
+ return {
361
+ path,
362
+ status: "removed",
363
+ diff: textDiff(path, current.content, ""),
364
+ current: current.content,
365
+ next: "",
366
+ };
367
+ }
254
368
  const status = !current.exists ? "added" : !current.safe || !managed ? "conflict" : current.content === content ? "unchanged" : "changed";
255
369
  return {
256
370
  path,
@@ -262,39 +376,102 @@ function githubFile(workspace, path, content) {
262
376
  };
263
377
  }
264
378
 
265
- function gitlabFile(workspace) {
379
+ function gitlabFile(workspace, features) {
266
380
  const path = ".gitlab-ci.yml";
267
381
  const current = readTarget(workspace, path);
268
382
  if (!current.safe) return { path, status: "conflict", message: "The existing pipeline is not a safe UTF-8 file.", diff: "", current: "", next: "" };
269
- const merged = mergeGitlab(current.content);
383
+ const merged = mergeGitlab(current.content, features);
270
384
  if (merged.conflict) return { path, status: "conflict", message: merged.conflict, diff: "", current: current.content, next: "" };
271
- const status = !current.exists ? "added" : current.content === merged.content ? "unchanged" : "changed";
385
+ const status = current.content === merged.content ? "unchanged" : !current.exists ? "added" : "changed";
272
386
  return { path, status, diff: textDiff(path, current.content, merged.content), current: current.content, next: merged.content };
273
387
  }
274
388
 
275
- function revisionFor(provider, files) {
389
+ function revisionFor(provider, features, files) {
276
390
  const hash = createHash("sha256").update(provider);
391
+ hash.update("\0"); hash.update([...features].sort().join(","));
277
392
  for (const file of files) {
278
393
  hash.update("\0"); hash.update(file.path); hash.update("\0"); hash.update(file.current); hash.update("\0"); hash.update(file.next);
279
394
  }
280
395
  return hash.digest("hex");
281
396
  }
282
397
 
283
- export function planDeliveryPreset(workspace, requestedProvider) {
398
+ function deliveryRequest(request) {
399
+ return typeof request === "string" || request == null ? { provider: request ?? undefined } : request;
400
+ }
401
+
402
+ function installedFeatures(workspace, provider) {
403
+ const selected = new Set();
404
+ if (provider === "github") {
405
+ const check = readTarget(workspace, ".github/workflows/portolan-check.yml");
406
+ const review = readTarget(workspace, ".github/workflows/portolan-review.yml");
407
+ const pages = readTarget(workspace, ".github/workflows/portolan-pages.yml");
408
+ if (check.safe && check.content.startsWith(GENERATED)) selected.add("check");
409
+ if (review.safe && review.content.startsWith(GENERATED)) {
410
+ selected.add("diff");
411
+ if (review.content.includes("sarif-output:")) selected.add("sarif");
412
+ }
413
+ if (pages.safe && pages.content.startsWith(GENERATED)) selected.add("pages");
414
+ return selected;
415
+ }
416
+ const pipeline = readTarget(workspace, ".gitlab-ci.yml");
417
+ if (!pipeline.safe || !pipeline.content.includes(GITLAB_START)) return selected;
418
+ const managed = pipeline.content.slice(
419
+ pipeline.content.indexOf(GITLAB_START),
420
+ pipeline.content.indexOf(GITLAB_END) + GITLAB_END.length,
421
+ );
422
+ if (/^[ \t]*["']?portolan:check["']?\s*:/m.test(managed)) selected.add("check");
423
+ if (/^[ \t]*["']?portolan:review["']?\s*:/m.test(managed)) selected.add("diff");
424
+ if (/^[ \t]*["']?portolan:pages["']?\s*:/m.test(managed)) selected.add("pages");
425
+ return selected;
426
+ }
427
+
428
+ function selectedFeatures(workspace, request, provider) {
429
+ const installed = request.features == null ? installedFeatures(workspace, provider) : null;
430
+ const raw = request.features == null
431
+ ? installed.size > 0 ? [...installed] : DEFAULT_FEATURES
432
+ : Array.isArray(request.features)
433
+ ? request.features
434
+ : String(request.features).split(",").filter(Boolean);
435
+ const selected = new Set(raw);
436
+ for (const feature of selected) {
437
+ if (!FEATURE_IDS.includes(feature)) throw new Error(`Unknown delivery feature ${feature}.`);
438
+ }
439
+ if (selected.has("sarif")) selected.add("diff");
440
+ if (provider !== "github" && selected.has("sarif")) {
441
+ throw new Error("SARIF annotations are currently available only for GitHub.");
442
+ }
443
+ return selected;
444
+ }
445
+
446
+ function featureOptions(provider, selected) {
447
+ return FEATURE_IDS.map((id) => ({
448
+ id,
449
+ ...FEATURE_DETAILS[id],
450
+ selected: selected.has(id),
451
+ available: id !== "sarif" || provider === "github",
452
+ requires: id === "sarif" ? ["diff"] : [],
453
+ }));
454
+ }
455
+
456
+ export function planDeliveryPreset(workspace, requested = {}) {
457
+ const request = deliveryRequest(requested);
284
458
  const detected = detectDeliveryForge(workspace);
285
- const provider = requestedProvider || detected.provider || "github";
459
+ const provider = request.provider || detected.provider || "github";
286
460
  if (!PROVIDERS.has(provider)) throw new Error("Delivery preset must be github or gitlab.");
461
+ const features = selectedFeatures(workspace, request, provider);
287
462
  const files = provider === "github"
288
463
  ? [
289
- githubFile(workspace, ".github/workflows/portolan-check.yml", githubCheck(detected.defaultBranch)),
290
- githubFile(workspace, ".github/workflows/portolan-pages.yml", githubPages(detected.defaultBranch)),
291
- ]
292
- : [gitlabFile(workspace)];
464
+ githubFile(workspace, ".github/workflows/portolan-check.yml", features.has("check") ? githubCheck(detected.defaultBranch) : null),
465
+ githubFile(workspace, ".github/workflows/portolan-review.yml", features.has("diff") ? githubDiff({ pages: features.has("pages"), sarif: features.has("sarif") }) : null),
466
+ githubFile(workspace, ".github/workflows/portolan-pages.yml", features.has("pages") ? githubPages(detected.defaultBranch) : null),
467
+ ].filter(Boolean)
468
+ : [gitlabFile(workspace, features)];
293
469
  const status = files.some((file) => file.status === "conflict")
294
470
  ? "conflict"
295
471
  : files.every((file) => file.status === "unchanged")
296
472
  ? "installed"
297
- : files.some((file) => file.status === "changed") && (provider !== "gitlab" || files[0].current.includes(GITLAB_START))
473
+ : files.some((file) => ["removed", "unchanged"].includes(file.status))
474
+ || files.some((file) => file.status === "changed" && (provider !== "gitlab" || file.current.includes(GITLAB_START)))
298
475
  ? "update"
299
476
  : "available";
300
477
  return {
@@ -304,8 +481,8 @@ export function planDeliveryPreset(workspace, requestedProvider) {
304
481
  repository: detected.repository,
305
482
  defaultBranch: detected.defaultBranch,
306
483
  status,
307
- revision: revisionFor(provider, files),
308
- features: ["Validate generated architecture in reviews", "Publish the static catalog"],
484
+ revision: revisionFor(provider, features, files),
485
+ features: featureOptions(provider, features),
309
486
  files,
310
487
  };
311
488
  }
@@ -315,7 +492,8 @@ export function publicDeliveryPreset(plan) {
315
492
  }
316
493
 
317
494
  export function installDeliveryPreset(workspace, request) {
318
- const plan = planDeliveryPreset(workspace, request.provider);
495
+ const selection = { provider: request.provider, features: request.features };
496
+ const plan = planDeliveryPreset(workspace, selection);
319
497
  if (!request.revision || request.revision !== plan.revision) {
320
498
  const error = new Error("Files changed after this preset preview. Preview it again.");
321
499
  error.status = 409;
@@ -339,6 +517,11 @@ export function installDeliveryPreset(workspace, request) {
339
517
  for (const file of plan.files) {
340
518
  if (file.status === "unchanged") continue;
341
519
  const target = resolve(workspace, file.path);
520
+ if (file.status === "removed") {
521
+ rmSync(target);
522
+ written.push(file.path);
523
+ continue;
524
+ }
342
525
  mkdirSync(dirname(target), { recursive: true });
343
526
  const temporary = join(dirname(target), `.portolan-${randomUUID()}.tmp`);
344
527
  try {
@@ -349,5 +532,5 @@ export function installDeliveryPreset(workspace, request) {
349
532
  }
350
533
  written.push(file.path);
351
534
  }
352
- return { ...publicDeliveryPreset(planDeliveryPreset(workspace, request.provider)), written };
535
+ return { ...publicDeliveryPreset(planDeliveryPreset(workspace, selection)), written };
353
536
  }
package/scripts/diff.mjs CHANGED
@@ -29,6 +29,7 @@ import { enrichCatalog } from "../src/enrich.ts";
29
29
  import { mergeCatalogs } from "../src/merge.ts";
30
30
  import { diffCatalogs, SEVERITIES } from "../src/lib/catalog-diff.ts";
31
31
  import { loadCatalog } from "./catalog-sources.mjs";
32
+ import { readManifestText } from "./manifest.mjs";
32
33
 
33
34
  const HEADINGS = {
34
35
  breaking: "Breaking",
@@ -142,7 +143,10 @@ function defaultBase() {
142
143
 
143
144
  /** The merged, enriched, validated catalog as it stood at `ref`. */
144
145
  function catalogAt(ref) {
145
- const manifest = JSON.parse(git(["show", ref + ":portolan.json"]));
146
+ const manifest = readManifestText(
147
+ git(["show", ref + ":portolan.json"]),
148
+ `${ref}:portolan.json`,
149
+ );
146
150
  const patterns = (manifest.sources ?? []).map(globToRegExp);
147
151
 
148
152
  const paths = git(["ls-tree", "-r", "--name-only", ref])