@shortlink-org/portolan 0.2.3 → 0.3.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 (184) hide show
  1. package/README.md +23 -0
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1066 -0
  5. package/catalog/roundtrip_test.go +203 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +8 -0
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +14 -0
  11. package/go.sum +20 -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 +22 -10
  35. package/plugin/describe.go +118 -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 +114 -46
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-celery/extract.py +0 -2
  42. package/plugins/extract-celery/extract_test.py +1 -1
  43. package/plugins/extract-django/README.md +39 -17
  44. package/plugins/extract-django/domain.py +28 -17
  45. package/plugins/extract-django/extract.py +21 -7
  46. package/plugins/extract-django/extract_test.py +55 -2
  47. package/plugins/extract-django/lifecycle.py +2 -0
  48. package/plugins/extract-django/operations.py +1 -1
  49. package/plugins/extract-django/routing_test.py +109 -1
  50. package/plugins/extract-django/store.py +1 -1
  51. package/plugins/extract-django/transport.py +101 -55
  52. package/plugins/extract-django/verbs.py +241 -0
  53. package/plugins/extract-go/README.md +47 -0
  54. package/plugins/extract-http-clients/describe.go +19 -0
  55. package/plugins/extract-http-clients/describe_test.go +11 -0
  56. package/plugins/extract-http-clients/extract.go +740 -0
  57. package/plugins/extract-http-clients/extract_test.go +1561 -0
  58. package/plugins/extract-http-clients/main.go +41 -0
  59. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  60. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  61. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  62. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  63. package/plugins/extract-python-kafka/README.md +6 -0
  64. package/plugins/extract-python-kafka/extract.py +2 -4
  65. package/plugins/extract-python-kafka/extract_test.py +18 -2
  66. package/plugins/extract-ts/extract.test.ts +2 -2
  67. package/plugins/extract-ts/extract.ts +4 -5
  68. package/plugins/extract-ts/graphql.test.ts +1 -1
  69. package/plugins/openapi/ids.go +261 -0
  70. package/plugins/openapi/ids_test.go +98 -0
  71. package/plugins/portolan-go.wasm +0 -0
  72. package/plugins/pyplugin/catalog.py +12 -1
  73. package/plugins/pyplugin/kafka.py +74 -3
  74. package/plugins/pyplugin/protocol.py +1 -5
  75. package/portolan.json +3 -2
  76. package/schema/portolan.schema.json +34 -0
  77. package/scripts/README.md +18 -12
  78. package/scripts/catalog-sources.mjs +6 -0
  79. package/scripts/delivery-presets.mjs +21 -11
  80. package/scripts/diff.mjs +5 -1
  81. package/scripts/django-aggregates.test.mjs +58 -0
  82. package/scripts/gen-likec4.mjs +79 -21
  83. package/scripts/gen-likec4.test.mjs +25 -2
  84. package/scripts/gen.mjs +118 -115
  85. package/scripts/go-discovery.test.mjs +30 -0
  86. package/scripts/history.mjs +186 -3
  87. package/scripts/history.test.mjs +1 -1
  88. package/scripts/host-plugins/fetch-git.mjs +77 -21
  89. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  90. package/scripts/local-api.mjs +71 -4
  91. package/scripts/local-api.test.mjs +63 -4
  92. package/scripts/local-discovery.mjs +82 -9
  93. package/scripts/manifest.mjs +5 -3
  94. package/scripts/manifest.test.mjs +24 -0
  95. package/scripts/output-diff.mjs +94 -0
  96. package/scripts/output-diff.test.mjs +36 -0
  97. package/scripts/package-smoke.mjs +62 -4
  98. package/scripts/plugin-host.mjs +22 -2
  99. package/scripts/plugin-host.test.mjs +9 -0
  100. package/scripts/plugin-wasm-worker.mjs +4 -1
  101. package/scripts/provenance.mjs +72 -0
  102. package/scripts/provenance.test.mjs +149 -0
  103. package/scripts/run-builtin.mjs +39 -5
  104. package/scripts/schema.mjs +29 -0
  105. package/scripts/warning-policy.mjs +161 -0
  106. package/scripts/warning-policy.test.mjs +56 -0
  107. package/src/app/Breadcrumbs.test.ts +4 -0
  108. package/src/app/Breadcrumbs.tsx +1 -0
  109. package/src/app/Sidebar.tsx +3 -3
  110. package/src/catalog-docs.test.ts +64 -0
  111. package/src/catalog-docs.ts +35 -0
  112. package/src/catalog-error.test.ts +15 -0
  113. package/src/catalog-model.ts +70 -6
  114. package/src/catalog-stores.test.ts +17 -0
  115. package/src/catalog-validation.ts +52 -2
  116. package/src/catalog.test.ts +13 -2
  117. package/src/chat/Starter.tsx +5 -11
  118. package/src/chat/tools.test.ts +27 -0
  119. package/src/chat/tools.ts +5 -9
  120. package/src/components/CatalogStamp.tsx +10 -8
  121. package/src/components/ChannelRows.messagepack.test.tsx +28 -0
  122. package/src/components/ChannelRows.test.tsx +54 -0
  123. package/src/components/ChannelRows.tsx +57 -10
  124. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  125. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  126. package/src/components/Integrations.tsx +1 -1
  127. package/src/components/LifecycleDiagram.tsx +28 -12
  128. package/src/components/MachineDocs.tsx +6 -5
  129. package/src/components/MethodRows.tsx +9 -2
  130. package/src/components/ProblemRow.tsx +4 -0
  131. package/src/components/RelationEvidence.test.tsx +14 -0
  132. package/src/components/RelationEvidence.tsx +53 -0
  133. package/src/components/WhatLinksHere.tsx +6 -4
  134. package/src/data.ts +25 -7
  135. package/src/enrich.test.ts +336 -6
  136. package/src/enrich.ts +206 -3
  137. package/src/flow/StepDetail.tsx +104 -54
  138. package/src/flow/answers.test.ts +18 -1
  139. package/src/flow/answers.ts +37 -8
  140. package/src/flow/evidence.test.ts +16 -0
  141. package/src/flow/evidence.ts +34 -0
  142. package/src/index.css +44 -0
  143. package/src/landing/DraggableReveal.tsx +3 -2
  144. package/src/landing/EvidencePipeline.tsx +105 -0
  145. package/src/landing/LandingPage.tsx +2 -59
  146. package/src/lib/backlinks.test.ts +16 -1
  147. package/src/lib/backlinks.ts +20 -0
  148. package/src/lib/catalog-diff.test.ts +18 -0
  149. package/src/lib/catalog-diff.ts +20 -2
  150. package/src/lib/derive.ts +1 -0
  151. package/src/lib/django-aggregates.d.mts +9 -0
  152. package/src/lib/django-aggregates.mjs +36 -0
  153. package/src/lib/django-aggregates.test.ts +29 -0
  154. package/src/lib/django-aggregates.ts +5 -0
  155. package/src/lib/kafka-ui.test.ts +87 -0
  156. package/src/lib/kafka-ui.ts +105 -0
  157. package/src/lib/local-api.ts +20 -2
  158. package/src/lib/setup-info.test.ts +17 -0
  159. package/src/lib/setup-info.ts +58 -0
  160. package/src/lib/warnings.test.ts +54 -0
  161. package/src/lib/warnings.ts +260 -0
  162. package/src/lib/wire-problems.test.ts +21 -0
  163. package/src/lib/wire-problems.ts +62 -1
  164. package/src/likec4/FlowView.tsx +2 -6
  165. package/src/likec4/flow-edges.test.ts +64 -1
  166. package/src/likec4/flow-edges.ts +43 -7
  167. package/src/likec4/view-index.ts +8 -2
  168. package/src/map/ContextMapGraph.tsx +76 -32
  169. package/src/merge.test.ts +23 -0
  170. package/src/merge.ts +33 -10
  171. package/src/pages/AggregatePage.tsx +8 -7
  172. package/src/pages/CatalogFailure.tsx +2 -2
  173. package/src/pages/ContextPage.tsx +6 -5
  174. package/src/pages/ServicePage.tsx +4 -3
  175. package/src/pages/Settings.tsx +200 -44
  176. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  177. package/src/pages/settings/IntegrationsSettings.tsx +117 -0
  178. package/src/routes.test.ts +2 -0
  179. package/src/routes.ts +2 -1
  180. package/src/selection/DetailPanel.tsx +61 -1
  181. package/src/virtual-provenance.d.ts +11 -0
  182. package/vite.config.ts +5 -0
  183. package/scripts/vendor-lock.mjs +0 -58
  184. package/scripts/vendor-lock.test.mjs +0 -69
@@ -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,7 +380,7 @@ 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) : "";
@@ -349,6 +421,7 @@ function detectionsFor(root, files) {
349
421
  supportedAdrs[0] ? `${posix.dirname(supportedAdrs[0])}/*.md` : `${posix.dirname(adrs[0] ?? "docs/adr/x.md")}/*.md (format not recognized)`,
350
422
  !supportedAdrs.length,
351
423
  supportedAdrs.length > 0,
424
+ adrPreviews,
352
425
  ),
353
426
  detected("glossary", glossaries, glossaries.length ? { files: glossaries } : {}, glossaries.join(", ")),
354
427
  ].filter(Boolean);
@@ -12,6 +12,7 @@ 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";
15
16
 
16
17
  // Read when asked, not when loaded: the CLI sets PORTOLAN_SCHEMA after its imports.
17
18
  const schemaFile = () => process.env.PORTOLAN_SCHEMA || "schema/portolan.schema.json";
@@ -38,20 +39,21 @@ export function loadManifest(path = "portolan.json") {
38
39
  */
39
40
  export function parseManifest(text, path = "portolan.json") {
40
41
  const manifest = JSON.parse(text);
42
+ const policyProblems = warningPolicyProblems(manifest.warningPolicies, path);
41
43
 
42
44
  let schema;
43
45
  try {
44
46
  schema = JSON.parse(readFileSync(schemaFile(), "utf8"));
45
47
  } catch {
46
- return { manifest, problems: [] };
48
+ return { manifest, problems: policyProblems };
47
49
  }
48
50
 
49
51
  const ajv = new Ajv({ allErrors: true, strictSchema: false });
50
52
  const validate = ajv.compile(schema);
51
53
 
52
- if (validate(manifest)) return { manifest, problems: [] };
54
+ if (validate(manifest)) return { manifest, problems: policyProblems };
53
55
 
54
- return { manifest, problems: explain(validate.errors ?? [], manifest, schema, path) };
56
+ return { manifest, problems: [...explain(validate.errors ?? [], manifest, schema, path), ...policyProblems] };
55
57
  }
56
58
 
57
59
  /**
@@ -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
+ }
@@ -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) {
@@ -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,72 @@
1
+ // Where each catalog source last changed, for the site's stamp.
2
+ //
3
+ // A fragment carries no provenance of its own (portolan.0010); the history of
4
+ // the checkout does. The browser cannot ask git, so this Vite plugin asks at
5
+ // build time - and again under the dev server whenever a source changes -
6
+ // and answers with one virtual module, keyed by the path `src/data.ts` gives
7
+ // a source. Nothing watches `.git`: a commit made while the dev server runs
8
+ // shows after a restart, or after the next change to a source.
9
+
10
+ import { existsSync, globSync, readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ import { forgetHistory, stampsFor } from "./history.mjs";
14
+ import { readManifest } from "./manifest.mjs";
15
+
16
+ export const PROVENANCE_MODULE = "virtual:portolan-provenance";
17
+ const RESOLVED = `\0${PROVENANCE_MODULE}`;
18
+
19
+ /**
20
+ * The stamp of every source the manifest's patterns find under `workspace`,
21
+ * keyed by its path as the manifest spells it.
22
+ *
23
+ * A staged site (`portolan dev`, `portolan build`) imports its sources under
24
+ * flattened names, `portolan/source-0007.json`, and keeps the way back in
25
+ * `.portolan/source-paths.json`. Such a stamp is keyed by the staged name the
26
+ * browser imports and carries `source`, the path the file has in the
27
+ * workspace - the one a project's root is a prefix of, and the one a link to
28
+ * the file has to name. Without it the site would know its sources only by
29
+ * names nobody typed.
30
+ *
31
+ * @param {string} workspace
32
+ * @param {string} siteRoot Vite's root, which may contain flattened sources
33
+ * @returns {Record<string, {commit: string, generatedAt: string, source?: string}>}
34
+ */
35
+ export function provenance(workspace, siteRoot = workspace) {
36
+ const sourceMap = join(siteRoot, ".portolan/source-paths.json");
37
+ if (existsSync(sourceMap)) {
38
+ const paths = JSON.parse(readFileSync(sourceMap, "utf8"));
39
+ const stamps = stampsFor(workspace, Object.values(paths));
40
+ return Object.fromEntries(Object.entries(paths).map(([staged, source]) => [staged, { ...stamps.get(source), source }]));
41
+ }
42
+ const manifest = readManifest(join(workspace, "portolan.json"));
43
+ const paths = [];
44
+ for (const pattern of manifest.sources ?? []) {
45
+ for (const path of globSync(pattern, { cwd: workspace })) paths.push(path.split("\\").join("/"));
46
+ }
47
+ return Object.fromEntries(stampsFor(workspace, paths.sort()));
48
+ }
49
+
50
+ /** @param {string} workspace */
51
+ export function provenancePlugin(workspace) {
52
+ let siteRoot = workspace;
53
+ return {
54
+ name: "portolan-provenance",
55
+ configResolved(config) {
56
+ siteRoot = config.root;
57
+ },
58
+ resolveId(id) {
59
+ return id === PROVENANCE_MODULE ? RESOLVED : undefined;
60
+ },
61
+ load(id) {
62
+ if (id !== RESOLVED) return undefined;
63
+ return `export default ${JSON.stringify(provenance(workspace, siteRoot))};\n`;
64
+ },
65
+ handleHotUpdate({ file, server }) {
66
+ if (!file.endsWith(".json") || file.includes("/node_modules/")) return;
67
+ forgetHistory();
68
+ const mod = server.moduleGraph.getModuleById(RESOLVED);
69
+ if (mod) server.moduleGraph.invalidateModule(mod);
70
+ },
71
+ };
72
+ }