@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
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  import { afterEach, describe, expect, it } from "vitest";
6
6
  import { rmSync } from "node:fs";
7
7
 
8
- import { classifyRepositoryFailure, diffGeneratedFiles, discoverProject, forgetRepositoryCredential, inspectionRoot, localApiPath, planProject, readLocalSource, resolveRepositoryCommit, storeRepositoryCredential, summarizeProjectTrial, writeProject } from "./local-api.mjs";
8
+ import { classifyRepositoryFailure, diffGeneratedFiles, discoverProject, externalProjectDefaults, forgetRepositoryCredential, inspectionRoot, localApiPath, manifestWithoutProject, manifestWithProject, planProject, readLocalSource, removeProject, resolveRepositoryCommit, starterManifestProject, storeRepositoryCredential, summarizeProjectTrial, undoProjectRemoval, writeProject } from "./local-api.mjs";
9
9
  import { installDeliveryPreset, planDeliveryPreset, providerFromRemote, publicDeliveryPreset } from "./delivery-presets.mjs";
10
10
 
11
11
  const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
@@ -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",
@@ -189,6 +239,18 @@ describe("local project setup", () => {
189
239
  expect(discovery.detections.find((item) => item.plugin === "adr")?.selected).toBe(false);
190
240
  });
191
241
 
242
+ it("names external projects from the repository or selected component instead of the inspection cache", () => {
243
+ expect(externalProjectDefaults("https://github.com/batazor/microservice-template-ddd")).toEqual({
244
+ id: "microservice-template-ddd",
245
+ name: "Microservice Template Ddd",
246
+ group: "microservice-template-ddd",
247
+ component: "microservice-template-ddd",
248
+ context: "microservice-template-ddd",
249
+ service: "microservice-template-ddd",
250
+ });
251
+ expect(externalProjectDefaults("git@github.com:acme/platform.git", "services/payments").id).toBe("payments");
252
+ });
253
+
192
254
  it("finds component roots in a monorepo without reading dependency directories", () => {
193
255
  const root = workspace();
194
256
  mkdirSync(join(root, "services/orders"), { recursive: true });
@@ -209,6 +271,55 @@ describe("local project setup", () => {
209
271
  ]);
210
272
  });
211
273
 
274
+ it("splits corroborated Go cmd entrypoints into deployable services", () => {
275
+ const root = workspace();
276
+ const platform = join(root, "services/platform");
277
+ mkdirSync(platform, { recursive: true });
278
+ writeFileSync(join(platform, "go.mod"), "module example.com/platform\nrequire github.com/go-redis/redis v6.15.9+incompatible\n");
279
+ for (const service of ["api", "billing", "book", "user"]) {
280
+ mkdirSync(join(platform, "cmd", service), { recursive: true });
281
+ mkdirSync(join(platform, "ops/dockerfile"), { recursive: true });
282
+ writeFileSync(join(platform, "cmd", service, `${service}.go`), "package main\nfunc main() {}\n");
283
+ writeFileSync(join(platform, "ops/dockerfile", `${service}.Dockerfile`), `FROM golang\nRUN go build -o app ./cmd/${service}\n`);
284
+ }
285
+ for (const service of ["billing", "book", "user"]) {
286
+ mkdirSync(join(platform, "internal", service, "infrastructure/rpc"), { recursive: true });
287
+ writeFileSync(join(platform, "internal", service, "infrastructure/rpc", `${service}.proto`), `syntax = "proto3"; package ${service}; service ${service}RPC {}\n`);
288
+ mkdirSync(join(platform, "internal", service, "domain"), { recursive: true });
289
+ writeFileSync(join(platform, "internal", service, "domain", `${service}.go`), `package domain\ntype ${service[0].toUpperCase() + service.slice(1)} struct{}\n`);
290
+ }
291
+ mkdirSync(join(platform, "internal/db/redis"), { recursive: true });
292
+ mkdirSync(join(platform, "internal/di"), { recursive: true });
293
+ writeFileSync(join(platform, "internal/db/redis/redis.go"), "package redisdb\nimport redis \"github.com/go-redis/redis\"\nfunc Open() { _ = redis.NewClient(nil) }\n");
294
+ writeFileSync(join(platform, "internal/di/book.go"), "package di\nimport _ \"example.com/platform/internal/db\"\n");
295
+
296
+ const discovery = discoverProject(root, "services/platform");
297
+ expect(discovery.deployables.map(({ slug, confidence }) => [slug, confidence])).toEqual([
298
+ ["api", "high"], ["billing", "high"], ["book", "high"], ["user", "high"],
299
+ ]);
300
+
301
+ const manifest = JSON.parse(readFileSync(join(root, "portolan.json"), "utf8"));
302
+ const plan = planProject(root, manifest, {
303
+ root: "services/platform", id: "platform", name: "Platform", group: "platform", component: "platform", componentKind: "application", plugins: ["project", "go-domain", "proto", "redis"],
304
+ });
305
+ expect(plan.project).toMatchObject({ group: "platform", components: ["api", "billing", "book", "user"] });
306
+ expect(plan.project).not.toHaveProperty("component");
307
+ expect(plan.steps[0].options.components.map((component) => component.slug)).toEqual(["api", "billing", "book", "user"]);
308
+ expect(plan.steps.filter((step) => step.plugin === "proto").map((step) => [step.options.service, step.options.out])).toEqual([
309
+ ["billing", "proto-billing.json"], ["book", "proto-book.json"], ["user", "proto-user.json"],
310
+ ]);
311
+ expect(plan.steps.filter((step) => step.plugin === "redis").map((step) => [step.options.service, step.options.out])).toEqual([
312
+ ["book", "redis-book.json"],
313
+ ]);
314
+ expect(plan.steps.filter((step) => step.plugin === "go-domain").map((step) => [step.options.service, step.options.scope, step.options.store, step.options.peers, step.options.out])).toEqual([
315
+ ["api", "api", undefined, { billing: "platform.billing", book: "platform.book", user: "platform.user" }, "go-domain-api.json"],
316
+ ["billing", "billing", undefined, { billing: "platform.billing", book: "platform.book", user: "platform.user" }, "go-domain-billing.json"],
317
+ ["book", "book", "redis", { billing: "platform.billing", book: "platform.book", user: "platform.user" }, "go-domain-book.json"],
318
+ ["user", "user", undefined, { billing: "platform.billing", book: "platform.book", user: "platform.user" }, "go-domain-user.json"],
319
+ ]);
320
+ expect(plan.steps[0].options.groupKind).toBe("system");
321
+ });
322
+
212
323
  it("only offers the Go domain extractor when the layout contains an aggregate root", () => {
213
324
  const root = workspace();
214
325
  mkdirSync(join(root, "services/billing/internal/domain/invoice"), { recursive: true });
@@ -233,6 +344,14 @@ describe("local project setup", () => {
233
344
  expect(discovery.detections.find((item) => item.plugin === "sql")?.evidence).toBe("2 repository packages");
234
345
  });
235
346
 
347
+ it("accepts a feature domain package named domain", () => {
348
+ const root = workspace();
349
+ mkdirSync(join(root, "services/billing/internal/invoice/domain"), { recursive: true });
350
+ writeFileSync(join(root, "services/billing/internal/invoice/domain/invoice.go"), "package domain\n\ntype Invoice struct{}\n");
351
+ const discovery = discoverProject(root, "services/billing");
352
+ expect(discovery.detections.find((item) => item.plugin === "go-domain")?.evidence).toBe("internal/invoice/domain/invoice.go");
353
+ });
354
+
236
355
  it("offers the River extractor when the Go module uses River", () => {
237
356
  const root = workspace();
238
357
  writeFileSync(join(root, "services/billing/go.mod"), "module example.com/billing\nrequire github.com/riverqueue/river v0.26.0\n");
@@ -383,6 +502,131 @@ describe("local project setup", () => {
383
502
  expect(() => writeProject(root, { ...request, id: "another" })).toThrow(/already exists/);
384
503
  });
385
504
 
505
+ it("removes a project's complete catalog slice without touching its neighbour", () => {
506
+ const manifest = {
507
+ sources: ["portolan/*.json", "services/billing/portolan/*.json"],
508
+ defaultCatalog: "portolan",
509
+ catalogs: [
510
+ { id: "portolan", title: "Portolan", sources: ["portolan/*.json"], contexts: ["portolan"], projects: ["portolan"] },
511
+ { id: "billing", title: "Billing", sources: ["services/billing/portolan/*.json"], contexts: ["finance"], projects: ["billing"] },
512
+ ],
513
+ projects: [
514
+ { id: "portolan", name: "Portolan", root: ".", group: "portolan", component: "portolan" },
515
+ { id: "billing", name: "Billing", root: "services/billing", group: "finance", component: "billing" },
516
+ ],
517
+ extract: [
518
+ { plugin: "project", in: ".", out: "portolan", options: {} },
519
+ { plugin: "commands", in: "scripts", out: "portolan", options: {} },
520
+ { plugin: "project", in: "services/billing", out: "services/billing/portolan", options: {} },
521
+ ],
522
+ verify: [
523
+ { plugin: "otel", in: ".", out: "portolan", options: {} },
524
+ { plugin: "otel", in: "services/billing", out: "services/billing/portolan", options: {} },
525
+ ],
526
+ generate: [
527
+ { plugin: "markdown", catalog: "portolan", out: "docs", options: {} },
528
+ { plugin: "markdown", catalog: "billing", out: "docs/billing", options: {} },
529
+ ],
530
+ };
531
+ const result = manifestWithoutProject(manifest, "portolan");
532
+ expect(result.manifest.projects.map((project) => project.id)).toEqual(["billing"]);
533
+ expect(result.manifest.sources).toEqual(["services/billing/portolan/*.json"]);
534
+ expect(result.manifest.extract.map((step) => step.out)).toEqual(["services/billing/portolan"]);
535
+ expect(result.manifest.verify.map((step) => step.out)).toEqual(["services/billing/portolan"]);
536
+ expect(result.manifest.catalogs.map((catalog) => catalog.id)).toEqual(["billing"]);
537
+ expect(result.manifest.defaultCatalog).toBe("billing");
538
+ expect(result.manifest.generate.map((step) => step.catalog)).toEqual(["billing"]);
539
+ expect(result.removedOutputs).toEqual(["docs"]);
540
+ });
541
+
542
+ it("keeps a valid empty-workspace source and replaces it when a project is added", () => {
543
+ const removed = manifestWithoutProject({
544
+ sources: ["portolan/*.json"],
545
+ projects: [{ id: "portolan", name: "Portolan", root: ".", group: "portolan", component: "portolan" }],
546
+ extract: [{ plugin: "project", in: ".", out: "portolan", options: {} }],
547
+ generate: [{ plugin: "markdown", out: "docs", options: {} }],
548
+ }, "portolan");
549
+ expect(removed.manifest).toMatchObject({ sources: ["portolan/*.json"], projects: [], extract: [] });
550
+ expect(removed.removedOutputs).toEqual(["docs"]);
551
+
552
+ const added = planProject(workspace(), { sources: ["portolan/*.json"], projects: [], extract: [] }, {
553
+ root: "services/billing", id: "aviaadmin", name: "Avia Admin", group: "avia", groupKind: "bounded-context", component: "aviaadmin", plugins: ["project"],
554
+ });
555
+ expect(added.project).toMatchObject({ id: "aviaadmin", group: "avia", groupKind: "bounded-context" });
556
+ expect(added.steps[0].options).toMatchObject({ group: "avia", groupKind: "bounded-context" });
557
+ const manifest = { sources: ["portolan/*.json"], projects: [], extract: [] };
558
+ expect(manifestWithProject(manifest, added).sources).toEqual(["services/billing/portolan/*.json"]);
559
+ });
560
+
561
+ it("does not duplicate an existing external fetch when a project is re-added", () => {
562
+ const fetch = { repo: "https://github.com/acme/orders", commit: "a".repeat(40), paths: [] };
563
+ const manifest = {
564
+ sources: ["vendor/repos/*/*/git.repo.json"], projects: [],
565
+ extract: [{ plugin: "git", in: "vendor", out: "vendor/repos", options: { cache: "vendor/repos", repos: [fetch] } }],
566
+ };
567
+ const plan = {
568
+ project: { id: "orders", root: "vendor/repos/acme/orders", group: "orders", component: "orders" },
569
+ steps: [{ plugin: "project", in: "vendor/repos/acme/orders", out: "vendor/repos/acme/orders/portolan", options: {} }],
570
+ source: "vendor/repos/**/portolan/*.json", fetch,
571
+ };
572
+ expect(manifestWithProject(manifest, plan).extract[0].options.repos).toEqual([fetch]);
573
+ });
574
+
575
+ it("adds the project slice to the default catalog profile", () => {
576
+ const root = workspace();
577
+ const manifest = {
578
+ sources: ["portolan/*.json"],
579
+ defaultCatalog: "company",
580
+ catalogs: [{ id: "company", title: "Company", sources: ["portolan/*.json"], contexts: ["platform"], projects: ["portolan"] }],
581
+ projects: [{ id: "portolan", root: ".", group: "platform", component: "portolan" }],
582
+ extract: [],
583
+ };
584
+ const added = planProject(root, manifest, {
585
+ root: "services/billing", id: "billing", name: "Billing", group: "finance", component: "billing", plugins: ["project"],
586
+ });
587
+ expect(manifestWithProject(manifest, added).catalogs[0]).toMatchObject({
588
+ sources: ["portolan/*.json", "services/billing/portolan/*.json"],
589
+ contexts: ["platform", "finance"],
590
+ projects: ["portolan", "billing"],
591
+ });
592
+ });
593
+
594
+ it("replaces a starter atomically after project discovery succeeds", () => {
595
+ const root = workspace();
596
+ const manifest = JSON.parse(readFileSync(join(root, "portolan.json"), "utf8"));
597
+ Object.assign(manifest, {
598
+ sources: ["portolan/*.json"],
599
+ projects: [{ id: "starter", name: "Starter", root: ".", group: "starter", component: "starter" }],
600
+ extract: [{ plugin: "project", in: ".", out: "portolan", options: { group: "starter", component: "starter" } }],
601
+ });
602
+ writeFileSync(join(root, "portolan.json"), `${JSON.stringify(manifest, null, 2)}\n`);
603
+ expect(starterManifestProject(manifest)?.id).toBe("starter");
604
+ const result = writeProject(root, {
605
+ root: "services/billing", id: "aviaadmin", name: "Avia Admin", group: "avia", contextName: "Aviation", contextSummary: "Flight administration.", classification: "core", groupKind: "bounded-context", component: "aviaadmin", componentKind: "service", replaceStarter: true, plugins: ["project"],
606
+ });
607
+ const written = JSON.parse(readFileSync(join(root, "portolan.json"), "utf8"));
608
+ expect(result.replacedProject?.id).toBe("starter");
609
+ expect(written.projects.map((project) => project.id)).toEqual(["aviaadmin"]);
610
+ expect(starterManifestProject(written)).toBeNull();
611
+ expect(written.sources).toEqual(["services/billing/portolan/*.json"]);
612
+ expect(written.extract).toHaveLength(1);
613
+ expect(written.extract[0].options).toMatchObject({ group: "avia", groupName: "Aviation", groupSummary: "Flight administration.", classification: "core", groupKind: "bounded-context", componentKind: "service" });
614
+ });
615
+
616
+ it("undoes a project removal only while the manifest is unchanged", () => {
617
+ const root = workspace();
618
+ writeProject(root, { root: "services/billing", id: "billing", name: "Billing", group: "finance", component: "billing", plugins: ["project"] });
619
+ mkdirSync(join(root, "services/billing/portolan"), { recursive: true });
620
+ writeFileSync(join(root, "services/billing/portolan/project.json"), "generated slice\n");
621
+ const removed = removeProject(root, "billing");
622
+ expect(JSON.parse(readFileSync(join(root, "portolan.json"), "utf8")).projects).toEqual([]);
623
+ expect(existsSync(join(root, "services/billing/portolan/project.json"))).toBe(false);
624
+ expect(undoProjectRemoval(root, removed.undoToken)).toEqual({ restored: true });
625
+ expect(JSON.parse(readFileSync(join(root, "portolan.json"), "utf8")).projects[0].id).toBe("billing");
626
+ expect(readFileSync(join(root, "services/billing/portolan/project.json"), "utf8")).toBe("generated slice\n");
627
+ expect(() => undoProjectRemoval(root, removed.undoToken)).toThrow(/can no longer be undone/);
628
+ });
629
+
386
630
  it("plans a pinned external repository through the built-in git fetcher", () => {
387
631
  const root = workspace();
388
632
  const repository = "https://github.com/acme/platform.git";