@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
@@ -169,7 +169,7 @@ function goDeployables(root, files) {
169
169
  return out;
170
170
  }
171
171
 
172
- function detected(plugin, candidates, options = {}, label = candidates[0], ambiguous = false, selected = true) {
172
+ function detected(plugin, candidates, options = {}, label = candidates[0], ambiguous = false, selected = true, preview = []) {
173
173
  if (!candidates.length) return null;
174
174
  return {
175
175
  plugin,
@@ -178,15 +178,74 @@ function detected(plugin, candidates, options = {}, label = candidates[0], ambig
178
178
  candidates,
179
179
  options,
180
180
  selected,
181
+ ...(preview.length ? { preview } : {}),
182
+ };
183
+ }
184
+
185
+ const ADR_STATUSES = new Map([
186
+ ["proposed", "proposed"], ["draft", "proposed"], ["pending", "proposed"], ["на рассмотрении", "proposed"],
187
+ ["accepted", "accepted"], ["approved", "accepted"], ["adopted", "accepted"], ["принято", "accepted"], ["принят", "accepted"],
188
+ ["superseded", "superseded"], ["заменено", "superseded"], ["заменён", "superseded"],
189
+ ["deprecated", "deprecated"], ["obsolete", "deprecated"], ["устарело", "deprecated"],
190
+ ["rejected", "rejected"], ["declined", "rejected"], ["отклонено", "rejected"],
191
+ ]);
192
+
193
+ function normalizedAdrStatus(value) {
194
+ return ADR_STATUSES.get(value.trim().replace(/[.:]+$/, "").toLowerCase()) ?? "";
195
+ }
196
+
197
+ // Discovery mirrors the tolerant shapes accepted by extract-adr closely
198
+ // enough to enable the capability with confidence and to show what it found.
199
+ // Git-backed dates are resolved by the extractor, so a format without an
200
+ // explicit Date can still be previewed here without inventing one.
201
+ function adrPreview(root, name) {
202
+ let source = "";
203
+ try { source = readFileSync(join(root, name), "utf8").replaceAll("\r\n", "\n"); } catch { return null; }
204
+ const lines = source.split("\n");
205
+ const heading = lines.find((line) => line.trim()) ?? "";
206
+ const base = posix.basename(name, ".md");
207
+ const numberedFile = /^(\d+)-([a-z0-9]+(?:-[a-z0-9]+)*)$/.exec(base);
208
+
209
+ let number = "";
210
+ let title = "";
211
+ let match = /^#\s+[a-z][a-z0-9.-]*\.(\d{4})\s+—\s+(.+?)\s*$/i.exec(heading);
212
+ if (match) [number, title] = match.slice(1);
213
+ else if ((match = /^#\s+(\d+)\.\s+(.+?)\s*$/.exec(heading))) [number, title] = match.slice(1);
214
+ else if ((match = /^#\s+ADR[-\s]?0*(\d+)\s*[.:—-]\s*(.+?)\s*$/i.exec(heading))) [number, title] = match.slice(1);
215
+ else if (numberedFile && (match = /^#\s+(.+?)\s*$/.exec(heading))) {
216
+ number = numberedFile[1];
217
+ title = match[1];
218
+ }
219
+ if (!number || !title || !numberedFile || Number(numberedFile[1]) !== Number(number)) return null;
220
+ if (!lines.some((line) => /^#{2,6}\s/.test(line))) return null;
221
+
222
+ let status = "";
223
+ const bullet = /^-\s+\*\*Status:\*\*\s*(.*?)\s*$/mi.exec(source);
224
+ if (bullet) status = normalizedAdrStatus(bullet[1]);
225
+ if (!status) {
226
+ const at = lines.findIndex((line) => /^#{2,6}\s+(?:Status|Статус)\s*:?/i.test(line));
227
+ if (at >= 0) {
228
+ const inline = /^#{2,6}\s+(?:Status|Статус)\s*:?\s*(.*?)\s*$/i.exec(lines[at])?.[1] ?? "";
229
+ const following = lines.slice(at + 1).find((line) => line.trim() && !/^#{1,6}\s/.test(line)) ?? "";
230
+ status = normalizedAdrStatus(inline || following);
231
+ }
232
+ }
233
+ if (!status) return null;
234
+
235
+ const writtenDate = /^(?:-\s+\*\*Date:\*\*|Date:)\s*(\S.*?)\s*$/mi.exec(source)?.[1];
236
+ if (writtenDate) {
237
+ const stamp = new Date(`${writtenDate}T00:00:00Z`);
238
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(writtenDate) || Number.isNaN(stamp.getTime()) || stamp.toISOString().slice(0, 10) !== writtenDate) return null;
239
+ }
240
+ const date = writtenDate ?? "from git history";
241
+ return {
242
+ file: name,
243
+ fields: { number: String(Number(number)), title: title.trim(), status, date },
181
244
  };
182
245
  }
183
246
 
184
247
  function compatibleAdrs(root, candidates) {
185
- return candidates.filter((name) => {
186
- let source = "";
187
- try { source = readFileSync(join(root, name), "utf8"); } catch { return false; }
188
- return /^#\s+[^\n]+\.\d{4}\s+[—-]/m.test(source) && /^-\s+\*\*Status:\*\*/mi.test(source) && /^-\s+\*\*Date:\*\*/mi.test(source);
189
- });
248
+ return candidates.map((name) => adrPreview(root, name)).filter(Boolean);
190
249
  }
191
250
 
192
251
  function goDomainEvidence(root, files) {
@@ -211,6 +270,18 @@ function goDomainEvidence(root, files) {
211
270
  return "";
212
271
  }
213
272
 
273
+ // This is a discovery hint; the Go AST extractor proves the handler binding.
274
+ function goHTTPServerEvidence(root, files) {
275
+ for (const name of matches(files, /\.go$/).filter((name) => !/(?:_test|\.gen|_generated)\.go$/.test(name) && !/(?:^|\/)(?:testdata|vendor)\//.test(name))) {
276
+ let source;
277
+ try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
278
+ if (/Code generated .*DO NOT EDIT/.test(source)) continue;
279
+ if (/"net\/http"/.test(source) && /\.Handle(?:Func)?\s*\(/.test(source)) return name;
280
+ if (/"github\.com\/(?:go-chi\/chi|gin-gonic\/gin|labstack\/echo)(?:\/v\d+)?"/.test(source) && /\.(?:Get|Post|Put|Patch|Delete|GET|POST|PUT|PATCH|DELETE)\s*\(/.test(source)) return name;
281
+ }
282
+ return "";
283
+ }
284
+
214
285
  function laidOutDomainEvidence(root, files, language) {
215
286
  const extension = language === "typescript" ? "ts" : language === "rust" ? "rs" : "java";
216
287
  const prefix = language === "java" ? /(?:^|\/)domain\/([^/]+)\/[^/]+\.java$/i : /^src\/domain\/([^/]+)\/[^/]+\.(?:ts|rs)$/i;
@@ -291,8 +362,9 @@ function detectionsFor(root, files) {
291
362
  const graphql = matches(files, /\.graphqls?$/i);
292
363
  const protos = matches(files, /\.proto$/i);
293
364
  const sql = matches(files, /(^|\/)(migrations?|repository)(\/|.*\/).*\.sql$/i);
294
- const adrs = matches(files, /(^|\/)(docs\/adr|adr)\/.*\.md$/i);
295
- const supportedAdrs = compatibleAdrs(root, adrs);
365
+ const adrs = matches(files, /(^|\/)(docs\/adr|adr)\/.*\.md$/i).filter((name) => posix.basename(name).toLowerCase() !== "readme.md");
366
+ const adrPreviews = compatibleAdrs(root, adrs);
367
+ const supportedAdrs = adrPreviews.map((item) => item.file);
296
368
  const glossaries = matches(files, /(^|\/)glossary\.md$/i);
297
369
  // The app module is the one file a Celery project always has; the tasks
298
370
  // and the calls that enqueue them are found from there.
@@ -308,13 +380,22 @@ function detectionsFor(root, files) {
308
380
  const protoDirs = compactDirectories(protos);
309
381
  const projectMarkers = ["go.mod", "package.json", "Cargo.toml", "pom.xml", "build.gradle", "build.gradle.kts", "manage.py", "Dockerfile", "README.md"].filter((name) => files.has(name));
310
382
  const projectEvidence = projectMarkers.length ? projectMarkers : [[...files].sort()[0]].filter(Boolean);
311
- const goDomain = files.has("go.mod") ? goDomainEvidence(root, files) : "";
383
+ const goDomain = files.has("go.mod") ? goDomainEvidence(root, files) || goHTTPServerEvidence(root, files) : "";
312
384
  const goHTTPClient = files.has("go.mod") ? goHTTPClientEvidence(root, files) : "";
313
385
  const goSOAPClient = files.has("go.mod") ? goSOAPClientEvidence(root, files) : "";
314
386
  const goRedis = files.has("go.mod") ? goRedisEvidence(root, files) : "";
315
387
  const tsDomain = files.has("package.json") ? laidOutDomainEvidence(root, files, "typescript") : "";
316
388
  const rustDomain = files.has("Cargo.toml") ? laidOutDomainEvidence(root, files, "rust") : "";
317
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] ?? "";
318
399
  return [
319
400
  detected("project", projectEvidence, {}, projectEvidence.join(", ")),
320
401
  detected("go-domain", goDomain ? [goDomain] : [], {}, goDomain),
@@ -322,6 +403,9 @@ function detectionsFor(root, files) {
322
403
  detected("rust-domain", rustDomain ? [rustDomain] : [], {}, rustDomain),
323
404
  detected("java-domain", javaDomain ? [javaDomain] : [], {}, javaDomain),
324
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),
325
409
  detected("celery", celery, {}, celery[0]),
326
410
  detected("openapi", openapi, openapi[0] ? { spec: openapi[0] } : {}, openapi[0], true),
327
411
  detected(
@@ -349,6 +433,7 @@ function detectionsFor(root, files) {
349
433
  supportedAdrs[0] ? `${posix.dirname(supportedAdrs[0])}/*.md` : `${posix.dirname(adrs[0] ?? "docs/adr/x.md")}/*.md (format not recognized)`,
350
434
  !supportedAdrs.length,
351
435
  supportedAdrs.length > 0,
436
+ adrPreviews,
352
437
  ),
353
438
  detected("glossary", glossaries, glossaries.length ? { files: glossaries } : {}, glossaries.join(", ")),
354
439
  ].filter(Boolean);
@@ -12,6 +12,13 @@ import { readFileSync } from "node:fs";
12
12
  import { normalize } from "node:path";
13
13
 
14
14
  import Ajv from "ajv/dist/2020.js";
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);
15
22
 
16
23
  // Read when asked, not when loaded: the CLI sets PORTOLAN_SCHEMA after its imports.
17
24
  const schemaFile = () => process.env.PORTOLAN_SCHEMA || "schema/portolan.schema.json";
@@ -38,20 +45,24 @@ export function loadManifest(path = "portolan.json") {
38
45
  */
39
46
  export function parseManifest(text, path = "portolan.json") {
40
47
  const manifest = JSON.parse(text);
48
+ const policyProblems = [
49
+ ...warningPolicyProblems(manifest.warningPolicies, path),
50
+ ...problemRuleProblems(manifest.problemRules, builtinRuleIds(), path),
51
+ ];
41
52
 
42
53
  let schema;
43
54
  try {
44
55
  schema = JSON.parse(readFileSync(schemaFile(), "utf8"));
45
56
  } catch {
46
- return { manifest, problems: [] };
57
+ return { manifest, problems: policyProblems };
47
58
  }
48
59
 
49
60
  const ajv = new Ajv({ allErrors: true, strictSchema: false });
50
61
  const validate = ajv.compile(schema);
51
62
 
52
- if (validate(manifest)) return { manifest, problems: [] };
63
+ if (validate(manifest)) return { manifest, problems: policyProblems };
53
64
 
54
- return { manifest, problems: explain(validate.errors ?? [], manifest, schema, path) };
65
+ return { manifest, problems: [...explain(validate.errors ?? [], manifest, schema, path), ...policyProblems] };
55
66
  }
56
67
 
57
68
  /**
@@ -58,6 +58,30 @@ describe("the manifest schema", () => {
58
58
  expect(check(good)).toEqual([]);
59
59
  });
60
60
 
61
+ it("accepts a typed CEL warning policy only with an action and reason", () => {
62
+ expect(check({
63
+ ...good,
64
+ warningPolicies: [{
65
+ when: "plugin == 'openapi' && rule == 'openapi.missing-operation-id' && count > 10",
66
+ action: "suppress",
67
+ reason: "The contract is owned upstream.",
68
+ }],
69
+ })).toEqual([]);
70
+
71
+ const problems = check({
72
+ ...good,
73
+ warningPolicies: [{ when: "plugin == 'openapi'", action: "suppress" }],
74
+ });
75
+ expect(problems.join("\n")).toContain('warningPolicies/0: "reason" is missing');
76
+ });
77
+
78
+ it("refuses a CEL warning policy with unknown variables or a non-boolean result", () => {
79
+ expect(check({ ...good, warningPolicies: [{ when: "owner == 'team'", action: "suppress", reason: "test" }] }).join("\n"))
80
+ .toContain("Unknown variable: owner");
81
+ expect(check({ ...good, warningPolicies: [{ when: "plugin", action: "suppress", reason: "test" }] }).join("\n"))
82
+ .toContain("CEL expression must return bool");
83
+ });
84
+
61
85
  it("refuses an unstable project id", () => {
62
86
  const problems = check({
63
87
  ...good,
@@ -0,0 +1,94 @@
1
+ // Why a generated file is not what the generator produces now, in one line.
2
+ //
3
+ // `gen --check` used to say "changed docs/x.md" and nothing else, which sent
4
+ // the reader to a diff of the whole file to learn that one number moved.
5
+ // The first difference is usually the whole story: a JSON fragment names the
6
+ // path that differs and both values, a page names the line.
7
+
8
+ /**
9
+ * @param {string | Buffer | null} current what is on disk, null when absent
10
+ * @param {string | Buffer} wanted what the generator produced
11
+ * @param {string} name the file's name, for its format
12
+ * @returns {string}
13
+ */
14
+ export function explainChange(current, wanted, name) {
15
+ if (current === null) return "not on disk yet";
16
+ if (Buffer.isBuffer(current) || Buffer.isBuffer(wanted)) return "binary contents differ";
17
+ if (name.endsWith(".json")) {
18
+ const found = jsonDifference(current, wanted);
19
+ if (found) return found;
20
+ }
21
+ return lineDifference(current, wanted);
22
+ }
23
+
24
+ /** The first path where two JSON documents disagree, or "" when either does not parse. */
25
+ export function jsonDifference(current, wanted) {
26
+ let a;
27
+ let b;
28
+ try {
29
+ a = JSON.parse(current);
30
+ b = JSON.parse(wanted);
31
+ } catch {
32
+ return "";
33
+ }
34
+ return firstDifference(a, b, "") ?? "same value, different formatting";
35
+ }
36
+
37
+ function firstDifference(a, b, path) {
38
+ if (Array.isArray(a) && Array.isArray(b)) {
39
+ const shared = Math.min(a.length, b.length);
40
+ for (let i = 0; i < shared; i++) {
41
+ const found = firstDifference(a[i], b[i], `${path}[${i}]`);
42
+ if (found) return found;
43
+ }
44
+ if (a.length !== b.length) {
45
+ return `${path || "the document"}: ${a.length} item${a.length === 1 ? "" : "s"} → ${b.length}`;
46
+ }
47
+ return null;
48
+ }
49
+ if (isObject(a) && isObject(b)) {
50
+ for (const key of Object.keys(b)) {
51
+ const at = path ? `${path}.${key}` : key;
52
+ if (!(key in a)) return `${at}: added ${short(b[key])}`;
53
+ const found = firstDifference(a[key], b[key], at);
54
+ if (found) return found;
55
+ }
56
+ for (const key of Object.keys(a)) {
57
+ if (!(key in b)) return `${path ? `${path}.${key}` : key}: removed`;
58
+ }
59
+ return null;
60
+ }
61
+ if (Object.is(a, b)) return null;
62
+ return `${path || "the document"}: ${short(a)} → ${short(b)}`;
63
+ }
64
+
65
+ /** The first line that differs, with both sides, or the extra lines when one is a prefix of the other. */
66
+ export function lineDifference(current, wanted) {
67
+ const a = current.split("\n");
68
+ const b = wanted.split("\n");
69
+ const shared = Math.min(a.length, b.length);
70
+ for (let i = 0; i < shared; i++) {
71
+ if (a[i] !== b[i]) return `line ${i + 1}: ${short(a[i])} → ${short(b[i])}`;
72
+ }
73
+ if (a.length === b.length) return "identical";
74
+ const extra = Math.abs(a.length - b.length);
75
+ return a.length < b.length
76
+ ? `line ${shared + 1}: ${extra} line${extra === 1 ? "" : "s"} added`
77
+ : `line ${shared + 1}: ${extra} line${extra === 1 ? "" : "s"} removed`;
78
+ }
79
+
80
+ function isObject(value) {
81
+ return typeof value === "object" && value !== null && !Array.isArray(value);
82
+ }
83
+
84
+ /** A value as one short token: strings quoted, structures summarised. */
85
+ function short(value) {
86
+ const text = typeof value === "string" ? JSON.stringify(value) : isObject(value) || Array.isArray(value) ? summarise(value) : String(value);
87
+ return text.length > 60 ? `${text.slice(0, 57)}…` : text;
88
+ }
89
+
90
+ function summarise(value) {
91
+ if (Array.isArray(value)) return `[${value.length} item${value.length === 1 ? "" : "s"}]`;
92
+ const keys = Object.keys(value);
93
+ return `{${keys.slice(0, 3).join(", ")}${keys.length > 3 ? ", …" : ""}}`;
94
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { explainChange, jsonDifference, lineDifference } from "./output-diff.mjs";
4
+
5
+ describe("explainChange", () => {
6
+ it("names the JSON path and both values", () => {
7
+ const current = JSON.stringify({ contexts: [{ services: [{ commands: [{ source: "package.json:58" }] }] }] });
8
+ const wanted = JSON.stringify({ contexts: [{ services: [{ commands: [{ source: "package.json:59" }] }] }] });
9
+ expect(explainChange(current, wanted, "commands.json")).toBe(
10
+ 'contexts[0].services[0].commands[0].source: "package.json:58" → "package.json:59"',
11
+ );
12
+ });
13
+
14
+ it("says what was added, what was removed, and when a list grew", () => {
15
+ expect(jsonDifference('{"a":1}', '{"a":1,"b":{"x":1,"y":2}}')).toBe("b: added {x, y}");
16
+ expect(jsonDifference('{"a":1,"b":2}', '{"a":1}')).toBe("b: removed");
17
+ expect(jsonDifference('{"flows":[1]}', '{"flows":[1,2,3]}')).toBe("flows: 1 item → 3");
18
+ });
19
+
20
+ it("falls back to lines for text, and for JSON that does not parse", () => {
21
+ expect(lineDifference("a\nb\nc", "a\nB\nc")).toBe('line 2: "b" → "B"');
22
+ expect(lineDifference("a\nb", "a\nb\nc\nd")).toBe("line 3: 2 lines added");
23
+ expect(lineDifference("a\nb\nc", "a")).toBe("line 2: 2 lines removed");
24
+ expect(explainChange("{not json", "{still not", "x.json")).toBe('line 1: "{not json" → "{still not"');
25
+ });
26
+
27
+ it("knows a file that is not there yet, and a binary one", () => {
28
+ expect(explainChange(null, "x", "x.md")).toBe("not on disk yet");
29
+ expect(explainChange(Buffer.from([1]), Buffer.from([2]), "x.png")).toBe("binary contents differ");
30
+ });
31
+
32
+ it("keeps a long value readable", () => {
33
+ const long = "x".repeat(200);
34
+ expect(jsonDifference('{"a":"short"}', `{"a":"${long}"}`)).toMatch(/^a: "short" → "x{56}…$/);
35
+ });
36
+ });
@@ -2,9 +2,9 @@
2
2
  // This catches accidental cwd coupling before the same package reaches npm.
3
3
 
4
4
  import { execFileSync } from "node:child_process";
5
- import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
6
6
  import { tmpdir } from "node:os";
7
- import { resolve } from "node:path";
7
+ import { dirname, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
 
10
10
  const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
@@ -31,9 +31,58 @@ try {
31
31
  writeFileSync(resolve(fixture, "package.json"), '{"name":"package-smoke","version":"1.0.0"}\n');
32
32
  writeFileSync(resolve(fixture, "README.md"), "# Package smoke\n");
33
33
  // A Go domain layout: init should notice it and wire the extractor without being asked.
34
- writeFileSync(resolve(fixture, "go.mod"), "module example.com/smoke\n\ngo 1.24\n");
34
+ writeFileSync(resolve(fixture, "go.mod"), "module example.com/smoke\n\ngo 1.27.0\n");
35
35
  mkdirSync(resolve(fixture, "internal/domain/order"), { recursive: true });
36
36
  writeFileSync(resolve(fixture, "internal/domain/order/order.go"), "package order\n\ntype Order struct{ ID string }\n");
37
+ writeGoFixture("app/main.go", `package app
38
+ import (
39
+ "example.com/smoke/actions/rules"
40
+ "example.com/smoke/connector"
41
+ )
42
+ type Router struct{}
43
+ func (*Router) POST(string, func()) {}
44
+ type Requester interface { ConnExec(connector.API) }
45
+ func Start(r *Router) { r.POST("/rules", RulesAction) }
46
+ func RulesAction() {
47
+ request := &rules.Request{}
48
+ Dispatch(request)
49
+ }
50
+ func Dispatch(request Requester) { Invoke(request) }
51
+ func Invoke(request Requester) {
52
+ conn := connector.Build("runtime")
53
+ request.ConnExec(conn)
54
+ }
55
+ `);
56
+ writeGoFixture("actions/rules/request.go", `package rules
57
+ import "example.com/smoke/connector"
58
+ type Request struct{}
59
+ func (*Request) ConnExec(conn connector.API) { conn.Rules() }
60
+ `);
61
+ writeGoFixture("connector/factory.go", `package connector
62
+ import "example.com/smoke/provider/alpha"
63
+ type API interface { Rules() }
64
+ func Build(name string) API {
65
+ switch name { case "alpha": return alpha.New(); default: return nil }
66
+ }
67
+ `);
68
+ writeGoFixture("provider/alpha/connector.go", `package alpha
69
+ import "example.com/smoke/provider/alpha/client"
70
+ type rulesClient interface { FetchRules() }
71
+ type Connector struct { client rulesClient }
72
+ func New() *Connector { return &Connector{client: &client.Client{}} }
73
+ func (c *Connector) Rules() { c.client.FetchRules() }
74
+ `);
75
+ writeGoFixture("provider/alpha/client/client.go", `package client
76
+ import "net/http"
77
+ type Client struct{}
78
+ func (c *Client) FetchRules() { c.fetchRules() }
79
+ func (c *Client) fetchRules() { c.finishRules() }
80
+ func (c *Client) finishRules() {
81
+ _, _ = http.Get("https://alpha.example/v1/rules")
82
+ if false { c.fetchRules() }
83
+ }
84
+ func (*Client) CheckRules() { _, _ = http.Get("https://alpha.example/v1/check-rules") }
85
+ `);
37
86
  run("git", ["init", "--quiet"]);
38
87
  run("git", ["config", "user.email", "portolan@example.invalid"]);
39
88
  run("git", ["config", "user.name", "Portolan smoke test"]);
@@ -44,9 +93,13 @@ try {
44
93
  run(process.execPath, [cli, "check", "--cwd", fixture]);
45
94
  run(process.execPath, [cli, "build", "--cwd", fixture, "--output", "dist", "--base", "/architecture/"]);
46
95
 
47
- for (const path of ["portolan/project.json", "portolan/domain.json", "docs/README.md", "dist/index.html", "dist/404.html"]) {
96
+ for (const path of ["portolan/project.json", "portolan/domain.json", "portolan/http-clients.json", "docs/README.md", "dist/index.html", "dist/404.html"]) {
48
97
  if (!existsSync(resolve(fixture, path))) throw new Error(`smoke test did not write ${path}`);
49
98
  }
99
+ const calls = JSON.parse(readFileSync(resolve(fixture, "portolan/http-clients.json"), "utf8"));
100
+ if (!calls.flows.some((flow) => flow.name === "POST /rules → provider APIs")) {
101
+ throw new Error(`package smoke did not preserve the typed HTTP provider flow; got ${calls.flows.map((flow) => flow.name).join(", ")}`);
102
+ }
50
103
  console.log("package smoke: init, generate, check, and build passed outside the repository");
51
104
  } finally {
52
105
  rmSync(fixture, { recursive: true, force: true });
@@ -56,3 +109,8 @@ try {
56
109
  function run(command, args) {
57
110
  execFileSync(command, args, { cwd: fixture, stdio: "inherit" });
58
111
  }
112
+
113
+ function writeGoFixture(path, contents) {
114
+ mkdirSync(dirname(resolve(fixture, path)), { recursive: true });
115
+ writeFileSync(resolve(fixture, path), contents);
116
+ }
@@ -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);
@@ -185,8 +185,28 @@ export function validateResponse(name, response) {
185
185
  }
186
186
 
187
187
  function validBase64(value) {
188
- return value.length % 4 === 0
189
- && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
188
+ if (value.length % 4 !== 0) return false;
189
+
190
+ let payloadLength = value.length;
191
+ if (value.endsWith("==")) payloadLength -= 2;
192
+ else if (value.endsWith("=")) payloadLength -= 1;
193
+
194
+ // A repeated-group regexp over a multi-megabyte binary can exhaust V8's
195
+ // regexp stack. Validate the same alphabet iteratively so vendoring a large
196
+ // file remains bounded by its bytes rather than the JavaScript call stack.
197
+ for (let index = 0; index < payloadLength; index += 1) {
198
+ const code = value.charCodeAt(index);
199
+ const alphabet = code >= 65 && code <= 90
200
+ || code >= 97 && code <= 122
201
+ || code >= 48 && code <= 57
202
+ || code === 43
203
+ || code === 47;
204
+ if (!alphabet) return false;
205
+ }
206
+ for (let index = payloadLength; index < value.length; index += 1) {
207
+ if (value.charCodeAt(index) !== 61) return false;
208
+ }
209
+ return true;
190
210
  }
191
211
 
192
212
  function safeFileName(name) {
@@ -195,6 +215,25 @@ function safeFileName(name) {
195
215
  return parts.every((part) => part !== "" && part !== "." && part !== "..");
196
216
  }
197
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
+
198
237
  function validateDescriptor(pluginName, descriptor) {
199
238
  if (!descriptor || typeof descriptor !== "object" || Array.isArray(descriptor)) {
200
239
  throw new Error(`plugin ${pluginName}: describe is not an object`);
@@ -205,6 +244,9 @@ function validateDescriptor(pluginName, descriptor) {
205
244
  if (typeof descriptor.summary !== "string") {
206
245
  throw new Error(`plugin ${pluginName}: describe.summary is missing`);
207
246
  }
247
+ if (!PLUGIN_CATEGORIES.includes(descriptor.category)) {
248
+ throw new Error(`plugin ${pluginName}: describe.category must be one of ${PLUGIN_CATEGORIES.join(", ")}`);
249
+ }
208
250
  if (!Array.isArray(descriptor.phases) || descriptor.phases.some((phase) => !["extract", "verify", "generate"].includes(phase))) {
209
251
  throw new Error(`plugin ${pluginName}: describe.phases is invalid`);
210
252
  }
@@ -21,6 +21,15 @@ describe("plugin response validation", () => {
21
21
  expect(() => validateResponse("fixture", { files: [{ name: "image.png", contents: "x", encoding: "binary" }] })).toThrow("encoding is not supported");
22
22
  });
23
23
 
24
+ it("validates multi-megabyte base64 without overflowing the regexp stack", () => {
25
+ const contents = "A".repeat(13 * 1024 * 1024);
26
+ expect(validateResponse("fixture", { files: [{ name: "large.bin", contents, encoding: "base64" }] }).files[0]).toEqual({
27
+ name: "large.bin",
28
+ contents,
29
+ encoding: "base64",
30
+ });
31
+ });
32
+
24
33
  it.each(["../secret", "/tmp/result", "C:\\tmp\\result", "a/../../secret", "./result"])(
25
34
  "rejects unsafe output name %s",
26
35
  (name) => {
@@ -10,7 +10,10 @@ async function run() {
10
10
  const wasi = new WASI({
11
11
  version: "preview1",
12
12
  args: [workerData.name],
13
- env: {},
13
+ env: {
14
+ GOOS: process.env.GOOS || ({ win32: "windows" }[process.platform] ?? process.platform),
15
+ GOARCH: process.env.GOARCH || ({ x64: "amd64", ia32: "386" }[process.arch] ?? process.arch),
16
+ },
14
17
  // Empty for a generator or a describe request. An extract or verify
15
18
  // step gets the workspace as `/`, and nothing else (portolan.0006).
16
19
  preopens: workerData.workspace ? { "/": workerData.workspace } : {},
@@ -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
+ }