@shortlink-org/portolan 0.3.0 → 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 (188) hide show
  1. package/README.md +151 -6
  2. package/catalog/model.go +147 -0
  3. package/catalog/roundtrip_test.go +7 -0
  4. package/cli/portolan.mjs +6 -1
  5. package/go.mod +7 -0
  6. package/go.sum +20 -6
  7. package/package.json +10 -9
  8. package/plugin/describe.go +38 -0
  9. package/plugins/README.md +296 -5
  10. package/plugins/extract-argocd/options.schema.json +44 -0
  11. package/plugins/extract-celery/main.py +1 -0
  12. package/plugins/extract-csharp-ddd/README.md +213 -0
  13. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  14. package/plugins/extract-django/README.md +13 -2
  15. package/plugins/extract-django/choices.py +47 -0
  16. package/plugins/extract-django/domain.py +2 -1
  17. package/plugins/extract-django/extract_test.py +13 -0
  18. package/plugins/extract-django/lifecycle.py +2 -28
  19. package/plugins/extract-django/main.py +1 -0
  20. package/plugins/extract-django/rules.py +154 -0
  21. package/plugins/extract-django/rules_test.py +158 -0
  22. package/plugins/extract-go-sqs/options.schema.json +21 -0
  23. package/plugins/extract-http-clients/describe.go +5 -4
  24. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  25. package/plugins/extract-k8s/options.schema.json +32 -0
  26. package/plugins/extract-laravel/Cargo.lock +962 -0
  27. package/plugins/extract-laravel/Cargo.toml +20 -0
  28. package/plugins/extract-laravel/README.md +200 -0
  29. package/plugins/extract-laravel/options.schema.json +68 -0
  30. package/plugins/extract-laravel/rustfmt.toml +1 -0
  31. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  32. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  33. package/plugins/extract-php-ddd/README.md +141 -0
  34. package/plugins/extract-php-ddd/options.schema.json +50 -0
  35. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  36. package/plugins/extract-python-kafka/main.py +1 -0
  37. package/plugins/extract-terraform/options.schema.json +26 -0
  38. package/plugins/extract-ts/main.ts +1 -0
  39. package/plugins/phpscan/Cargo.lock +953 -0
  40. package/plugins/phpscan/Cargo.toml +19 -0
  41. package/plugins/phpscan/rustfmt.toml +1 -0
  42. package/plugins/portolan-go.wasm +0 -0
  43. package/plugins/pyplugin/catalog.py +24 -1
  44. package/plugins/verify-otel/options.schema.json +12 -0
  45. package/portolan.json +130 -3
  46. package/rules/builtin.json +218 -0
  47. package/schema/portolan.schema.json +880 -13
  48. package/scripts/README.md +3 -1
  49. package/scripts/delivery-presets.mjs +36 -3
  50. package/scripts/gen-likec4.mjs +149 -16
  51. package/scripts/gen-likec4.test.mjs +96 -0
  52. package/scripts/gen.mjs +30 -3
  53. package/scripts/gitops-example.test.mjs +108 -0
  54. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  55. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  56. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  57. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  58. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  59. package/scripts/host-plugins/fetch-git.mjs +1 -0
  60. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  61. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  62. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  63. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  64. package/scripts/json-format.mjs +192 -0
  65. package/scripts/json-format.test.mjs +97 -0
  66. package/scripts/local-api.mjs +216 -8
  67. package/scripts/local-api.test.mjs +88 -2
  68. package/scripts/local-discovery.mjs +12 -0
  69. package/scripts/manifest.mjs +10 -1
  70. package/scripts/plugin-host.mjs +23 -1
  71. package/scripts/plugins-fresh.mjs +97 -0
  72. package/scripts/plugins-fresh.test.mjs +64 -0
  73. package/scripts/run-builtin.mjs +26 -5
  74. package/scripts/schema.mjs +138 -19
  75. package/scripts/trace-trials.mjs +176 -0
  76. package/scripts/trace-trials.test.mjs +142 -0
  77. package/scripts/warning-policy.mjs +12 -6
  78. package/scripts/warning-policy.test.mjs +40 -3
  79. package/src/app/Breadcrumbs.test.ts +3 -0
  80. package/src/app/Breadcrumbs.tsx +3 -0
  81. package/src/app/CatalogApp.tsx +2 -0
  82. package/src/app/SidebarFooter.tsx +20 -4
  83. package/src/catalog-index.ts +25 -0
  84. package/src/catalog-model.ts +246 -1
  85. package/src/catalog-validation.ts +104 -2
  86. package/src/catalog.test.ts +40 -1
  87. package/src/components/ChannelRows.test.tsx +76 -1
  88. package/src/components/ChannelRows.tsx +223 -60
  89. package/src/components/DeploymentRows.tsx +131 -0
  90. package/src/components/DocsLinks.test.tsx +27 -0
  91. package/src/components/DocsLinks.tsx +56 -0
  92. package/src/components/FieldTree.tsx +5 -2
  93. package/src/components/HTTPDestinationEvidence.tsx +1 -1
  94. package/src/components/PluginIcon.tsx +77 -0
  95. package/src/components/ProblemRow.tsx +173 -153
  96. package/src/components/RuleMarks.tsx +22 -0
  97. package/src/components/ShapeRows.tsx +24 -20
  98. package/src/data.ts +21 -0
  99. package/src/enrich.test.ts +127 -3
  100. package/src/enrich.ts +104 -6
  101. package/src/er/ErCanvas.tsx +218 -12
  102. package/src/er/GroupNode.tsx +57 -0
  103. package/src/er/StoreHeader.tsx +1 -0
  104. package/src/er/layout.test.ts +85 -2
  105. package/src/er/layout.ts +140 -5
  106. package/src/er/spec.test.ts +17 -0
  107. package/src/er/spec.ts +23 -10
  108. package/src/flow/Recordings.test.tsx +52 -0
  109. package/src/flow/Recordings.tsx +236 -0
  110. package/src/flow/StepDetail.tsx +53 -0
  111. package/src/flow/TraceTrial.tsx +419 -0
  112. package/src/flow/examples.test.ts +33 -0
  113. package/src/flow/examples.ts +37 -0
  114. package/src/flow/outline.test.ts +28 -0
  115. package/src/flow/outline.ts +7 -2
  116. package/src/flow/trace-trial-resume.test.ts +42 -0
  117. package/src/flow/trace-trial-resume.ts +74 -0
  118. package/src/graph/elk.ts +78 -0
  119. package/src/index.css +24 -0
  120. package/src/landing/LandingPage.tsx +15 -11
  121. package/src/landing/ProductTour.tsx +6 -6
  122. package/src/lib/all-problems.ts +27 -17
  123. package/src/lib/centrality.test.ts +251 -0
  124. package/src/lib/centrality.ts +232 -0
  125. package/src/lib/confluence.test.ts +37 -0
  126. package/src/lib/confluence.ts +41 -0
  127. package/src/lib/context-color.ts +1 -1
  128. package/src/lib/deployment-drift.ts +22 -0
  129. package/src/lib/derive.ts +43 -69
  130. package/src/lib/environments.test.ts +83 -0
  131. package/src/lib/environments.ts +57 -0
  132. package/src/lib/integration-url.test.ts +30 -0
  133. package/src/lib/integration-url.ts +63 -0
  134. package/src/lib/kafka-ui.ts +3 -45
  135. package/src/lib/local-api.ts +96 -2
  136. package/src/lib/notion.ts +13 -0
  137. package/src/lib/plugin-index.json +2765 -0
  138. package/src/lib/plugins.test.ts +68 -0
  139. package/src/lib/plugins.ts +264 -0
  140. package/src/lib/problem-flows.test.ts +61 -0
  141. package/src/lib/problem-flows.ts +78 -0
  142. package/src/lib/problem-rules-cel.d.mts +43 -0
  143. package/src/lib/problem-rules-cel.mjs +407 -0
  144. package/src/lib/problem-rules.test.ts +287 -0
  145. package/src/lib/problem-rules.ts +271 -0
  146. package/src/lib/problem-subjects.ts +737 -0
  147. package/src/lib/rule-entries.ts +39 -0
  148. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  149. package/src/lib/rules-deploy.test.ts +133 -0
  150. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  151. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  152. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  153. package/src/lib/rules.test.ts +51 -0
  154. package/src/lib/rules.ts +86 -0
  155. package/src/lib/shape.test.ts +32 -0
  156. package/src/lib/shape.ts +30 -6
  157. package/src/lib/tech.ts +16 -0
  158. package/src/lib/trace-project.test.ts +34 -0
  159. package/src/lib/trace-project.ts +50 -0
  160. package/src/lib/use-problems.ts +23 -0
  161. package/src/lib/warnings.test.ts +9 -0
  162. package/src/lib/warnings.ts +10 -10
  163. package/src/likec4/ids.test.ts +6 -2
  164. package/src/likec4/ids.ts +43 -0
  165. package/src/main.tsx +23 -0
  166. package/src/merge-deployments.test.ts +127 -0
  167. package/src/merge.test.ts +82 -0
  168. package/src/merge.ts +173 -9
  169. package/src/pages/AggregatePage.tsx +57 -7
  170. package/src/pages/ContextMap.tsx +45 -3
  171. package/src/pages/ContextPage.tsx +2 -0
  172. package/src/pages/EventPage.tsx +15 -5
  173. package/src/pages/FlowDetail.tsx +23 -2
  174. package/src/pages/GraphPage.tsx +40 -3
  175. package/src/pages/Overview.tsx +152 -12
  176. package/src/pages/PluginIndex.tsx +190 -0
  177. package/src/pages/Problems.tsx +396 -128
  178. package/src/pages/ServicePage.tsx +58 -2
  179. package/src/pages/Settings.tsx +28 -2
  180. package/src/pages/settings/AboutSettings.tsx +8 -1
  181. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  182. package/src/pages/settings/RecordingSettings.tsx +138 -0
  183. package/src/pages/settings/RulesSettings.tsx +825 -0
  184. package/src/routes.test.ts +9 -0
  185. package/src/routes.ts +22 -1
  186. package/src/lib/data-problems.ts +0 -314
  187. package/src/lib/proto-problems.ts +0 -237
  188. package/src/lib/wire-problems.ts +0 -342
@@ -20,8 +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";
23
24
  import { djangoAggregateCandidates } from "../src/lib/django-aggregates.mjs";
24
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";
25
28
  import {
26
29
  discoverProject,
27
30
  matches,
@@ -311,7 +314,16 @@ function pluginOptions(plugin, project, detectedOptions = {}) {
311
314
  out: "project.json",
312
315
  };
313
316
  }
314
- 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)) {
315
327
  return { ...common, ...(project.repository ? { repo: repositoryParts(project.repository).web } : {}), ...detectedOptions, serviceName: project.name, out: "domain.json" };
316
328
  }
317
329
  if (plugin === "sql") return { ...common, store: "pg", ...detectedOptions, out: "stores.json" };
@@ -431,7 +443,7 @@ export function planProject(workspace, manifest, request) {
431
443
  };
432
444
  const out = posix.join(finalRoot, "portolan");
433
445
  const detectionByPlugin = new Map(discovery.detections.map((item) => [item.plugin, item]));
434
- 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));
435
447
  const projectDetectionOptions = {
436
448
  groupKind: splitDeployables ? "system" : hasDomainModel ? "bounded-context" : "system",
437
449
  ...(hasDomainModel ? { componentKind: "service" } : {}),
@@ -456,7 +468,7 @@ export function planProject(workspace, manifest, request) {
456
468
  if (!splitDeployables) {
457
469
  const options = plugin === "project"
458
470
  ? projectDetectionOptions
459
- : ["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)
460
472
  ? domainDetectionOptions(plugin)
461
473
  : detectionByPlugin.get(plugin)?.options;
462
474
  return [{ plugin, in: finalRoot, out, options: pluginOptions(plugin, project, options) }];
@@ -605,11 +617,19 @@ function projectRequestPlan(workspace, manifest, request) {
605
617
  return { base, plan, starter };
606
618
  }
607
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
+ */
608
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);
609
629
  const staging = mkdtempSync(join(dirname(path), ".portolan-manifest-"));
610
630
  const temp = join(staging, "portolan.json");
611
631
  try {
612
- writeFileSync(temp, `${JSON.stringify(manifest, null, 2)}\n`, { flag: "wx" });
632
+ writeFileSync(temp, `${text}\n`, { flag: "wx" });
613
633
  const validation = loadManifest(temp);
614
634
  if (validation.problems.length) throw new Error(validation.problems.join("\n"));
615
635
  renameSync(temp, path);
@@ -664,6 +684,40 @@ export function saveDjangoAggregates(workspace, request) {
664
684
  return { saved: request.selections.length };
665
685
  }
666
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
+
667
721
  export function writeProject(workspace, request) {
668
722
  const manifestPath = join(workspace, "portolan.json");
669
723
  const before = readFileSync(manifestPath, "utf8");
@@ -762,6 +816,18 @@ async function body(req) {
762
816
  return text ? JSON.parse(text) : {};
763
817
  }
764
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
+
765
831
  function localRequest(req) {
766
832
  const address = req.socket.remoteAddress ?? "";
767
833
  const localAddress = address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
@@ -993,6 +1059,65 @@ function prepareProjectTrial(workspace, request) {
993
1059
  return { ...snapshot, fingerprint, plan };
994
1060
  }
995
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
+
996
1121
  function freeLocalPort() {
997
1122
  return new Promise((resolvePort, reject) => {
998
1123
  const server = createNetServer();
@@ -1053,9 +1178,31 @@ async function startProjectPreview(job) {
1053
1178
  return job.previewUrl;
1054
1179
  }
1055
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
+
1056
1203
  function startJob(workspace, mode, approvedPreview, preparedTrial) {
1057
1204
  const id = randomUUID();
1058
- const preview = mode === "preview" || mode === "project-preview";
1205
+ const preview = mode === "preview" || mode === "project-preview" || mode === "trace-preview";
1059
1206
  const fingerprint = preparedTrial?.fingerprint ?? (preview ? workspaceFingerprint(workspace) : approvedPreview?.fingerprint);
1060
1207
  const snapshot = preparedTrial ?? (preview ? snapshotWorkspace(workspace) : null);
1061
1208
  if (preview && !preparedTrial && workspaceFingerprint(workspace) !== fingerprint) {
@@ -1064,13 +1211,21 @@ function startJob(workspace, mode, approvedPreview, preparedTrial) {
1064
1211
  }
1065
1212
  const generatedAt = preview ? new Date().toISOString() : approvedPreview?.generatedAt;
1066
1213
  const gitAuth = gitAuthEnvironment();
1067
- 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 };
1068
1215
  jobs.set(id, job);
1069
1216
  const cli = process.env.PORTOLAN_CLI;
1070
- 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";
1071
1224
  const args = cli
1072
1225
  ? [cli, mode === "check" ? "check" : "generate", "--cwd", job.runRoot]
1073
- : ["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"];
1074
1229
  let child;
1075
1230
  try {
1076
1231
  child = spawn(command, args, {
@@ -1107,6 +1262,18 @@ function startJob(workspace, mode, approvedPreview, preparedTrial) {
1107
1262
  }
1108
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)}` }); }
1109
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);
1110
1277
  emit(job, { type: "process-finished", status: job.status, code, signal });
1111
1278
  for (const response of job.subscribers) response.end();
1112
1279
  job.subscribers.clear();
@@ -1140,6 +1307,9 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
1140
1307
  if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/django-aggregates`) {
1141
1308
  return send(res, 200, djangoAggregateProposals(workspace));
1142
1309
  }
1310
+ if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/rules`) {
1311
+ return send(res, 200, problemRulesState(workspace));
1312
+ }
1143
1313
  if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/delivery-presets`) {
1144
1314
  const features = url.searchParams.has("features")
1145
1315
  ? url.searchParams.get("features").split(",").filter(Boolean)
@@ -1159,6 +1329,19 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
1159
1329
  req.on("close", () => job.subscribers.delete(res));
1160
1330
  return;
1161
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
+ }
1162
1345
  if (req.method !== "POST" || req.headers["content-type"]?.split(";")[0] !== "application/json" || req.headers["x-portolan-local"] !== "1") {
1163
1346
  return send(res, 405, { error: "Use a local JSON request." });
1164
1347
  }
@@ -1167,6 +1350,10 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
1167
1350
  if ([...jobs.values()].some((job) => job.status === "running")) throw new Error("Wait for the current generation to finish before saving aggregate roots.");
1168
1351
  return send(res, 200, saveDjangoAggregates(workspace, input));
1169
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
+ }
1170
1357
  if (url.pathname === `${LOCAL_API_PREFIX}/delivery-presets/install`) {
1171
1358
  return send(res, 201, installDeliveryPreset(workspace, input));
1172
1359
  }
@@ -1201,6 +1388,27 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
1201
1388
  disposeProjectTrial(trial);
1202
1389
  return send(res, 200, { runId: trial.id, status: "disposed" });
1203
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
+ }
1204
1412
  if (url.pathname === `${LOCAL_API_PREFIX}/projects/preview`) {
1205
1413
  const manifest = readManifest(join(workspace, "portolan.json"));
1206
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, workspaceFingerprint, 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");
@@ -771,3 +774,86 @@ describe("workspaceFingerprint", () => {
771
774
  expect(workspaceFingerprint(root)).toBe(unborn);
772
775
  });
773
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
+ });
@@ -387,6 +387,15 @@ function detectionsFor(root, files) {
387
387
  const tsDomain = files.has("package.json") ? laidOutDomainEvidence(root, files, "typescript") : "";
388
388
  const rustDomain = files.has("Cargo.toml") ? laidOutDomainEvidence(root, files, "rust") : "";
389
389
  const javaDomain = ["pom.xml", "build.gradle", "build.gradle.kts"].some((name) => files.has(name)) ? laidOutDomainEvidence(root, files, "java") : "";
390
+ // A Laravel application keeps its Eloquent models under app/Models, or
391
+ // under each package's src/Models when it is built from packages.
392
+ const laravelDomain = files.has("composer.json") ? matches(files, /^(?:app|packages\/[^/]+\/[^/]+\/src)\/Models\/[^/]+\.php$/)[0] ?? "" : "";
393
+ // A PHP tree laid out by bounded context keeps each module's model under
394
+ // src/<Context>/<Module>/Domain, with Shared beside the contexts.
395
+ const phpDdd = files.has("composer.json") ? matches(files, /^src\/(?!Shared\/)[^/]+\/(?!Shared\/)[^/]+\/Domain\/[^/]+\.php$/)[0] ?? "" : "";
396
+ // A .NET tree laid out by module keeps each module's model under
397
+ // src/Modules/<Module>/Domain, with the HTTP host under src/API.
398
+ const csharpDdd = matches(files, /^src\/Modules\/[^/]+\/Domain\/.+\.cs$/)[0] ?? "";
390
399
  return [
391
400
  detected("project", projectEvidence, {}, projectEvidence.join(", ")),
392
401
  detected("go-domain", goDomain ? [goDomain] : [], {}, goDomain),
@@ -394,6 +403,9 @@ function detectionsFor(root, files) {
394
403
  detected("rust-domain", rustDomain ? [rustDomain] : [], {}, rustDomain),
395
404
  detected("java-domain", javaDomain ? [javaDomain] : [], {}, javaDomain),
396
405
  detected("django-domain", files.has("manage.py") && matches(files, /(^|\/)models(?:\/[^/]+)?\.py$/i).length ? ["manage.py"] : []),
406
+ detected("laravel-domain", laravelDomain ? [laravelDomain] : [], {}, laravelDomain),
407
+ detected("php-ddd", phpDdd ? [phpDdd] : [], {}, phpDdd),
408
+ detected("csharp-ddd", csharpDdd ? [csharpDdd] : [], {}, csharpDdd),
397
409
  detected("celery", celery, {}, celery[0]),
398
410
  detected("openapi", openapi, openapi[0] ? { spec: openapi[0] } : {}, openapi[0], true),
399
411
  detected(
@@ -13,6 +13,12 @@ import { normalize } from "node:path";
13
13
 
14
14
  import Ajv from "ajv/dist/2020.js";
15
15
  import { warningPolicyProblems } from "./warning-policy.mjs";
16
+ import { problemRuleProblems } from "../src/lib/problem-rules-cel.mjs";
17
+
18
+ // The rules that have a reader, by id, so that an entry naming one is read as
19
+ // a switch and any other as a rule of its own. Read once, like the schema:
20
+ // the file is part of the package, and a manifest cannot add to it.
21
+ const builtinRuleIds = () => JSON.parse(readFileSync(new URL("../rules/builtin.json", import.meta.url), "utf8")).map((rule) => rule.id);
16
22
 
17
23
  // Read when asked, not when loaded: the CLI sets PORTOLAN_SCHEMA after its imports.
18
24
  const schemaFile = () => process.env.PORTOLAN_SCHEMA || "schema/portolan.schema.json";
@@ -39,7 +45,10 @@ export function loadManifest(path = "portolan.json") {
39
45
  */
40
46
  export function parseManifest(text, path = "portolan.json") {
41
47
  const manifest = JSON.parse(text);
42
- const policyProblems = warningPolicyProblems(manifest.warningPolicies, path);
48
+ const policyProblems = [
49
+ ...warningPolicyProblems(manifest.warningPolicies, path),
50
+ ...problemRuleProblems(manifest.problemRules, builtinRuleIds(), path),
51
+ ];
43
52
 
44
53
  let schema;
45
54
  try {
@@ -90,7 +90,7 @@ export async function runPlugin(plugin, request, requestedLimits = {}, access =
90
90
  // can be named, so a manifest cannot point the host at arbitrary code.
91
91
  // ---------------------------------------------------------------------------
92
92
 
93
- const HOST_PLUGINS = new Set(["fetch-git", "fetch-bsr", "fetch-csr"]);
93
+ const HOST_PLUGINS = new Set(["fetch-git", "fetch-bsr", "fetch-csr", "fetch-k8s", "fetch-argocd"]);
94
94
 
95
95
  async function runHost(plugin, request) {
96
96
  const name = String(plugin.host);
@@ -215,6 +215,25 @@ function safeFileName(name) {
215
215
  return parts.every((part) => part !== "" && part !== "." && part !== "..");
216
216
  }
217
217
 
218
+ /**
219
+ * What a plugin reads, or what it makes - the groups of the plugin index. A
220
+ * phase says where in a run a plugin goes; the category says what kind of
221
+ * fact it is after, which is the question a reader choosing extractors asks.
222
+ * Mirrors the Category constants in plugin/describe.go.
223
+ */
224
+ export const PLUGIN_CATEGORIES = [
225
+ "code",
226
+ "contracts",
227
+ "messaging",
228
+ "data",
229
+ "infrastructure",
230
+ "repository",
231
+ "documents",
232
+ "evidence",
233
+ "sources",
234
+ "exports",
235
+ ];
236
+
218
237
  function validateDescriptor(pluginName, descriptor) {
219
238
  if (!descriptor || typeof descriptor !== "object" || Array.isArray(descriptor)) {
220
239
  throw new Error(`plugin ${pluginName}: describe is not an object`);
@@ -225,6 +244,9 @@ function validateDescriptor(pluginName, descriptor) {
225
244
  if (typeof descriptor.summary !== "string") {
226
245
  throw new Error(`plugin ${pluginName}: describe.summary is missing`);
227
246
  }
247
+ if (!PLUGIN_CATEGORIES.includes(descriptor.category)) {
248
+ throw new Error(`plugin ${pluginName}: describe.category must be one of ${PLUGIN_CATEGORIES.join(", ")}`);
249
+ }
228
250
  if (!Array.isArray(descriptor.phases) || descriptor.phases.some((phase) => !["extract", "verify", "generate"].includes(phase))) {
229
251
  throw new Error(`plugin ${pluginName}: describe.phases is invalid`);
230
252
  }
@@ -0,0 +1,97 @@
1
+ // Says whether the built plugins are newer than their sources, so that a run
2
+ // started from the page can skip building them.
3
+ //
4
+ // `npm run gen` builds the wasm and the Java extractor first, every time,
5
+ // which is right for a checkout somebody just changed and wrong for the
6
+ // fourth trial of a recording in a row: a minute of javac and the Go
7
+ // toolchain to learn nothing changed. The page asks here first and runs the
8
+ // generator alone when the answer is yes. Anything doubtful - a missing
9
+ // artefact, a source newer than it, a tree that cannot be read - is "no",
10
+ // and the build runs as it always did.
11
+
12
+ import { readdirSync, statSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ /** Where the Go sources the wasm is built from live, relative to the workspace. */
16
+ const GO_ROOTS = ["plugins", "catalog", "plugin", "internal", "go.mod", "go.sum"];
17
+ const GO_WASM = "plugins/portolan-go.wasm";
18
+ const JAVA_SOURCES = "plugins/extract-java/src";
19
+ const JAVA_BUILD = "plugins/extract-java/build";
20
+
21
+ /** Directories under a source root that hold no sources of ours. */
22
+ const SKIP = new Set(["node_modules", "target", "build", "testdata", ".git", "vendor"]);
23
+
24
+ /**
25
+ * The newest modification time under a path, counting only files whose
26
+ * name passes `keep`, or -1 when nothing does. A path that cannot be read is
27
+ * treated as newer than anything, which sends the caller down the safe road.
28
+ */
29
+ export function newestMtime(root, keep) {
30
+ let newest = -1;
31
+ const pending = [root];
32
+ while (pending.length) {
33
+ const path = pending.pop();
34
+ let stat;
35
+ try {
36
+ stat = statSync(path);
37
+ } catch {
38
+ continue;
39
+ }
40
+ if (stat.isDirectory()) {
41
+ let entries;
42
+ try {
43
+ entries = readdirSync(path, { withFileTypes: true });
44
+ } catch {
45
+ return Number.POSITIVE_INFINITY;
46
+ }
47
+ for (const entry of entries) {
48
+ if (entry.isDirectory() && SKIP.has(entry.name)) continue;
49
+ pending.push(join(path, entry.name));
50
+ }
51
+ } else if (keep(path)) {
52
+ newest = Math.max(newest, stat.mtimeMs);
53
+ }
54
+ }
55
+ return newest;
56
+ }
57
+
58
+ function oldestArtefact(root, keep) {
59
+ let oldest = Number.POSITIVE_INFINITY;
60
+ let any = false;
61
+ const pending = [root];
62
+ while (pending.length) {
63
+ const path = pending.pop();
64
+ let stat;
65
+ try {
66
+ stat = statSync(path);
67
+ } catch {
68
+ continue;
69
+ }
70
+ if (stat.isDirectory()) {
71
+ for (const entry of readdirSync(path, { withFileTypes: true })) pending.push(join(path, entry.name));
72
+ } else if (keep(path)) {
73
+ any = true;
74
+ oldest = Math.min(oldest, stat.mtimeMs);
75
+ }
76
+ }
77
+ return any ? oldest : -1;
78
+ }
79
+
80
+ /**
81
+ * Whether every built plugin is at least as new as every source it is built
82
+ * from. `true` means `node scripts/gen.mjs` can run on its own; anything
83
+ * else means `npm run gen`, which builds first.
84
+ */
85
+ export function pluginsFresh(workspace) {
86
+ const wasm = oldestArtefact(join(workspace, GO_WASM), () => true);
87
+ if (wasm < 0) return false;
88
+ const goSources = Math.max(
89
+ ...GO_ROOTS.map((root) => newestMtime(join(workspace, root), (path) => /\.(go|mod|sum)$/.test(path))),
90
+ );
91
+ if (!(goSources <= wasm)) return false;
92
+
93
+ const classes = oldestArtefact(join(workspace, JAVA_BUILD), (path) => path.endsWith(".class"));
94
+ if (classes < 0) return false;
95
+ const javaSources = newestMtime(join(workspace, JAVA_SOURCES), (path) => path.endsWith(".java"));
96
+ return javaSources <= classes;
97
+ }