@shortlink-org/portolan 0.2.1 → 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 (135) hide show
  1. package/README.md +7 -2
  2. package/cli/init.mjs +14 -6
  3. package/cli/init.test.mjs +8 -1
  4. package/cli/portolan.mjs +92 -13
  5. package/cli/portolan.test.mjs +47 -0
  6. package/package.json +7 -6
  7. package/plugins/extract-django/README.md +1 -1
  8. package/plugins/extract-django/extract_test.py +1 -1
  9. package/plugins/extract-django/openapi.py +4 -14
  10. package/plugins/extract-django/openapi_test.py +4 -4
  11. package/plugins/extract-go/options.schema.json +5 -0
  12. package/plugins/extract-project/options.schema.json +21 -0
  13. package/plugins/extract-ts/README.md +1 -1
  14. package/plugins/extract-ts/clients.ts +1 -1
  15. package/plugins/extract-ts/extract.test.ts +2 -2
  16. package/plugins/extract-ts/graphql.test.ts +2 -2
  17. package/plugins/extract-ts/openapi.test.ts +6 -7
  18. package/plugins/extract-ts/openapi.ts +3 -10
  19. package/plugins/portolan-go.wasm +0 -0
  20. package/public/404-cat-v1.webp +0 -0
  21. package/public/cat-about-v1.webp +0 -0
  22. package/public/cat-clear-skies-v1.webp +0 -0
  23. package/public/cat-no-changes-v1.webp +0 -0
  24. package/public/cat-onboarding-v1.webp +0 -0
  25. package/public/cat-trial-success-v1.webp +0 -0
  26. package/schema/portolan.schema.json +61 -0
  27. package/scripts/builtin-plugins.mjs +3 -2
  28. package/scripts/catalog-sources.mjs +14 -2
  29. package/scripts/catalog-sources.test.mjs +45 -0
  30. package/scripts/delivery-presets.mjs +207 -24
  31. package/scripts/diff.mjs +5 -1
  32. package/scripts/gen-likec4.mjs +242 -85
  33. package/scripts/gen-likec4.test.mjs +168 -14
  34. package/scripts/gen.mjs +5 -3
  35. package/scripts/host-plugins/fetch-git.mjs +22 -12
  36. package/scripts/host-plugins/fetch-git.test.mjs +25 -5
  37. package/scripts/local-api.mjs +330 -346
  38. package/scripts/local-api.test.mjs +245 -1
  39. package/scripts/local-discovery.mjs +378 -0
  40. package/scripts/manifest.mjs +42 -1
  41. package/scripts/manifest.test.mjs +24 -1
  42. package/scripts/plugin-host.mjs +14 -3
  43. package/scripts/plugin-host.test.mjs +8 -0
  44. package/scripts/run-builtin.mjs +4 -2
  45. package/scripts/schema.mjs +11 -0
  46. package/scripts/site-docs.mjs +2 -2
  47. package/src/app/CatalogApp.tsx +23 -8
  48. package/src/app/CommandPalette.tsx +7 -2
  49. package/src/app/Sidebar.tsx +13 -730
  50. package/src/app/SidebarFlowSections.tsx +251 -0
  51. package/src/app/SidebarFooter.tsx +160 -0
  52. package/src/app/SidebarTree.tsx +322 -0
  53. package/src/app/toast.tsx +11 -7
  54. package/src/catalog-index.ts +485 -0
  55. package/src/catalog-model.ts +1339 -0
  56. package/src/catalog-validation.ts +1570 -0
  57. package/src/catalog.test.ts +49 -0
  58. package/src/catalog.ts +6 -3124
  59. package/src/components/CatIllustration.tsx +30 -0
  60. package/src/components/CommitLink.tsx +154 -0
  61. package/src/components/EditorLink.tsx +15 -8
  62. package/src/components/GrpcMethodReference.tsx +93 -0
  63. package/src/components/Markdown.tsx +17 -0
  64. package/src/components/MethodRows.tsx +8 -1
  65. package/src/components/PageHeader.test.tsx +23 -0
  66. package/src/components/PageHeader.tsx +34 -0
  67. package/src/components/ProblemRow.tsx +3 -0
  68. package/src/components/ShapeRows.tsx +12 -0
  69. package/src/components/SourceDoc.tsx +27 -12
  70. package/src/components/SourcePreview.tsx +125 -37
  71. package/src/enrich.test.ts +209 -2
  72. package/src/enrich.ts +284 -15
  73. package/src/er/RedisSchema.tsx +86 -5
  74. package/src/flow/FlowTable.tsx +19 -4
  75. package/src/flow/StepDetail.tsx +461 -101
  76. package/src/flow/StepRail.tsx +36 -13
  77. package/src/flow/answers-response.test.ts +100 -0
  78. package/src/flow/answers.ts +11 -8
  79. package/src/flow/chapters.ts +2 -1
  80. package/src/flow/labels.ts +10 -0
  81. package/src/flow/mermaid.test.ts +87 -3
  82. package/src/flow/mermaid.ts +26 -14
  83. package/src/index.css +132 -17
  84. package/src/landing/LandingPage.tsx +16 -12
  85. package/src/lib/all-problems.ts +1 -1
  86. package/src/lib/api.test.ts +4 -6
  87. package/src/lib/build-info.test.ts +29 -0
  88. package/src/lib/build-info.ts +21 -0
  89. package/src/lib/data-model.ts +14 -0
  90. package/src/lib/derive.ts +1 -0
  91. package/src/lib/flow-tree.ts +1 -1
  92. package/src/lib/github-catalog.test.ts +51 -0
  93. package/src/lib/github-catalog.ts +69 -0
  94. package/src/lib/grpc-reference.test.ts +72 -0
  95. package/src/lib/grpc-reference.ts +117 -0
  96. package/src/lib/local-api.ts +36 -11
  97. package/src/lib/motion.test.ts +4 -2
  98. package/src/lib/motion.tsx +5 -4
  99. package/src/lib/product.ts +7 -0
  100. package/src/lib/proto-problems.test.ts +170 -3
  101. package/src/lib/proto-problems.ts +176 -4
  102. package/src/lib/queries.ts +16 -1
  103. package/src/lib/readme-assets.test.ts +19 -0
  104. package/src/lib/readme-assets.ts +34 -0
  105. package/src/lib/setup-info.test.ts +7 -0
  106. package/src/lib/setup-info.ts +5 -0
  107. package/src/lib/source-link.test.ts +106 -18
  108. package/src/lib/source-link.ts +22 -6
  109. package/src/merge.test.ts +101 -0
  110. package/src/merge.ts +65 -1
  111. package/src/pages/AdrDetail.tsx +3 -2
  112. package/src/pages/AdrIndex.tsx +10 -7
  113. package/src/pages/AggregatePage.tsx +34 -4
  114. package/src/pages/BlockPage.tsx +39 -32
  115. package/src/pages/Changes.tsx +7 -9
  116. package/src/pages/ContextMap.tsx +11 -2
  117. package/src/pages/ContextPage.tsx +11 -4
  118. package/src/pages/FlowIndex.tsx +16 -7
  119. package/src/pages/GraphPage.tsx +21 -7
  120. package/src/pages/Language.tsx +9 -9
  121. package/src/pages/NotFound.tsx +58 -15
  122. package/src/pages/Overview.tsx +6 -0
  123. package/src/pages/Problems.tsx +10 -8
  124. package/src/pages/RegistryIndex.tsx +10 -8
  125. package/src/pages/ServicePage.tsx +1 -1
  126. package/src/pages/Settings.tsx +257 -44
  127. package/src/pages/settings/AboutSettings.tsx +129 -0
  128. package/src/pages/settings/DeliverySettings.tsx +71 -15
  129. package/src/routes.test.ts +2 -0
  130. package/src/routes.ts +2 -1
  131. package/src/selection/DetailPanel.tsx +213 -113
  132. package/src/selection/pages.test.ts +14 -1
  133. package/src/selection/pages.ts +9 -3
  134. package/src/testing/setup.ts +14 -0
  135. package/vite.config.ts +3 -1
Binary file
Binary file
Binary file
Binary file
@@ -888,6 +888,16 @@
888
888
  "minLength": 1,
889
889
  "description": "Component slug inside the group."
890
890
  },
891
+ "components": {
892
+ "type": "array",
893
+ "minItems": 2,
894
+ "uniqueItems": true,
895
+ "items": {
896
+ "type": "string",
897
+ "minLength": 1
898
+ },
899
+ "description": "Deployable component slugs discovered inside one repository-backed project."
900
+ },
891
901
  "groupKind": {
892
902
  "enum": [
893
903
  "bounded-context",
@@ -1025,6 +1035,14 @@
1025
1035
  ],
1026
1036
  "description": "Semantic role of the top-level group. Defaults to system for this neutral extractor."
1027
1037
  },
1038
+ "classification": {
1039
+ "enum": [
1040
+ "core",
1041
+ "supporting",
1042
+ "generic"
1043
+ ],
1044
+ "description": "DDD classification of a bounded context when the group represents one."
1045
+ },
1028
1046
  "component": {
1029
1047
  "type": "string",
1030
1048
  "description": "Slug of the component. Defaults to the input directory name."
@@ -1058,6 +1076,41 @@
1058
1076
  "type": "string",
1059
1077
  "description": "Source repository identity. Defaults to a Go module, package.json repository, Cargo package, or Maven coordinates when present."
1060
1078
  },
1079
+ "components": {
1080
+ "type": "array",
1081
+ "minItems": 1,
1082
+ "items": {
1083
+ "type": "object",
1084
+ "additionalProperties": false,
1085
+ "required": [
1086
+ "slug"
1087
+ ],
1088
+ "properties": {
1089
+ "slug": {
1090
+ "type": "string",
1091
+ "minLength": 1
1092
+ },
1093
+ "name": {
1094
+ "type": "string",
1095
+ "minLength": 1
1096
+ },
1097
+ "kind": {
1098
+ "enum": [
1099
+ "service",
1100
+ "application",
1101
+ "webapp",
1102
+ "worker",
1103
+ "job",
1104
+ "function",
1105
+ "cli",
1106
+ "library",
1107
+ "data-pipeline"
1108
+ ]
1109
+ }
1110
+ }
1111
+ },
1112
+ "description": "Independently deployable runtime components found inside one repository. When present, the singular component fields are ignored."
1113
+ },
1061
1114
  "out": {
1062
1115
  "type": "string",
1063
1116
  "description": "Name of the fragment file inside the step output directory.",
@@ -1133,6 +1186,14 @@
1133
1186
  "type": "string",
1134
1187
  "description": "What the service is called on the page. Defaults to the title of its README, and failing that to the slug, titled."
1135
1188
  },
1189
+ "scope": {
1190
+ "type": "string",
1191
+ "description": "Deployable name in a shared Go module. Limits owned source to internal/<scope> while allowing calls to imported application packages to be followed.",
1192
+ "examples": [
1193
+ "api",
1194
+ "billing"
1195
+ ]
1196
+ },
1136
1197
  "repo": {
1137
1198
  "type": "string",
1138
1199
  "description": "Where the source lives. Defaults to the module path in go.mod.",
@@ -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)
@@ -37,7 +38,11 @@ export async function loadCatalog(manifestPath = "portolan.json", { exclude = []
37
38
  if (!excluded.has(normalize(path))) paths.push(path);
38
39
  }
39
40
 
40
- if (paths.length === 0) {
41
+ const emptyWorkspace = Array.isArray(manifest.projects)
42
+ && manifest.projects.length === 0
43
+ && (manifest.extract ?? []).length === 0
44
+ && (manifest.verify ?? []).length === 0;
45
+ if (paths.length === 0 && !emptyWorkspace) {
41
46
  throw new Error(
42
47
  `${manifestPath}: no catalog matched ${JSON.stringify(patterns)}`,
43
48
  );
@@ -49,6 +54,13 @@ export async function loadCatalog(manifestPath = "portolan.json", { exclude = []
49
54
  catalog: JSON.parse(readFileSync(path, "utf8")),
50
55
  })),
51
56
  );
57
+ if (paths.length === 0) {
58
+ // An intentionally empty workspace still needs a valid, deterministic
59
+ // catalog value for generators and the local preview. These are state
60
+ // markers, not provenance claims: there is no source file to stamp.
61
+ merged.catalog.generatedAt = "1970-01-01T00:00:00Z";
62
+ merged.catalog.commit = "empty";
63
+ }
52
64
 
53
65
  // The edges the flows imply are added before validation, the same way the
54
66
  // app does it, so a generator draws the same estate the reader sees.
@@ -0,0 +1,45 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ import { loadCatalog } from "./catalog-sources.mjs";
7
+
8
+ const roots = [];
9
+ afterEach(() => {
10
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
11
+ });
12
+
13
+ function manifest(value) {
14
+ const root = mkdtempSync(join(tmpdir(), "portolan-empty-catalog-"));
15
+ roots.push(root);
16
+ const path = join(root, "portolan.json");
17
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
18
+ return { path, missing: join(root, "portolan/*.json") };
19
+ }
20
+
21
+ describe("catalog sources", () => {
22
+ it("loads an intentional empty workspace as an empty catalog", async () => {
23
+ const { path, missing } = manifest({ sources: [], projects: [], extract: [], verify: [] });
24
+ writeFileSync(path, `${JSON.stringify({ sources: [missing], projects: [], extract: [], verify: [] }, null, 2)}\n`);
25
+ const loaded = await loadCatalog(path);
26
+ expect(loaded.sources).toEqual([]);
27
+ expect(loaded.catalog).toMatchObject({ contexts: [], defs: {}, flows: [], adrs: [] });
28
+ });
29
+
30
+ it("still reports missing sources for a configured project", async () => {
31
+ const { path, missing } = manifest({ sources: [], projects: [] });
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`);
43
+ await expect(loadCatalog(path)).rejects.toThrow(/no catalog matched/);
44
+ });
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])