@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
@@ -0,0 +1,64 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ import { newestMtime, pluginsFresh } from "./plugins-fresh.mjs";
7
+
8
+ const roots = [];
9
+ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
10
+
11
+ function at(path, seconds) {
12
+ utimesSync(path, seconds, seconds);
13
+ }
14
+
15
+ /** A workspace with a built wasm and Java classes, all stamped at `built`, over sources stamped at `source`. */
16
+ function workspace({ source, built }) {
17
+ const root = mkdtempSync(join(tmpdir(), "portolan-fresh-"));
18
+ roots.push(root);
19
+ for (const dir of ["plugins/verify-otel", "plugins/extract-java/src/org", "plugins/extract-java/build/org", "catalog", "plugin"]) mkdirSync(join(root, dir), { recursive: true });
20
+ const files = {
21
+ "go.mod": source, "plugins/verify-otel/verify.go": source, "catalog/model.go": source, "plugin/protocol.go": source,
22
+ "plugins/extract-java/src/org/Main.java": source,
23
+ "plugins/portolan-go.wasm": built, "plugins/extract-java/build/org/Main.class": built,
24
+ };
25
+ for (const [name, stamp] of Object.entries(files)) {
26
+ writeFileSync(join(root, name), "x");
27
+ at(join(root, name), stamp);
28
+ }
29
+ return root;
30
+ }
31
+
32
+ describe("whether the built plugins are newer than their sources", () => {
33
+ it("is yes when every artefact is at least as new as every source", () => {
34
+ expect(pluginsFresh(workspace({ source: 1000, built: 2000 }))).toBe(true);
35
+ expect(pluginsFresh(workspace({ source: 2000, built: 2000 }))).toBe(true);
36
+ });
37
+
38
+ it("is no when a Go source, a Java source, or go.mod is newer, or an artefact is missing", () => {
39
+ const go = workspace({ source: 1000, built: 2000 });
40
+ at(join(go, "catalog/model.go"), 3000);
41
+ expect(pluginsFresh(go)).toBe(false);
42
+
43
+ const java = workspace({ source: 1000, built: 2000 });
44
+ at(join(java, "plugins/extract-java/src/org/Main.java"), 3000);
45
+ expect(pluginsFresh(java)).toBe(false);
46
+
47
+ const mod = workspace({ source: 1000, built: 2000 });
48
+ at(join(mod, "go.mod"), 3000);
49
+ expect(pluginsFresh(mod)).toBe(false);
50
+
51
+ const missing = workspace({ source: 1000, built: 2000 });
52
+ rmSync(join(missing, "plugins/portolan-go.wasm"));
53
+ expect(pluginsFresh(missing)).toBe(false);
54
+ });
55
+
56
+ it("leaves a plugin's own build output and test data out of the sources", () => {
57
+ const root = workspace({ source: 1000, built: 2000 });
58
+ mkdirSync(join(root, "plugins/extract-rust/target"), { recursive: true });
59
+ writeFileSync(join(root, "plugins/extract-rust/target/out.go"), "x");
60
+ at(join(root, "plugins/extract-rust/target/out.go"), 5000);
61
+ expect(pluginsFresh(root)).toBe(true);
62
+ expect(newestMtime(join(root, "plugins"), (path) => path.endsWith(".go"))).toBe(1000 * 1000);
63
+ });
64
+ });
@@ -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
+ }
@@ -0,0 +1,149 @@
1
+ // The provenance of a source is the history's (portolan.0010): the commit
2
+ // that last changed the file, read off the checkout and never written in.
3
+
4
+ import { execFileSync } from "node:child_process";
5
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { afterEach, describe, expect, it } from "vitest";
9
+
10
+ import { changedSince, forgetHistory, lastCommitTouching, stampsFor } from "./history.mjs";
11
+ import { provenance } from "./provenance.mjs";
12
+
13
+ const created = [];
14
+ afterEach(() => {
15
+ forgetHistory();
16
+ for (const path of created.splice(0)) rmSync(path, { recursive: true, force: true });
17
+ });
18
+
19
+ const ADA = { GIT_AUTHOR_NAME: "Ada Lovelace", GIT_AUTHOR_EMAIL: "ada@example.com", GIT_COMMITTER_NAME: "Ada Lovelace", GIT_COMMITTER_EMAIL: "ada@example.com" };
20
+
21
+ function repository() {
22
+ const root = mkdtempSync(join(tmpdir(), "portolan-provenance-"));
23
+ created.push(root);
24
+ const git = (args, date = "2026-01-01T09:00:00Z") =>
25
+ execFileSync("git", ["-C", root, ...args], {
26
+ encoding: "utf8",
27
+ env: { ...process.env, ...ADA, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date },
28
+ stdio: ["ignore", "pipe", "pipe"],
29
+ }).trim();
30
+ const write = (name, contents) => {
31
+ mkdirSync(join(root, name, ".."), { recursive: true });
32
+ writeFileSync(join(root, name), contents);
33
+ };
34
+ git(["init", "-q"]);
35
+ return { root, git, write };
36
+ }
37
+
38
+ function elsewhere() {
39
+ const dir = mkdtempSync(join(tmpdir(), "portolan-no-repo-"));
40
+ created.push(dir);
41
+ return dir;
42
+ }
43
+
44
+ describe("stampsFor", () => {
45
+ // The stamp is the commit the change landed in, which is what a stamp
46
+ // written into the file could never say.
47
+ it("dates a source by the commit that last changed it", () => {
48
+ const { root, git, write } = repository();
49
+ write("portolan/domain.json", "{}\n");
50
+ write("portolan/api.json", "{}\n");
51
+ git(["add", "."]);
52
+ git(["commit", "-q", "-m", "both"], "2026-01-01T09:00:00Z");
53
+ write("portolan/domain.json", '{"changed":true}\n');
54
+ git(["add", "."]);
55
+ git(["commit", "-q", "-m", "domain again"], "2026-02-02T10:00:00Z");
56
+ const second = git(["rev-parse", "--short=7", "HEAD"]);
57
+ const first = git(["rev-parse", "--short=7", "HEAD~1"]);
58
+
59
+ const stamps = stampsFor(root, ["portolan/domain.json", "portolan/api.json"]);
60
+ expect(stamps.get("portolan/domain.json")).toEqual({ commit: second, generatedAt: "2026-02-02T10:00:00Z" });
61
+ expect(stamps.get("portolan/api.json")).toEqual({ commit: first, generatedAt: "2026-01-01T09:00:00Z" });
62
+ });
63
+
64
+ it("calls a modified, an untracked and an unversioned file uncommitted, dated by the working tree", () => {
65
+ const { root, git, write } = repository();
66
+ write("portolan/domain.json", "{}\n");
67
+ git(["add", "."]);
68
+ git(["commit", "-q", "-m", "one"]);
69
+ write("portolan/domain.json", '{"dirty":true}\n');
70
+ write("portolan/new.json", "{}\n");
71
+ const outside = elsewhere();
72
+ writeFileSync(join(outside, "authored.json"), "{}\n");
73
+
74
+ const stamps = stampsFor(root, ["portolan/domain.json", "portolan/new.json", join(outside, "authored.json")]);
75
+ expect(stamps.size).toBe(3);
76
+ for (const stamp of stamps.values()) {
77
+ expect(stamp.commit).toBe("uncommitted");
78
+ expect(Number.isNaN(Date.parse(stamp.generatedAt))).toBe(false);
79
+ }
80
+ });
81
+ });
82
+
83
+ describe("what changed since the output was last committed", () => {
84
+ it("lists the inputs that moved, working tree included, and leaves the output directory out", () => {
85
+ const { root, git, write } = repository();
86
+ write("svc/main.go", "package main\n");
87
+ write("svc/go.mod", "module svc\n");
88
+ write("svc/portolan/domain.json", "{}\n");
89
+ git(["add", "."]);
90
+ git(["commit", "-q", "-m", "generated"], "2026-01-01T09:00:00Z");
91
+ const generated = git(["rev-parse", "--short=7", "HEAD"]);
92
+ write("svc/main.go", "package main // changed\n");
93
+ git(["add", "."]);
94
+ git(["commit", "-q", "-m", "source moved"], "2026-02-02T10:00:00Z");
95
+ write("svc/go.mod", "module svc // dirty\n");
96
+ write("svc/new.go", "package main\n");
97
+ write("svc/portolan/domain.json", '{"regenerated":true}\n');
98
+
99
+ expect(lastCommitTouching(root, ["svc/portolan/domain.json"])).toEqual({ commit: generated, date: "2026-01-01T09:00:00Z" });
100
+ expect(changedSince(root, generated, ["svc"], ["svc/portolan"])).toEqual(["svc/go.mod", "svc/main.go", "svc/new.go"]);
101
+ expect(changedSince(root, generated, ["svc"])).toContain("svc/portolan/domain.json");
102
+ });
103
+
104
+ it("answers null for an output nobody has committed, and nothing outside a repository", () => {
105
+ const { root, write } = repository();
106
+ write("svc/portolan/domain.json", "{}\n");
107
+ expect(lastCommitTouching(root, ["svc/portolan/domain.json"])).toBeNull();
108
+ const outside = elsewhere();
109
+ expect(lastCommitTouching(outside, ["x.json"])).toBeNull();
110
+ expect(changedSince(outside, "HEAD", ["."])).toEqual([]);
111
+ });
112
+ });
113
+
114
+ describe("the site's provenance module", () => {
115
+ it("stamps every source the manifest's patterns find, keyed as the manifest spells them", () => {
116
+ const { root, git, write } = repository();
117
+ write("portolan.json", JSON.stringify({ sources: ["portolan/*.json", "services/*/portolan/*.json"] }));
118
+ write("portolan/host.json", "{}\n");
119
+ write("services/oms/portolan/domain.json", "{}\n");
120
+ git(["add", "."]);
121
+ git(["commit", "-q", "-m", "one"], "2026-03-03T03:00:00Z");
122
+ const head = git(["rev-parse", "--short=7", "HEAD"]);
123
+
124
+ expect(provenance(root)).toEqual({
125
+ "portolan/host.json": { commit: head, generatedAt: "2026-03-03T03:00:00Z" },
126
+ "services/oms/portolan/domain.json": { commit: head, generatedAt: "2026-03-03T03:00:00Z" },
127
+ });
128
+ });
129
+
130
+ // A staged site imports flattened copies. The stamp is the workspace file's,
131
+ // keyed by the name the browser imports, and it says which file that was -
132
+ // the path a project's root is a prefix of.
133
+ it("keys a staged site's sources by their flattened names and says where each came from", () => {
134
+ const { root, git, write } = repository();
135
+ write("portolan.json", JSON.stringify({ sources: ["services/*/portolan/*.json"] }));
136
+ write("services/oms/portolan/domain.json", "{}\n");
137
+ git(["add", "."]);
138
+ git(["commit", "-q", "-m", "one"], "2026-03-03T03:00:00Z");
139
+ const head = git(["rev-parse", "--short=7", "HEAD"]);
140
+ const stage = join(root, ".portolan", "site");
141
+ write(".portolan/site/.portolan/source-paths.json", JSON.stringify({
142
+ "portolan/source-0001.json": "services/oms/portolan/domain.json",
143
+ }));
144
+
145
+ expect(provenance(root, stage)).toEqual({
146
+ "portolan/source-0001.json": { commit: head, generatedAt: "2026-03-03T03:00:00Z", source: "services/oms/portolan/domain.json" },
147
+ });
148
+ });
149
+ });
@@ -2,12 +2,12 @@
2
2
 
3
3
  // Process adapter for plugins bundled in the npm package. It keeps the
4
4
  // plugin's cwd in the user's workspace while resolving source and toolchain
5
- // files from the installation. The Go plugins no longer come through here:
6
- // they are one wasm module (portolan.0006), and the fetchers run inside the
7
- // host (portolan.0008).
5
+ // files from the installation. Most Go plugins are one wasm module
6
+ // (portolan.0006). A Go plugin that needs the project toolchain is compiled to
7
+ // a workspace-local sidecar here, then run with the user's workspace as cwd.
8
8
 
9
- import { spawn } from "node:child_process";
10
- import { existsSync } from "node:fs";
9
+ import { spawn, spawnSync } from "node:child_process";
10
+ import { existsSync, mkdirSync } from "node:fs";
11
11
  import { dirname, resolve } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
 
@@ -27,8 +27,11 @@ if (!plugin?.process) {
27
27
  const command = plugin.process.command;
28
28
  const args = [...(plugin.process.args ?? [])];
29
29
 
30
- if (command === "cargo" && existsSync(resolve(installRoot, "plugins/extract-rust/target/release/portolan-extract-rust"))) {
31
- run(resolve(installRoot, "plugins/extract-rust/target/release/portolan-extract-rust"), []);
30
+ const prebuilt = command === "cargo" ? prebuiltCargoBinary(args) : "";
31
+ if (prebuilt) {
32
+ run(prebuilt, []);
33
+ } else if (command === "go") {
34
+ buildAndRunGo(args);
32
35
  } else {
33
36
  if (command === "cargo") {
34
37
  process.env.CARGO_TARGET_DIR = resolve(workspace, ".portolan", "bin", "cargo");
@@ -40,9 +43,61 @@ if (command === "cargo" && existsSync(resolve(installRoot, "plugins/extract-rust
40
43
  run(command, resolved);
41
44
  }
42
45
 
46
+ // `go run` changes module resolution with the current directory, but the
47
+ // analyzer must keep the scanned workspace as its cwd. Build from Portolan's
48
+ // shipped module first, outside the workspace, and execute the resulting
49
+ // native sidecar from the workspace. The Go build cache makes subsequent
50
+ // describe/extract calls cheap; rebuilding also prevents a stale sidecar after
51
+ // an npm upgrade with the same workspace cache.
52
+ function buildAndRunGo(argv) {
53
+ // `go run [build flags] <package> [args]`: the build flags (-mod=mod, -tags)
54
+ // belong to the build, the package is what is built, the rest is the
55
+ // sidecar's own command line.
56
+ const flags = [];
57
+ let at = 1;
58
+ while (argv[0] === "run" && typeof argv[at] === "string" && argv[at].startsWith("-")) flags.push(argv[at++]);
59
+ const pkg = argv[at];
60
+ if (argv[0] !== "run" || typeof pkg !== "string" || !pkg) {
61
+ console.error(`portolan: built-in ${name} has an unsupported Go command`);
62
+ process.exitCode = 2;
63
+ return;
64
+ }
65
+ const binDir = resolve(workspace, ".portolan", "bin", "go");
66
+ const executable = resolve(binDir, process.platform === "win32" ? `${name}.exe` : name);
67
+ mkdirSync(binDir, { recursive: true });
68
+ const built = spawnSync("go", ["build", "-mod=mod", ...flags.filter((flag) => flag !== "-mod=mod"), "-o", executable, pkg], {
69
+ cwd: installRoot,
70
+ env: { ...process.env, GOWORK: "off" },
71
+ stdio: "inherit",
72
+ });
73
+ if (built.error) {
74
+ console.error(`portolan: built-in ${name} could not build: ${built.error.message}`);
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+ if (built.status !== 0) {
79
+ process.exitCode = built.status ?? 1;
80
+ return;
81
+ }
82
+ run(executable, argv.slice(at + 1));
83
+ }
84
+
85
+ // A Rust plugin already built in release mode under its own crate directory,
86
+ // `plugins/extract-rust/target/release/portolan-extract-rust`, is run as it
87
+ // is; the crate is named after its directory, which is what the manifest
88
+ // path names.
89
+ function prebuiltCargoBinary(argv) {
90
+ const manifest = argv[argv.indexOf("--manifest-path") + 1];
91
+ if (!manifest || argv.indexOf("--manifest-path") < 0) return "";
92
+ const crate = dirname(manifest).split("/").pop();
93
+ const binary = resolve(installRoot, dirname(manifest), "target", "release", `portolan-${crate}`);
94
+ return existsSync(binary) ? binary : "";
95
+ }
96
+
43
97
  function resolveArgument(executable, index) {
44
98
  if (["node", "python", "python3"].includes(executable)) return index === 0;
45
99
  if (executable === "java") return index === 1;
100
+ if (executable === "dotnet") return index === 0;
46
101
  if (executable === "cargo") return index === 3;
47
102
  return false;
48
103
  }
@@ -1,8 +1,9 @@
1
1
  // Composes the JSON Schema for portolan.json out of what the plugins say about
2
2
  // themselves.
3
3
  //
4
- // node scripts/schema.mjs write schema/portolan.schema.json
5
- // node scripts/schema.mjs --check fail if the schema on disk is not what
4
+ // node scripts/schema.mjs write schema/portolan.schema.json and
5
+ // src/lib/plugin-index.json
6
+ // node scripts/schema.mjs --check fail if either file on disk is not what
6
7
  // the plugins describe now
7
8
  //
8
9
  // The manifest tells each plugin the things its source cannot carry - which
@@ -19,8 +20,14 @@
19
20
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
21
 
21
22
  import { describePlugin } from "./plugin-host.mjs";
23
+ import { RULE_ID, SEVERITIES, SUBJECT_NAMES, SUBJECTS } from "../src/lib/problem-rules-cel.mjs";
24
+
25
+ // The built-in problem rules, so the schema can say which ids are switches
26
+ // and which must carry a whole rule.
27
+ const BUILTIN_RULES = JSON.parse(readFileSync("rules/builtin.json", "utf8"));
22
28
 
23
29
  const OUT = "schema/portolan.schema.json";
30
+ const INDEX = "src/lib/plugin-index.json";
24
31
 
25
32
  const check = process.argv.includes("--check");
26
33
  // Bootstrap exception: this command produces the schema that loadManifest
@@ -51,25 +58,65 @@ for (const plugin of manifest.plugins ?? []) {
51
58
  }
52
59
 
53
60
  const composed = `${JSON.stringify(compose(), null, 2)}\n`;
61
+ const index = `${JSON.stringify(pluginIndex(), null, 2)}\n`;
62
+
63
+ let stale = false;
64
+ for (const [path, contents, directory] of [
65
+ [OUT, composed, "schema"],
66
+ [INDEX, index, "src/lib"],
67
+ ]) {
68
+ let current = null;
69
+ try {
70
+ current = readFileSync(path, "utf8");
71
+ } catch {
72
+ // Absent, which is the same as out of date.
73
+ }
54
74
 
55
- let current = null;
56
- try {
57
- current = readFileSync(OUT, "utf8");
58
- } catch {
59
- // Absent, which is the same as out of date.
75
+ if (current === contents) {
76
+ console.log(`${path}: up to date`);
77
+ } else if (check) {
78
+ console.error(
79
+ `${path} is ${current === null ? "missing" : "out of date"}. Run \`npm run schema\`.`,
80
+ );
81
+ stale = true;
82
+ } else {
83
+ mkdirSync(directory, { recursive: true });
84
+ writeFileSync(path, contents);
85
+ console.log(`${path}: written from ${described.size} plugin${described.size === 1 ? "" : "s"}`);
86
+ }
60
87
  }
88
+ if (stale) process.exit(1);
61
89
 
62
- if (current === composed) {
63
- console.log(`${OUT}: up to date`);
64
- } else if (check) {
65
- console.error(
66
- `${OUT} is ${current === null ? "missing" : "out of date"}. Run \`npm run schema\`.`,
67
- );
68
- process.exit(1);
69
- } else {
70
- mkdirSync("schema", { recursive: true });
71
- writeFileSync(OUT, composed);
72
- console.log(`${OUT}: written from ${described.size} plugin${described.size === 1 ? "" : "s"}`);
90
+ /**
91
+ * The plugin index: every plugin the package ships, as it describes itself,
92
+ * with how the manifest runs it. The site's /plugins page and the landing's
93
+ * list of inputs are rendered from this, so a plugin added to portolan.json
94
+ * appears there without anyone writing it down twice. Kept under src/ rather
95
+ * than beside the schema because the staged site copies src/ and nothing else.
96
+ */
97
+ function pluginIndex() {
98
+ const out = [];
99
+ for (const plugin of manifest.plugins ?? []) {
100
+ const descriptor = described.get(plugin.name);
101
+ if (!descriptor) continue;
102
+ out.push({
103
+ name: plugin.name,
104
+ plugin: descriptor.name,
105
+ summary: descriptor.summary,
106
+ category: descriptor.category,
107
+ phases: descriptor.phases,
108
+ ...(descriptor.needs?.length ? { needs: descriptor.needs } : {}),
109
+ runtime: plugin.wasm ? "wasm" : plugin.host ? "host" : "process",
110
+ // The toolchain a process plugin asks the build for; the sandboxed
111
+ // module and the host's own code ask for nothing.
112
+ ...(plugin.process ? { toolchain: plugin.process.command } : {}),
113
+ source: plugin.host
114
+ ? `scripts/host-plugins/${plugin.host}.mjs`
115
+ : `plugins/${descriptor.name}`,
116
+ options: descriptor.options ?? {},
117
+ });
118
+ }
119
+ return out;
73
120
  }
74
121
 
75
122
  function compose() {
@@ -116,6 +163,20 @@ function compose() {
116
163
  description:
117
164
  "The source projects that make up the estate. A project gives repeated pipeline inputs one name for the generated site's Settings page; estate-wide inputs such as flows need no project.",
118
165
  },
166
+ warningPolicies: {
167
+ type: "array",
168
+ maxItems: 100,
169
+ items: { $ref: "#/$defs/warningPolicy" },
170
+ description:
171
+ "CEL policies for reviewed extraction limitations. Expressions are type-checked when the manifest is read and suppression always requires a reason.",
172
+ },
173
+ problemRules: {
174
+ type: "array",
175
+ maxItems: 200,
176
+ items: { $ref: "#/$defs/problemRule" },
177
+ description:
178
+ "The rules the Problems page applies. An entry naming a built-in rule (rules/builtin.json) switches it off or re-grades it, with a reason; any other id is a rule of its own, written in CEL over one subject. Expressions are type-checked when the manifest is read and run in the page over the merged catalog.",
179
+ },
119
180
  plugins: {
120
181
  type: "array",
121
182
  items: { $ref: "#/$defs/plugin" },
@@ -139,6 +200,29 @@ function compose() {
139
200
  },
140
201
  },
141
202
  $defs: {
203
+ problemRule: problemRuleSchema(),
204
+ warningPolicy: {
205
+ type: "object",
206
+ additionalProperties: false,
207
+ required: ["when", "action", "reason"],
208
+ properties: {
209
+ when: {
210
+ type: "string",
211
+ minLength: 1,
212
+ maxLength: 1000,
213
+ description: "Boolean CEL expression over plugin, rule, severity, project, phase, ref, message and count.",
214
+ },
215
+ action: {
216
+ const: "suppress",
217
+ description: "Suppress matching diagnostics from the active view while retaining them in the report.",
218
+ },
219
+ reason: {
220
+ type: "string",
221
+ minLength: 1,
222
+ description: "Why this limitation is consciously accepted.",
223
+ },
224
+ },
225
+ },
142
226
  catalogProfile: {
143
227
  type: "object",
144
228
  additionalProperties: false,
@@ -244,7 +328,7 @@ function compose() {
244
328
  description: "What the steps below call this plugin. It need not be the plugin's own name.",
245
329
  },
246
330
  host: {
247
- enum: ["fetch-git", "fetch-bsr", "fetch-csr"],
331
+ enum: ["fetch-git", "fetch-bsr", "fetch-csr", "fetch-k8s", "fetch-argocd"],
248
332
  description:
249
333
  "A plugin the host runs inside its own process, because it needs a socket or a git binary (portolan.0008). Only the ones shipped with Portolan can be named.",
250
334
  },
@@ -371,3 +455,67 @@ function fail(message) {
371
455
  console.error(`portolan schema: ${message}`);
372
456
  process.exit(1);
373
457
  }
458
+
459
+ /**
460
+ * One entry of `problemRules`. Two shapes under one id field: a built-in id
461
+ * may only be switched, re-graded and explained; any other id is a whole
462
+ * rule, over one subject, with the expressions the page runs.
463
+ */
464
+ function problemRuleSchema() {
465
+ const builtinIds = BUILTIN_RULES.map((rule) => rule.id);
466
+ const subjectFields = SUBJECT_NAMES.map(
467
+ (name) => `${name}: ${Object.keys(SUBJECTS[name].schema).join(", ")}`,
468
+ ).join("; ");
469
+ const expression = (returns, what) => ({
470
+ type: "string",
471
+ minLength: 1,
472
+ maxLength: 2000,
473
+ description: `${what} CEL returning ${returns}, over the subject named by \`over\` and \`estate\` (services, contexts, stores, channels, externals). Fields by subject: ${subjectFields}.`,
474
+ });
475
+ return {
476
+ type: "object",
477
+ additionalProperties: false,
478
+ required: ["id"],
479
+ properties: {
480
+ id: {
481
+ type: "string",
482
+ pattern: RULE_ID.source,
483
+ description: `The rule. One of the built-in ids - ${builtinIds.join(", ")} - to switch or re-grade that rule; any other id declares a rule of its own.`,
484
+ },
485
+ enabled: {
486
+ type: "boolean",
487
+ default: true,
488
+ description: "Whether the rule produces rows. A disabled rule needs a reason and stays listed on the Settings page.",
489
+ },
490
+ severity: {
491
+ enum: SEVERITIES,
492
+ description: "How wrong a row of this rule is. For a built-in rule this replaces the severity its passport was written with.",
493
+ },
494
+ reason: {
495
+ type: "string",
496
+ minLength: 1,
497
+ description: "Why the rule is off or re-graded, or, for a rule of your own, why the estate holds it.",
498
+ },
499
+ over: {
500
+ enum: SUBJECT_NAMES,
501
+ description: "What one row of the rule is about, and what `when` and `message` read.",
502
+ },
503
+ when: expression("bool", "The condition: a row exists for every subject where it holds."),
504
+ message: expression("string", "The note on the row."),
505
+ peer: expression("string", "The row's far end, when it has one; a service, event or table id links to its page."),
506
+ title: { type: "string", minLength: 1, description: "The rule's name on the Settings page." },
507
+ note: { type: "string", minLength: 1, description: "The short words on every row of the rule; the title when absent." },
508
+ description: { type: "string", description: "What the rule checks and why it matters." },
509
+ action: { type: "string", description: "What a reader does about a row." },
510
+ },
511
+ if: { properties: { id: { enum: builtinIds } } },
512
+ then: {
513
+ not: {
514
+ anyOf: ["over", "when", "message", "peer", "title", "note", "description", "action"].map((key) => ({
515
+ required: [key],
516
+ })),
517
+ },
518
+ },
519
+ else: { required: ["over", "when", "message", "title"] },
520
+ };
521
+ }