@shortlink-org/portolan 0.2.4 → 0.4.0

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 (291) hide show
  1. package/README.md +174 -6
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1213 -0
  5. package/catalog/roundtrip_test.go +210 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +14 -1
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +21 -0
  11. package/go.sum +34 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +28 -15
  35. package/plugin/describe.go +156 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +404 -50
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-argocd/options.schema.json +44 -0
  42. package/plugins/extract-celery/extract.py +0 -2
  43. package/plugins/extract-celery/extract_test.py +1 -1
  44. package/plugins/extract-celery/main.py +1 -0
  45. package/plugins/extract-csharp-ddd/README.md +213 -0
  46. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  47. package/plugins/extract-django/README.md +52 -19
  48. package/plugins/extract-django/choices.py +47 -0
  49. package/plugins/extract-django/domain.py +30 -18
  50. package/plugins/extract-django/extract.py +21 -7
  51. package/plugins/extract-django/extract_test.py +68 -2
  52. package/plugins/extract-django/lifecycle.py +4 -28
  53. package/plugins/extract-django/main.py +1 -0
  54. package/plugins/extract-django/operations.py +1 -1
  55. package/plugins/extract-django/routing_test.py +109 -1
  56. package/plugins/extract-django/rules.py +154 -0
  57. package/plugins/extract-django/rules_test.py +158 -0
  58. package/plugins/extract-django/store.py +1 -1
  59. package/plugins/extract-django/transport.py +101 -55
  60. package/plugins/extract-django/verbs.py +241 -0
  61. package/plugins/extract-go/README.md +47 -0
  62. package/plugins/extract-go-sqs/options.schema.json +21 -0
  63. package/plugins/extract-http-clients/describe.go +20 -0
  64. package/plugins/extract-http-clients/describe_test.go +11 -0
  65. package/plugins/extract-http-clients/extract.go +740 -0
  66. package/plugins/extract-http-clients/extract_test.go +1561 -0
  67. package/plugins/extract-http-clients/main.go +41 -0
  68. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  69. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  70. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  71. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  72. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  73. package/plugins/extract-k8s/options.schema.json +32 -0
  74. package/plugins/extract-laravel/Cargo.lock +962 -0
  75. package/plugins/extract-laravel/Cargo.toml +20 -0
  76. package/plugins/extract-laravel/README.md +200 -0
  77. package/plugins/extract-laravel/options.schema.json +68 -0
  78. package/plugins/extract-laravel/rustfmt.toml +1 -0
  79. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  80. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  81. package/plugins/extract-php-ddd/README.md +141 -0
  82. package/plugins/extract-php-ddd/options.schema.json +50 -0
  83. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  84. package/plugins/extract-python-kafka/extract.py +0 -2
  85. package/plugins/extract-python-kafka/extract_test.py +1 -1
  86. package/plugins/extract-python-kafka/main.py +1 -0
  87. package/plugins/extract-terraform/options.schema.json +26 -0
  88. package/plugins/extract-ts/extract.test.ts +2 -2
  89. package/plugins/extract-ts/extract.ts +4 -5
  90. package/plugins/extract-ts/graphql.test.ts +1 -1
  91. package/plugins/extract-ts/main.ts +1 -0
  92. package/plugins/openapi/ids.go +261 -0
  93. package/plugins/openapi/ids_test.go +98 -0
  94. package/plugins/phpscan/Cargo.lock +953 -0
  95. package/plugins/phpscan/Cargo.toml +19 -0
  96. package/plugins/phpscan/rustfmt.toml +1 -0
  97. package/plugins/portolan-go.wasm +0 -0
  98. package/plugins/pyplugin/catalog.py +24 -1
  99. package/plugins/pyplugin/protocol.py +1 -5
  100. package/plugins/verify-otel/options.schema.json +12 -0
  101. package/portolan.json +132 -4
  102. package/rules/builtin.json +218 -0
  103. package/schema/portolan.schema.json +905 -4
  104. package/scripts/README.md +21 -13
  105. package/scripts/catalog-sources.mjs +6 -0
  106. package/scripts/delivery-presets.mjs +57 -14
  107. package/scripts/diff.mjs +5 -1
  108. package/scripts/django-aggregates.test.mjs +58 -0
  109. package/scripts/gen-likec4.mjs +150 -17
  110. package/scripts/gen-likec4.test.mjs +96 -0
  111. package/scripts/gen.mjs +148 -118
  112. package/scripts/gitops-example.test.mjs +108 -0
  113. package/scripts/go-discovery.test.mjs +30 -0
  114. package/scripts/history.mjs +186 -3
  115. package/scripts/history.test.mjs +1 -1
  116. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  117. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  118. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  119. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  120. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  121. package/scripts/host-plugins/fetch-git.mjs +78 -21
  122. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  123. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  124. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  125. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  126. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  127. package/scripts/json-format.mjs +192 -0
  128. package/scripts/json-format.test.mjs +97 -0
  129. package/scripts/local-api.mjs +287 -12
  130. package/scripts/local-api.test.mjs +150 -5
  131. package/scripts/local-discovery.mjs +94 -9
  132. package/scripts/manifest.mjs +14 -3
  133. package/scripts/manifest.test.mjs +24 -0
  134. package/scripts/output-diff.mjs +94 -0
  135. package/scripts/output-diff.test.mjs +36 -0
  136. package/scripts/package-smoke.mjs +62 -4
  137. package/scripts/plugin-host.mjs +45 -3
  138. package/scripts/plugin-host.test.mjs +9 -0
  139. package/scripts/plugin-wasm-worker.mjs +4 -1
  140. package/scripts/plugins-fresh.mjs +97 -0
  141. package/scripts/plugins-fresh.test.mjs +64 -0
  142. package/scripts/provenance.mjs +72 -0
  143. package/scripts/provenance.test.mjs +149 -0
  144. package/scripts/run-builtin.mjs +62 -7
  145. package/scripts/schema.mjs +167 -19
  146. package/scripts/trace-trials.mjs +176 -0
  147. package/scripts/trace-trials.test.mjs +142 -0
  148. package/scripts/warning-policy.mjs +167 -0
  149. package/scripts/warning-policy.test.mjs +93 -0
  150. package/src/app/Breadcrumbs.test.ts +3 -0
  151. package/src/app/Breadcrumbs.tsx +3 -0
  152. package/src/app/CatalogApp.tsx +2 -0
  153. package/src/app/Sidebar.tsx +3 -3
  154. package/src/app/SidebarFooter.tsx +20 -4
  155. package/src/catalog-docs.test.ts +64 -0
  156. package/src/catalog-docs.ts +35 -0
  157. package/src/catalog-error.test.ts +15 -0
  158. package/src/catalog-index.ts +25 -0
  159. package/src/catalog-model.ts +293 -5
  160. package/src/catalog-validation.ts +113 -2
  161. package/src/catalog.test.ts +40 -1
  162. package/src/chat/Starter.tsx +5 -11
  163. package/src/chat/tools.test.ts +27 -0
  164. package/src/chat/tools.ts +5 -9
  165. package/src/components/CatalogStamp.tsx +10 -8
  166. package/src/components/ChannelRows.test.tsx +76 -1
  167. package/src/components/ChannelRows.tsx +223 -60
  168. package/src/components/DeploymentRows.tsx +131 -0
  169. package/src/components/DocsLinks.test.tsx +27 -0
  170. package/src/components/DocsLinks.tsx +56 -0
  171. package/src/components/FieldTree.tsx +5 -2
  172. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  173. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  174. package/src/components/Integrations.tsx +1 -1
  175. package/src/components/MachineDocs.tsx +6 -5
  176. package/src/components/MethodRows.tsx +9 -2
  177. package/src/components/PluginIcon.tsx +77 -0
  178. package/src/components/ProblemRow.tsx +173 -153
  179. package/src/components/RelationEvidence.test.tsx +14 -0
  180. package/src/components/RelationEvidence.tsx +53 -0
  181. package/src/components/RuleMarks.tsx +22 -0
  182. package/src/components/ShapeRows.tsx +24 -20
  183. package/src/data.ts +46 -7
  184. package/src/enrich.test.ts +459 -4
  185. package/src/enrich.ts +308 -7
  186. package/src/er/ErCanvas.tsx +218 -12
  187. package/src/er/GroupNode.tsx +57 -0
  188. package/src/er/StoreHeader.tsx +1 -0
  189. package/src/er/layout.test.ts +85 -2
  190. package/src/er/layout.ts +140 -5
  191. package/src/er/spec.test.ts +17 -0
  192. package/src/er/spec.ts +23 -10
  193. package/src/flow/Recordings.test.tsx +52 -0
  194. package/src/flow/Recordings.tsx +236 -0
  195. package/src/flow/StepDetail.tsx +59 -0
  196. package/src/flow/TraceTrial.tsx +419 -0
  197. package/src/flow/evidence.test.ts +16 -0
  198. package/src/flow/evidence.ts +34 -0
  199. package/src/flow/examples.test.ts +33 -0
  200. package/src/flow/examples.ts +37 -0
  201. package/src/flow/outline.test.ts +28 -0
  202. package/src/flow/outline.ts +7 -2
  203. package/src/flow/trace-trial-resume.test.ts +42 -0
  204. package/src/flow/trace-trial-resume.ts +74 -0
  205. package/src/graph/elk.ts +78 -0
  206. package/src/index.css +68 -0
  207. package/src/landing/DraggableReveal.tsx +3 -2
  208. package/src/landing/EvidencePipeline.tsx +105 -0
  209. package/src/landing/LandingPage.tsx +17 -70
  210. package/src/landing/ProductTour.tsx +6 -6
  211. package/src/lib/all-problems.ts +27 -17
  212. package/src/lib/catalog-diff.ts +1 -1
  213. package/src/lib/centrality.test.ts +251 -0
  214. package/src/lib/centrality.ts +232 -0
  215. package/src/lib/confluence.test.ts +37 -0
  216. package/src/lib/confluence.ts +41 -0
  217. package/src/lib/context-color.ts +1 -1
  218. package/src/lib/deployment-drift.ts +22 -0
  219. package/src/lib/derive.ts +43 -69
  220. package/src/lib/django-aggregates.d.mts +9 -0
  221. package/src/lib/django-aggregates.mjs +36 -0
  222. package/src/lib/django-aggregates.test.ts +29 -0
  223. package/src/lib/django-aggregates.ts +5 -0
  224. package/src/lib/environments.test.ts +83 -0
  225. package/src/lib/environments.ts +57 -0
  226. package/src/lib/integration-url.test.ts +30 -0
  227. package/src/lib/integration-url.ts +63 -0
  228. package/src/lib/kafka-ui.ts +3 -45
  229. package/src/lib/local-api.ts +116 -4
  230. package/src/lib/notion.ts +13 -0
  231. package/src/lib/plugin-index.json +2765 -0
  232. package/src/lib/plugins.test.ts +68 -0
  233. package/src/lib/plugins.ts +264 -0
  234. package/src/lib/problem-flows.test.ts +61 -0
  235. package/src/lib/problem-flows.ts +78 -0
  236. package/src/lib/problem-rules-cel.d.mts +43 -0
  237. package/src/lib/problem-rules-cel.mjs +407 -0
  238. package/src/lib/problem-rules.test.ts +287 -0
  239. package/src/lib/problem-rules.ts +271 -0
  240. package/src/lib/problem-subjects.ts +737 -0
  241. package/src/lib/rule-entries.ts +39 -0
  242. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  243. package/src/lib/rules-deploy.test.ts +133 -0
  244. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  245. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  246. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  247. package/src/lib/rules.test.ts +51 -0
  248. package/src/lib/rules.ts +86 -0
  249. package/src/lib/setup-info.test.ts +17 -0
  250. package/src/lib/setup-info.ts +58 -0
  251. package/src/lib/shape.test.ts +32 -0
  252. package/src/lib/shape.ts +30 -6
  253. package/src/lib/tech.ts +16 -0
  254. package/src/lib/trace-project.test.ts +34 -0
  255. package/src/lib/trace-project.ts +50 -0
  256. package/src/lib/use-problems.ts +23 -0
  257. package/src/lib/warnings.test.ts +63 -0
  258. package/src/lib/warnings.ts +260 -0
  259. package/src/likec4/ids.test.ts +6 -2
  260. package/src/likec4/ids.ts +43 -0
  261. package/src/main.tsx +23 -0
  262. package/src/map/ContextMapGraph.tsx +76 -32
  263. package/src/merge-deployments.test.ts +127 -0
  264. package/src/merge.test.ts +82 -0
  265. package/src/merge.ts +189 -18
  266. package/src/pages/AggregatePage.tsx +65 -14
  267. package/src/pages/ContextMap.tsx +45 -3
  268. package/src/pages/ContextPage.tsx +8 -5
  269. package/src/pages/EventPage.tsx +15 -5
  270. package/src/pages/FlowDetail.tsx +23 -2
  271. package/src/pages/GraphPage.tsx +40 -3
  272. package/src/pages/Overview.tsx +152 -12
  273. package/src/pages/PluginIndex.tsx +190 -0
  274. package/src/pages/Problems.tsx +396 -128
  275. package/src/pages/ServicePage.tsx +62 -5
  276. package/src/pages/Settings.tsx +217 -43
  277. package/src/pages/settings/AboutSettings.tsx +8 -1
  278. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  279. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  280. package/src/pages/settings/RecordingSettings.tsx +138 -0
  281. package/src/pages/settings/RulesSettings.tsx +825 -0
  282. package/src/routes.test.ts +9 -0
  283. package/src/routes.ts +22 -1
  284. package/src/selection/DetailPanel.tsx +15 -0
  285. package/src/virtual-provenance.d.ts +11 -0
  286. package/vite.config.ts +5 -0
  287. package/scripts/vendor-lock.mjs +0 -58
  288. package/scripts/vendor-lock.test.mjs +0 -69
  289. package/src/lib/data-problems.ts +0 -314
  290. package/src/lib/proto-problems.ts +0 -237
  291. package/src/lib/wire-problems.ts +0 -342
@@ -20,7 +20,11 @@ import { basename, dirname, join, posix, relative, resolve, sep } from "node:pat
20
20
 
21
21
  import { loadManifest, readManifest, readManifestText } from "./manifest.mjs";
22
22
  import { builtinPluginNames } from "./builtin-plugins.mjs";
23
+ import { pluginsFresh } from "./plugins-fresh.mjs";
24
+ import { djangoAggregateCandidates } from "../src/lib/django-aggregates.mjs";
23
25
  import { installDeliveryPreset, planDeliveryPreset, publicDeliveryPreset } from "./delivery-presets.mjs";
26
+ import { formatLike } from "./json-format.mjs";
27
+ import { UPLOAD_LIMIT, checkRecording, manifestWithTraceStep, recordingPath, stepWithMappings, summarizeTraceTrial, traceStepFor } from "./trace-trials.mjs";
24
28
  import {
25
29
  discoverProject,
26
30
  matches,
@@ -310,7 +314,16 @@ function pluginOptions(plugin, project, detectedOptions = {}) {
310
314
  out: "project.json",
311
315
  };
312
316
  }
313
- if (["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain"].includes(plugin)) {
317
+ if (plugin === "php-ddd" || plugin === "csharp-ddd") {
318
+ // The tree names its own contexts and services; the manifest only says
319
+ // where the code lives and how core it is.
320
+ return {
321
+ ...(project.repository ? { repo: repositoryParts(project.repository).web } : {}),
322
+ ...(detectedOptions?.classification ? { classification: detectedOptions.classification } : {}),
323
+ out: "domain.json",
324
+ };
325
+ }
326
+ if (["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain", "laravel-domain"].includes(plugin)) {
314
327
  return { ...common, ...(project.repository ? { repo: repositoryParts(project.repository).web } : {}), ...detectedOptions, serviceName: project.name, out: "domain.json" };
315
328
  }
316
329
  if (plugin === "sql") return { ...common, store: "pg", ...detectedOptions, out: "stores.json" };
@@ -325,7 +338,7 @@ function pluginOptions(plugin, project, detectedOptions = {}) {
325
338
  if (plugin === "graphql") return { ...common, ...detectedOptions, out: "graphql.json" };
326
339
  if (plugin === "proto") return { ...common, ...detectedOptions, out: "proto.json" };
327
340
  if (plugin === "glossary") return { context: group, ...detectedOptions, out: "glossary.json" };
328
- if (plugin === "adr") return { ...detectedOptions, out: "adr.json" };
341
+ if (plugin === "adr") return { scope: [group, component].filter(Boolean).join(".") || "org", ...detectedOptions, out: "adr.json" };
329
342
  return {};
330
343
  }
331
344
 
@@ -430,7 +443,7 @@ export function planProject(workspace, manifest, request) {
430
443
  };
431
444
  const out = posix.join(finalRoot, "portolan");
432
445
  const detectionByPlugin = new Map(discovery.detections.map((item) => [item.plugin, item]));
433
- const hasDomainModel = plugins.some((plugin) => ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain"].includes(plugin));
446
+ const hasDomainModel = plugins.some((plugin) => ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain", "laravel-domain", "php-ddd", "csharp-ddd"].includes(plugin));
434
447
  const projectDetectionOptions = {
435
448
  groupKind: splitDeployables ? "system" : hasDomainModel ? "bounded-context" : "system",
436
449
  ...(hasDomainModel ? { componentKind: "service" } : {}),
@@ -455,7 +468,7 @@ export function planProject(workspace, manifest, request) {
455
468
  if (!splitDeployables) {
456
469
  const options = plugin === "project"
457
470
  ? projectDetectionOptions
458
- : ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain"].includes(plugin)
471
+ : ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain", "laravel-domain", "php-ddd", "csharp-ddd"].includes(plugin)
459
472
  ? domainDetectionOptions(plugin)
460
473
  : detectionByPlugin.get(plugin)?.options;
461
474
  return [{ plugin, in: finalRoot, out, options: pluginOptions(plugin, project, options) }];
@@ -604,11 +617,19 @@ function projectRequestPlan(workspace, manifest, request) {
604
617
  return { base, plan, starter };
605
618
  }
606
619
 
620
+ /**
621
+ * Writes the manifest in the style the file already has: an unchanged
622
+ * subtree is copied byte for byte, a changed array keeps the shape its old
623
+ * self had, so that what the page changed is what the diff shows.
624
+ */
607
625
  export function writeManifest(path, manifest) {
626
+ let before = "";
627
+ try { before = readFileSync(path, "utf8"); } catch {}
628
+ const text = before.trim() ? formatLike(before.replace(/\n$/, ""), manifest) : JSON.stringify(manifest, null, 2);
608
629
  const staging = mkdtempSync(join(dirname(path), ".portolan-manifest-"));
609
630
  const temp = join(staging, "portolan.json");
610
631
  try {
611
- writeFileSync(temp, `${JSON.stringify(manifest, null, 2)}\n`, { flag: "wx" });
632
+ writeFileSync(temp, `${text}\n`, { flag: "wx" });
612
633
  const validation = loadManifest(temp);
613
634
  if (validation.problems.length) throw new Error(validation.problems.join("\n"));
614
635
  renameSync(temp, path);
@@ -617,6 +638,86 @@ export function writeManifest(path, manifest) {
617
638
  }
618
639
  }
619
640
 
641
+ export function djangoAggregateProposals(workspace) {
642
+ const path = join(workspace, "portolan.json");
643
+ const text = readFileSync(path, "utf8");
644
+ const manifest = readManifestText(text, path);
645
+ const revision = createHash("sha256").update(text).digest("hex");
646
+ let report;
647
+ try { report = JSON.parse(readFileSync(join(workspace, ".portolan/build-report.json"), "utf8")); } catch {}
648
+ const proposals = [];
649
+ for (const step of report?.steps ?? []) {
650
+ if (step.phase !== "extract") continue;
651
+ const matches = (manifest.extract ?? []).map((entry, index) => ({ entry, index }))
652
+ .filter(({ entry }) => entry.plugin === step.plugin && entry.in === step.input && entry.out === step.output);
653
+ if (matches.length !== 1) continue;
654
+ const { entry, index } = matches[0];
655
+ for (const message of step.warnings ?? []) {
656
+ const candidates = djangoAggregateCandidates(message);
657
+ if (!candidates) continue;
658
+ const id = `${index}:${candidates.app}`;
659
+ if (proposals.some((proposal) => proposal.id === id)) continue;
660
+ proposals.push({ id, step: index, plugin: entry.plugin, input: entry.in, output: entry.out, message, ...candidates });
661
+ }
662
+ }
663
+ return { revision, stale: !report || report.manifestSha256 !== revision || report.status === "running", proposals };
664
+ }
665
+
666
+ export function saveDjangoAggregates(workspace, request) {
667
+ const current = djangoAggregateProposals(workspace);
668
+ if (current.stale || request.revision !== current.revision) {
669
+ throw new Error("The manifest or extraction report has changed. Regenerate and review the candidates again.");
670
+ }
671
+ if (!Array.isArray(request.selections) || !request.selections.length) throw new Error("Choose at least one aggregate root.");
672
+ const manifest = readManifest(join(workspace, "portolan.json"));
673
+ const seen = new Set();
674
+ for (const choice of request.selections) {
675
+ const proposal = current.proposals.find((candidate) => candidate.id === choice?.id);
676
+ if (!proposal || !proposal.models.some((model) => model.name === choice.model) || seen.has(choice.id)) {
677
+ throw new Error("Choose one of the reported models for each application.");
678
+ }
679
+ seen.add(choice.id);
680
+ const step = manifest.extract[proposal.step];
681
+ step.options = { ...step.options, aggregates: { ...step.options?.aggregates, [proposal.app]: choice.model } };
682
+ }
683
+ writeManifest(join(workspace, "portolan.json"), manifest);
684
+ return { saved: request.selections.length };
685
+ }
686
+
687
+ /**
688
+ * The manifest's `problemRules` as written, with a revision of the whole file:
689
+ * a save quotes it, so two pages editing the rules cannot overwrite each
690
+ * other, and neither can a hand edit made while the page was open.
691
+ */
692
+ export function problemRulesState(workspace) {
693
+ const path = join(workspace, "portolan.json");
694
+ const text = readFileSync(path, "utf8");
695
+ const manifest = readManifestText(text, path);
696
+ return {
697
+ revision: createHash("sha256").update(text).digest("hex"),
698
+ rules: Array.isArray(manifest.problemRules) ? manifest.problemRules : [],
699
+ };
700
+ }
701
+
702
+ /**
703
+ * Replaces `problemRules`. The expressions are type-checked and the shape is
704
+ * checked against the schema by writeManifest, through the same loader gen
705
+ * uses, so a rule the page accepts is a rule gen accepts.
706
+ */
707
+ export function saveProblemRules(workspace, request) {
708
+ const current = problemRulesState(workspace);
709
+ if (request?.revision !== current.revision) {
710
+ throw new Error("portolan.json has changed since the rules were read. Reload the page and try again.");
711
+ }
712
+ if (!Array.isArray(request.rules)) throw new Error("rules must be an array.");
713
+ const path = join(workspace, "portolan.json");
714
+ const manifest = readManifest(path);
715
+ if (request.rules.length) manifest.problemRules = request.rules;
716
+ else delete manifest.problemRules;
717
+ writeManifest(path, manifest);
718
+ return problemRulesState(workspace);
719
+ }
720
+
620
721
  export function writeProject(workspace, request) {
621
722
  const manifestPath = join(workspace, "portolan.json");
622
723
  const before = readFileSync(manifestPath, "utf8");
@@ -715,6 +816,18 @@ async function body(req) {
715
816
  return text ? JSON.parse(text) : {};
716
817
  }
717
818
 
819
+ /** The bytes of an upload, as they came, up to a limit that is said out loud. */
820
+ async function rawBody(req, limit) {
821
+ const chunks = [];
822
+ let size = 0;
823
+ for await (const chunk of req) {
824
+ size += chunk.length;
825
+ if (size > limit) throw new Error(`The upload is larger than ${Math.round(limit / 1024 / 1024)} MB.`);
826
+ chunks.push(chunk);
827
+ }
828
+ return Buffer.concat(chunks);
829
+ }
830
+
718
831
  function localRequest(req) {
719
832
  const address = req.socket.remoteAddress ?? "";
720
833
  const localAddress = address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
@@ -747,11 +860,21 @@ function feed(job, stream, chunk) {
747
860
  }
748
861
  }
749
862
 
750
- function workspaceFingerprint(workspace) {
863
+ // The tree with nothing in it, which every repository has. Before the first
864
+ // commit there is no HEAD to diff against, and the index diffed against this
865
+ // tree is exactly what has been staged.
866
+ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
867
+
868
+ export function workspaceFingerprint(workspace) {
751
869
  const hash = createHash("sha256");
752
870
  try {
753
- const options = { cwd: workspace, encoding: "buffer", maxBuffer: 128 * 1024 * 1024 };
754
- hash.update(execFileSync("git", ["diff", "--binary", "HEAD", "--", "."], options));
871
+ // Git speaks to nobody here: a workspace that is not a checkout, or one
872
+ // without a commit yet, is a case this function handles, not an error to
873
+ // print from the dev server.
874
+ const options = { cwd: workspace, encoding: "buffer", maxBuffer: 128 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] };
875
+ let base = EMPTY_TREE;
876
+ try { base = execFileSync("git", ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], options).toString().trim() || EMPTY_TREE; } catch {}
877
+ hash.update(execFileSync("git", ["diff", "--binary", base, "--", "."], options));
755
878
  const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], options)
756
879
  .toString().split("\0").filter(Boolean).sort();
757
880
  for (const name of untracked) {
@@ -865,6 +988,7 @@ export function summarizeProjectTrial(snapshot, plan, events) {
865
988
  fileCount: event.fileCount,
866
989
  changedCount: event.changedCount,
867
990
  warnings: event.warnings ?? [],
991
+ diagnostics: event.diagnostics ?? [],
868
992
  ...(event.message ? { message: event.message } : {}),
869
993
  }));
870
994
  const facts = new Map(TRIAL_FACTS.map(([key]) => [key, new Set()]));
@@ -912,10 +1036,12 @@ export function summarizeProjectTrial(snapshot, plan, events) {
912
1036
  for (const term of fragment.terms ?? []) add("terms", term.id ?? term.slug ?? term.name);
913
1037
  }
914
1038
  const warnings = steps.flatMap((step) => step.warnings.map((message) => ({ plugin: step.plugin, message })));
1039
+ const diagnostics = steps.flatMap((step) => step.diagnostics);
915
1040
  return {
916
1041
  steps,
917
1042
  facts: TRIAL_FACTS.map(([key, label]) => ({ key, label, count: facts.get(key)?.size ?? 0 })).filter((fact) => fact.count > 0),
918
1043
  warnings,
1044
+ diagnostics,
919
1045
  generatedFiles: files.length,
920
1046
  };
921
1047
  }
@@ -933,6 +1059,65 @@ function prepareProjectTrial(workspace, request) {
933
1059
  return { ...snapshot, fingerprint, plan };
934
1060
  }
935
1061
 
1062
+ /**
1063
+ * A recording uploaded from the page, staged where the verifier will read it
1064
+ * - the project's recordings directory, in a snapshot of the workspace - with
1065
+ * the verify step that reads it, added to the snapshot's manifest when the
1066
+ * project has none. The workspace itself is not touched until the trial is
1067
+ * applied.
1068
+ */
1069
+ function prepareTraceTrial(workspace, { projectId, name, content }) {
1070
+ const manifest = readManifest(join(workspace, "portolan.json"));
1071
+ const project = (manifest.projects ?? []).find((candidate) => candidate.id === projectId);
1072
+ if (!project) throw new Error(`Project "${projectId}" does not exist. Add the project before recording it.`);
1073
+ const known = builtinPluginNames().has("otel") || (manifest.plugins ?? []).some((plugin) => plugin.name === "otel");
1074
+ if (!known) throw new Error("The otel verifier is not available in this installation.");
1075
+ const { batches, spans } = checkRecording(content);
1076
+ const recording = recordingPath(name, { taken: (candidate) => lstatExists(join(workspace, project.root, candidate)) });
1077
+ const fingerprint = workspaceFingerprint(workspace);
1078
+ const snapshot = snapshotWorkspace(workspace);
1079
+ if (workspaceFingerprint(workspace) !== fingerprint) {
1080
+ rmSync(snapshot.holder, { recursive: true, force: true });
1081
+ throw new Error("Files changed while the trial workspace was being created. Try again.");
1082
+ }
1083
+ const target = join(snapshot.snapshot, project.root, recording);
1084
+ mkdirSync(dirname(target), { recursive: true });
1085
+ writeFileSync(target, content);
1086
+ const next = manifestWithTraceStep(manifest, project);
1087
+ if (next.changed) writeManifest(join(snapshot.snapshot, "portolan.json"), next.manifest);
1088
+ const trace = { projectId, root: project.root, recording, step: next.step, stepAdded: next.changed, stepChange: next.change, batches, spans, content };
1089
+ return { ...snapshot, fingerprint, trace };
1090
+ }
1091
+
1092
+ /**
1093
+ * The recording written beside the project, and the manifest with the step
1094
+ * that reads it and the names the page mapped. Undoable the way a project
1095
+ * is: the manifest before is remembered for a while.
1096
+ */
1097
+ function applyTraceTrial(workspace, trial, { services, events, routes } = {}) {
1098
+ const manifestPath = join(workspace, "portolan.json");
1099
+ const before = readFileSync(manifestPath, "utf8");
1100
+ const manifest = readManifestText(before, manifestPath);
1101
+ const project = (manifest.projects ?? []).find((candidate) => candidate.id === trial.trace.projectId);
1102
+ if (!project) throw new Error(`Project "${trial.trace.projectId}" no longer exists.`);
1103
+ const target = join(workspace, project.root, trial.trace.recording);
1104
+ if (lstatExists(target)) throw new Error(`${posix.join(project.root, trial.trace.recording)} appeared while the trial ran. Run it again.`);
1105
+ mkdirSync(dirname(target), { recursive: true });
1106
+ writeFileSync(target, trial.trace.content, { flag: "wx" });
1107
+ const next = manifestWithTraceStep(manifest, project);
1108
+ const found = traceStepFor(next.manifest, project);
1109
+ const mapped = stepWithMappings(found.step, { services, events, routes });
1110
+ const changed = next.changed || JSON.stringify(mapped) !== JSON.stringify(found.step);
1111
+ let undoToken = null;
1112
+ if (changed) {
1113
+ const verify = [...next.manifest.verify];
1114
+ verify[found.index] = mapped;
1115
+ writeManifest(manifestPath, { ...next.manifest, verify });
1116
+ undoToken = rememberManifestUndo(workspace, before, readFileSync(manifestPath, "utf8"));
1117
+ }
1118
+ return { recording: posix.join(project.root, trial.trace.recording), project: project.id, stepAdded: next.changed, manifestChanged: changed, undoToken };
1119
+ }
1120
+
936
1121
  function freeLocalPort() {
937
1122
  return new Promise((resolvePort, reject) => {
938
1123
  const server = createNetServer();
@@ -993,9 +1178,31 @@ async function startProjectPreview(job) {
993
1178
  return job.previewUrl;
994
1179
  }
995
1180
 
1181
+ /**
1182
+ * Rebuilds src/likec4/generated.jsx from likec4/ in the workspace, the way
1183
+ * `npm run likec4:gen` does before `dev`. Said in the run's log either way;
1184
+ * a bundle that fails to build leaves the last one in place.
1185
+ */
1186
+ function refreshLikeC4Bundle(job) {
1187
+ return new Promise((done) => {
1188
+ const bin = join(job.runRoot, "node_modules/likec4/bin/likec4.mjs");
1189
+ if (!lstatExists(bin)) return done();
1190
+ emit(job, { type: "log", stream: "stdout", message: "likec4 → src/likec4/generated.jsx" });
1191
+ const child = spawn(process.execPath, [bin, "gen", "react", "likec4", "-o", "src/likec4/generated.jsx", "--no-use-dot"], { cwd: job.runRoot, stdio: ["ignore", "pipe", "pipe"] });
1192
+ let output = "";
1193
+ child.stdout.on("data", (chunk) => { output += chunk; });
1194
+ child.stderr.on("data", (chunk) => { output += chunk; });
1195
+ child.on("error", (error) => { emit(job, { type: "log", stream: "stderr", message: `likec4: ${error.message}` }); done(); });
1196
+ child.on("close", (code) => {
1197
+ if (code !== 0) emit(job, { type: "log", stream: "stderr", message: `likec4 gen react exited with ${code}:\n${output.trim()}` });
1198
+ done();
1199
+ });
1200
+ });
1201
+ }
1202
+
996
1203
  function startJob(workspace, mode, approvedPreview, preparedTrial) {
997
1204
  const id = randomUUID();
998
- const preview = mode === "preview" || mode === "project-preview";
1205
+ const preview = mode === "preview" || mode === "project-preview" || mode === "trace-preview";
999
1206
  const fingerprint = preparedTrial?.fingerprint ?? (preview ? workspaceFingerprint(workspace) : approvedPreview?.fingerprint);
1000
1207
  const snapshot = preparedTrial ?? (preview ? snapshotWorkspace(workspace) : null);
1001
1208
  if (preview && !preparedTrial && workspaceFingerprint(workspace) !== fingerprint) {
@@ -1004,13 +1211,21 @@ function startJob(workspace, mode, approvedPreview, preparedTrial) {
1004
1211
  }
1005
1212
  const generatedAt = preview ? new Date().toISOString() : approvedPreview?.generatedAt;
1006
1213
  const gitAuth = gitAuthEnvironment();
1007
- const job = { id, mode, status: "running", events: [], subscribers: new Set(), buffers: { stdout: "", stderr: "" }, child: null, gitAuth, runRoot: snapshot?.snapshot ?? workspace, snapshotHolder: snapshot?.holder ?? null, fingerprint, generatedAt, projectPlan: preparedTrial?.plan ?? null, projectRequest: preparedTrial ? structuredClone(preparedTrial.request) : null };
1214
+ const job = { id, mode, status: "running", events: [], subscribers: new Set(), buffers: { stdout: "", stderr: "" }, child: null, gitAuth, runRoot: snapshot?.snapshot ?? workspace, snapshotHolder: snapshot?.holder ?? null, fingerprint, generatedAt, projectPlan: preparedTrial?.plan ?? null, projectRequest: preparedTrial?.request ? structuredClone(preparedTrial.request) : null, trace: preparedTrial?.trace ?? null };
1008
1215
  jobs.set(id, job);
1009
1216
  const cli = process.env.PORTOLAN_CLI;
1010
- const command = cli ? process.execPath : process.platform === "win32" ? "npm.cmd" : "npm";
1217
+ // `npm run gen` builds the plugins first, which a checkout whose plugin
1218
+ // sources have not moved since the last build does not need: the fourth
1219
+ // trial of a recording in a row learns nothing from a minute of javac.
1220
+ // When every artefact is at least as new as its sources the generator
1221
+ // runs on its own; anything doubtful takes the road that builds.
1222
+ const fresh = !cli && pluginsFresh(job.runRoot);
1223
+ const command = cli || fresh ? process.execPath : process.platform === "win32" ? "npm.cmd" : "npm";
1011
1224
  const args = cli
1012
1225
  ? [cli, mode === "check" ? "check" : "generate", "--cwd", job.runRoot]
1013
- : ["run", mode === "check" ? "gen:check" : "gen"];
1226
+ : fresh
1227
+ ? [join(job.runRoot, "scripts/gen.mjs"), ...(mode === "check" ? ["--check"] : [])]
1228
+ : ["run", mode === "check" ? "gen:check" : "gen"];
1014
1229
  let child;
1015
1230
  try {
1016
1231
  child = spawn(command, args, {
@@ -1047,6 +1262,18 @@ function startJob(workspace, mode, approvedPreview, preparedTrial) {
1047
1262
  }
1048
1263
  catch (cause) { job.status = "failed"; emit(job, { type: "log", stream: "stderr", message: `Could not summarise project trial: ${cause instanceof Error ? cause.message : String(cause)}` }); }
1049
1264
  }
1265
+ if (mode === "trace-preview" && job.status === "ok") {
1266
+ try {
1267
+ job.traceTrial = summarizeTraceTrial(job.runRoot, job.trace, job.events);
1268
+ emit(job, { type: "trace-trial-ready", ...job.traceTrial });
1269
+ }
1270
+ catch (cause) { job.status = "failed"; emit(job, { type: "log", stream: "stderr", message: `Could not summarise the recording: ${cause instanceof Error ? cause.message : String(cause)}` }); }
1271
+ }
1272
+ // The catalog is written, and the pictures' sources with it; the bundle
1273
+ // the dev server draws them from is built once before it starts, so a
1274
+ // write from the page rebuilds it here, or the new flow has no picture
1275
+ // until the next start.
1276
+ if (mode === "write" && job.status === "ok" && !process.env.PORTOLAN_CLI) await refreshLikeC4Bundle(job);
1050
1277
  emit(job, { type: "process-finished", status: job.status, code, signal });
1051
1278
  for (const response of job.subscribers) response.end();
1052
1279
  job.subscribers.clear();
@@ -1077,6 +1304,12 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
1077
1304
  const active = [...jobs.values()].find((job) => job.status === "running");
1078
1305
  return send(res, 200, { local: true, workspace: realpathSync(workspace), setup: setup(workspace, publicSetupFrom), activeRun: active ? { id: active.id, mode: active.mode } : null });
1079
1306
  }
1307
+ if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/django-aggregates`) {
1308
+ return send(res, 200, djangoAggregateProposals(workspace));
1309
+ }
1310
+ if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/rules`) {
1311
+ return send(res, 200, problemRulesState(workspace));
1312
+ }
1080
1313
  if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/delivery-presets`) {
1081
1314
  const features = url.searchParams.has("features")
1082
1315
  ? url.searchParams.get("features").split(",").filter(Boolean)
@@ -1096,10 +1329,31 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
1096
1329
  req.on("close", () => job.subscribers.delete(res));
1097
1330
  return;
1098
1331
  }
1332
+ if (req.method === "POST" && url.pathname === `${LOCAL_API_PREFIX}/traces/trials` && req.headers["x-portolan-local"] === "1") {
1333
+ // The one upload the local API takes: a recording, as bytes,
1334
+ // named by headers rather than wrapped in JSON.
1335
+ if ([...jobs.values()].some((job) => job.status === "running")) return send(res, 409, { error: "A generator run is already active." });
1336
+ const content = await rawBody(req, UPLOAD_LIMIT);
1337
+ const projectId = String(req.headers["x-portolan-project"] ?? "");
1338
+ const name = decodeURIComponent(String(req.headers["x-portolan-filename"] ?? "recording.jsonl"));
1339
+ const prepared = prepareTraceTrial(workspace, { projectId, name, content });
1340
+ let job;
1341
+ try { job = startJob(workspace, "trace-preview", null, prepared); }
1342
+ catch (cause) { rmSync(prepared.holder, { recursive: true, force: true }); throw cause; }
1343
+ return send(res, 202, { runId: job.id, mode: job.mode, recording: posix.join(prepared.trace.root, prepared.trace.recording), project: projectId, stepAdded: prepared.trace.stepAdded, stepChange: prepared.trace.stepChange, spans: prepared.trace.spans });
1344
+ }
1099
1345
  if (req.method !== "POST" || req.headers["content-type"]?.split(";")[0] !== "application/json" || req.headers["x-portolan-local"] !== "1") {
1100
1346
  return send(res, 405, { error: "Use a local JSON request." });
1101
1347
  }
1102
1348
  const input = await body(req);
1349
+ if (url.pathname === `${LOCAL_API_PREFIX}/django-aggregates`) {
1350
+ if ([...jobs.values()].some((job) => job.status === "running")) throw new Error("Wait for the current generation to finish before saving aggregate roots.");
1351
+ return send(res, 200, saveDjangoAggregates(workspace, input));
1352
+ }
1353
+ if (url.pathname === `${LOCAL_API_PREFIX}/rules`) {
1354
+ if ([...jobs.values()].some((job) => job.status === "running")) throw new Error("Wait for the current generation to finish before saving rules.");
1355
+ return send(res, 200, saveProblemRules(workspace, input));
1356
+ }
1103
1357
  if (url.pathname === `${LOCAL_API_PREFIX}/delivery-presets/install`) {
1104
1358
  return send(res, 201, installDeliveryPreset(workspace, input));
1105
1359
  }
@@ -1134,6 +1388,27 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
1134
1388
  disposeProjectTrial(trial);
1135
1389
  return send(res, 200, { runId: trial.id, status: "disposed" });
1136
1390
  }
1391
+ const applyTraceMatch = url.pathname.match(/^\/__portolan\/traces\/trials\/([^/]+)\/apply$/);
1392
+ if (applyTraceMatch) {
1393
+ if ([...jobs.values()].some((job) => job.status === "running")) return send(res, 409, { error: "A generator run is already active." });
1394
+ const trial = jobs.get(applyTraceMatch[1]);
1395
+ if (!trial?.trace || trial.status !== "ok" || !trial.traceTrial) return send(res, 409, { error: "Run a successful recording trial before keeping it." });
1396
+ if (trial.applied) return send(res, 409, { error: "This recording was already kept." });
1397
+ if (workspaceFingerprint(workspace) !== trial.fingerprint) return send(res, 409, { error: "Files changed after this trial. Upload the recording again." });
1398
+ const result = applyTraceTrial(workspace, trial, { services: input.services, events: input.events, routes: input.routes });
1399
+ trial.applied = true;
1400
+ const generation = input.generate ? startJob(workspace, "write", trial) : null;
1401
+ return send(res, 201, { ...result, setup: setup(workspace, publicSetupFrom), run: generation ? { runId: generation.id, mode: generation.mode } : null });
1402
+ }
1403
+ const disposeTraceMatch = url.pathname.match(/^\/__portolan\/traces\/trials\/([^/]+)\/dispose$/);
1404
+ if (disposeTraceMatch) {
1405
+ const trial = jobs.get(disposeTraceMatch[1]);
1406
+ if (!trial?.trace) return send(res, 404, { error: "Recording trial not found." });
1407
+ if (trial.status === "running") return send(res, 409, { error: "Cancel the running trial first." });
1408
+ trial.trace.content = null;
1409
+ disposeProjectTrial(trial);
1410
+ return send(res, 200, { runId: trial.id, status: "disposed" });
1411
+ }
1137
1412
  if (url.pathname === `${LOCAL_API_PREFIX}/projects/preview`) {
1138
1413
  const manifest = readManifest(join(workspace, "portolan.json"));
1139
1414
  return send(res, 200, projectRequestPlan(workspace, manifest, input).plan);
@@ -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, externalProjectDefaults, forgetRepositoryCredential, inspectionRoot, localApiPath, manifestWithoutProject, manifestWithProject, planProject, readLocalSource, removeProject, resolveRepositoryCommit, starterManifestProject, storeRepositoryCredential, summarizeProjectTrial, undoProjectRemoval, writeProject } from "./local-api.mjs";
8
+ import { classifyRepositoryFailure, diffGeneratedFiles, discoverProject, externalProjectDefaults, forgetRepositoryCredential, inspectionRoot, localApiPath, manifestWithoutProject, manifestWithProject, planProject, problemRulesState, readLocalSource, removeProject, saveProblemRules, resolveRepositoryCommit, starterManifestProject, storeRepositoryCredential, summarizeProjectTrial, undoProjectRemoval, workspaceFingerprint, writeManifest, 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;
@@ -48,7 +48,10 @@ describe("local API base path", () => {
48
48
  });
49
49
  });
50
50
 
51
- describe("local project setup", () => {
51
+ // Each delivery-preset test spawns a dozen git processes; while the whole
52
+ // suite is starting, one spawn takes hundreds of milliseconds, so the
53
+ // default five seconds is a coin flip rather than a limit.
54
+ describe("local project setup", { timeout: 30_000 }, () => {
52
55
  it("detects GitHub and GitLab remotes without treating arbitrary hosts as a forge", () => {
53
56
  expect(providerFromRemote("git@github.com:acme/shop.git")).toBe("github");
54
57
  expect(providerFromRemote("https://gitlab.example.com/acme/shop.git")).toBe("gitlab");
@@ -140,13 +143,18 @@ describe("local project setup", () => {
140
143
  writeFileSync(join(root, ".gitlab-ci.yml"), "lint:\n script: echo lint\n");
141
144
  const preview = planDeliveryPreset(root);
142
145
  expect(preview).toMatchObject({ provider: "gitlab", detectedProvider: "gitlab", status: "available" });
143
- expect(preview.files[0].diff).toContain("+\"portolan:check\":");
146
+ expect(preview.features.filter((feature) => feature.selected).map((feature) => feature.id)).toEqual(["pages"]);
147
+ expect(preview.files[0].diff).not.toContain("+\"portolan:check\":");
144
148
  const installed = installDeliveryPreset(root, { provider: "gitlab", revision: preview.revision });
145
149
  expect(installed.status).toBe("installed");
146
150
  const pipeline = readFileSync(join(root, ".gitlab-ci.yml"), "utf8");
147
151
  expect(pipeline).toContain("lint:\n script: echo lint");
148
152
  expect(pipeline).toContain("# >>> Portolan delivery preset >>>");
149
- expect(pipeline).toContain("pages:\n publish: dist");
153
+ expect(pipeline).toContain("\npages:\n");
154
+ expect(pipeline).toContain("stage: deploy");
155
+ expect(pipeline).toContain("tags:\n - runner-type:docker");
156
+ expect(pipeline).toContain("portolan build --output public");
157
+ expect(pipeline).toContain("artifacts:\n paths:\n - public");
150
158
  expect(existsSync(join(root, ".github"))).toBe(false);
151
159
  });
152
160
 
@@ -239,6 +247,33 @@ describe("local project setup", () => {
239
247
  expect(discovery.detections.find((item) => item.plugin === "adr")?.selected).toBe(false);
240
248
  });
241
249
 
250
+ it("recognizes common ADR markdown variants and previews extracted fields", () => {
251
+ const root = workspace();
252
+ const adrRoot = join(root, "services/billing/docs/adr");
253
+ rmSync(join(adrRoot, "0001.md"));
254
+ writeFileSync(join(adrRoot, "0001-store-invoices.md"), "# 1. Store invoices\n\nDate: 2025-01-02\n\n## Status\n\nAccepted\n\n## Decision\n\nUse Postgres.\n");
255
+ writeFileSync(join(adrRoot, "02-package-layout.md"), "# ADR-2. Архитектура пакетов\n\n#### Статус: на рассмотрении\n\n### Решение\n\nУпростить структуру.\n");
256
+
257
+ const discovery = discoverProject(root, "services/billing");
258
+ const adr = discovery.detections.find((item) => item.plugin === "adr");
259
+ expect(adr).toMatchObject({
260
+ selected: true,
261
+ confidence: "high",
262
+ evidence: "docs/adr/*.md",
263
+ options: { files: ["docs/adr/*.md"] },
264
+ });
265
+ expect(adr?.preview).toEqual([
266
+ { file: "docs/adr/0001-store-invoices.md", fields: { number: "1", title: "Store invoices", status: "accepted", date: "2025-01-02" } },
267
+ { file: "docs/adr/02-package-layout.md", fields: { number: "2", title: "Архитектура пакетов", status: "proposed", date: "from git history" } },
268
+ ]);
269
+
270
+ const manifest = JSON.parse(readFileSync(join(root, "portolan.json"), "utf8"));
271
+ const plan = planProject(root, manifest, {
272
+ root: "services/billing", id: "billing", name: "Billing", group: "finance", component: "billing", plugins: ["adr"],
273
+ });
274
+ expect(plan.steps[0].options).toEqual({ files: ["docs/adr/*.md"], scope: "finance.billing", out: "adr.json" });
275
+ });
276
+
242
277
  it("names external projects from the repository or selected component instead of the inspection cache", () => {
243
278
  expect(externalProjectDefaults("https://github.com/batazor/microservice-template-ddd")).toEqual({
244
279
  id: "microservice-template-ddd",
@@ -691,7 +726,7 @@ describe("local project setup", () => {
691
726
  plugins: ["openapi", "redis"],
692
727
  steps: [{ plugin: "openapi", out: output }, { plugin: "redis", out: output }],
693
728
  }, [
694
- { type: "step-finished", phase: "extract", plugin: "openapi", output, status: "written", durationMs: 4, fileCount: 1, changedCount: 1, files: [`${output}/api.json`], warnings: ["one route has no description"] },
729
+ { type: "step-finished", phase: "extract", plugin: "openapi", output, status: "written", durationMs: 4, fileCount: 1, changedCount: 1, files: [`${output}/api.json`], warnings: ["one route has no description"], diagnostics: [{ plugin: "openapi", rule: "plugin.openapi.other-test", severity: "warning", action: "Inspect it.", message: "one route has no description", count: 1, project: "billing", phase: "extract", suppressed: true, suppressionReason: "Owned upstream." }] },
695
730
  { type: "step-finished", phase: "extract", plugin: "redis", output, status: "written", durationMs: 3, fileCount: 1, changedCount: 1, files: [`${output}/redis.json`], warnings: [] },
696
731
  { type: "step-finished", phase: "generate", plugin: "markdown", output: "docs", status: "written", durationMs: 2, fileCount: 10, changedCount: 10, files: ["docs/index.md"], warnings: [] },
697
732
  ]);
@@ -709,6 +744,116 @@ describe("local project setup", () => {
709
744
  });
710
745
  expect(result.steps.map((step) => step.plugin)).toEqual(["openapi", "redis"]);
711
746
  expect(result.warnings).toEqual([{ plugin: "openapi", message: "one route has no description" }]);
747
+ expect(result.diagnostics).toEqual([expect.objectContaining({ rule: "plugin.openapi.other-test", suppressed: true })]);
712
748
  expect(result.generatedFiles).toBe(2);
713
749
  });
714
750
  });
751
+
752
+ describe("workspaceFingerprint", () => {
753
+ it("reads a repository before its first commit without complaint, and moves when a file does", () => {
754
+ const root = workspace();
755
+ execFileSync("git", ["init", "-q", "-b", "main"], { cwd: root });
756
+ execFileSync("git", ["add", "services/billing/go.mod"], { cwd: root });
757
+
758
+ const before = workspaceFingerprint(root);
759
+ expect(workspaceFingerprint(root)).toBe(before);
760
+
761
+ writeFileSync(join(root, "services/billing/go.mod"), "module example.com/billing\n\ngo 1.22\n");
762
+ const staged = workspaceFingerprint(root);
763
+ expect(staged).not.toBe(before);
764
+
765
+ writeFileSync(join(root, "services/billing/api/handlers.go"), "package api\n");
766
+ expect(workspaceFingerprint(root)).not.toBe(staged);
767
+ });
768
+
769
+ it("is the same whether or not the repository has a commit, for the same tree", () => {
770
+ const root = workspace();
771
+ execFileSync("git", ["init", "-q", "-b", "main"], { cwd: root });
772
+ const unborn = workspaceFingerprint(root);
773
+ execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "--allow-empty", "-m", "empty"], { cwd: root });
774
+ expect(workspaceFingerprint(root)).toBe(unborn);
775
+ });
776
+ });
777
+
778
+ describe("writing the manifest", () => {
779
+ it("keeps the file's own formatting and changes only what changed", () => {
780
+ const root = workspace();
781
+ const path = join(root, "portolan.json");
782
+ const before = [
783
+ "{",
784
+ ' "sources": ["data/*.json", "services/*/portolan/*.json"],',
785
+ ' "projects": [],',
786
+ ' "plugins": [{ "name": "otel", "process": { "command": "true" } }],',
787
+ ' "extract": [],',
788
+ ' "verify": [',
789
+ " {",
790
+ ' "plugin": "otel",',
791
+ ' "in": "services/billing",',
792
+ ' "out": "services/billing/portolan",',
793
+ ' "options": {',
794
+ ' "traces": [',
795
+ ' "telemetry/traces.jsonl"',
796
+ " ]",
797
+ " }",
798
+ " }",
799
+ " ]",
800
+ "}",
801
+ "",
802
+ ].join("\n");
803
+ writeFileSync(path, before);
804
+
805
+ const manifest = JSON.parse(before);
806
+ manifest.verify[0].options.traces.push("telemetry/recordings/*.jsonl");
807
+ writeManifest(path, manifest);
808
+
809
+ const after = readFileSync(path, "utf8");
810
+ expect(JSON.parse(after)).toEqual(manifest);
811
+ expect(after).toContain(' "sources": ["data/*.json", "services/*/portolan/*.json"],');
812
+ expect(after).toContain(' "plugins": [{ "name": "otel", "process": { "command": "true" } }],');
813
+ expect(after).toContain(' "telemetry/traces.jsonl",\n "telemetry/recordings/*.jsonl"\n ]');
814
+ expect(after.endsWith("}\n")).toBe(true);
815
+ });
816
+
817
+ it("still refuses a manifest the schema rejects, and leaves the file as it was", () => {
818
+ const root = workspace();
819
+ const path = join(root, "portolan.json");
820
+ const before = readFileSync(path, "utf8");
821
+ expect(() => writeManifest(path, { ...JSON.parse(before), verify: [{ plugin: "nope", in: "x", out: "y" }] })).toThrow();
822
+ expect(readFileSync(path, "utf8")).toBe(before);
823
+ });
824
+ });
825
+
826
+ describe("problem rules", () => {
827
+ it("reads the manifest's rules with a revision, and writes them back through the schema", () => {
828
+ const root = workspace();
829
+ const before = problemRulesState(root);
830
+ expect(before.rules).toEqual([]);
831
+ expect(before.revision).toMatch(/^[0-9a-f]{64}$/);
832
+
833
+ const rules = [
834
+ { id: "shared-store", enabled: false, reason: "one database by design" },
835
+ { id: "team.quiet-event", over: "event", severity: "warning", title: "Quiet event", when: "size(event.consumers) == 0", message: "'nothing consumes ' + event.id" },
836
+ ];
837
+ const after = saveProblemRules(root, { revision: before.revision, rules });
838
+ expect(after.rules).toEqual(rules);
839
+ expect(after.revision).not.toBe(before.revision);
840
+ expect(JSON.parse(readFileSync(join(root, "portolan.json"), "utf8")).problemRules).toEqual(rules);
841
+
842
+ // Emptying the list removes the key, so a manifest that never had one
843
+ // does not gain an empty array.
844
+ const emptied = saveProblemRules(root, { revision: after.revision, rules: [] });
845
+ expect(emptied.rules).toEqual([]);
846
+ expect(JSON.parse(readFileSync(join(root, "portolan.json"), "utf8"))).not.toHaveProperty("problemRules");
847
+ });
848
+
849
+ it("refuses a stale revision and a rule the type check rejects, and leaves the file alone", () => {
850
+ const root = workspace();
851
+ const { revision } = problemRulesState(root);
852
+ const text = readFileSync(join(root, "portolan.json"), "utf8");
853
+ expect(() => saveProblemRules(root, { revision: "0".repeat(64), rules: [] })).toThrow(/changed since/);
854
+ expect(() => saveProblemRules(root, { revision, rules: [{ id: "team.a", over: "event", title: "A", when: "event.nme == 'x'", message: "'m'" }] })).toThrow(/nme/);
855
+ expect(() => saveProblemRules(root, { revision, rules: [{ id: "rpc", when: "true" }] })).toThrow(/built-in/);
856
+ expect(() => saveProblemRules(root, { revision, rules: [{ id: "team.b", over: "event", title: "B", when: "true", message: "'m'", extra: 1 }] })).toThrow();
857
+ expect(readFileSync(join(root, "portolan.json"), "utf8")).toBe(text);
858
+ });
859
+ });