@shortlink-org/portolan 0.2.4 → 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 (151) 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 +109 -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/extract.py +0 -2
  64. package/plugins/extract-python-kafka/extract_test.py +1 -1
  65. package/plugins/extract-ts/extract.test.ts +2 -2
  66. package/plugins/extract-ts/extract.ts +4 -5
  67. package/plugins/extract-ts/graphql.test.ts +1 -1
  68. package/plugins/openapi/ids.go +261 -0
  69. package/plugins/openapi/ids_test.go +98 -0
  70. package/plugins/portolan-go.wasm +0 -0
  71. package/plugins/pyplugin/protocol.py +1 -5
  72. package/portolan.json +3 -2
  73. package/schema/portolan.schema.json +34 -0
  74. package/scripts/README.md +18 -12
  75. package/scripts/catalog-sources.mjs +6 -0
  76. package/scripts/delivery-presets.mjs +21 -11
  77. package/scripts/diff.mjs +5 -1
  78. package/scripts/django-aggregates.test.mjs +58 -0
  79. package/scripts/gen-likec4.mjs +1 -1
  80. package/scripts/gen.mjs +118 -115
  81. package/scripts/go-discovery.test.mjs +30 -0
  82. package/scripts/history.mjs +186 -3
  83. package/scripts/history.test.mjs +1 -1
  84. package/scripts/host-plugins/fetch-git.mjs +77 -21
  85. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  86. package/scripts/local-api.mjs +71 -4
  87. package/scripts/local-api.test.mjs +63 -4
  88. package/scripts/local-discovery.mjs +82 -9
  89. package/scripts/manifest.mjs +5 -3
  90. package/scripts/manifest.test.mjs +24 -0
  91. package/scripts/output-diff.mjs +94 -0
  92. package/scripts/output-diff.test.mjs +36 -0
  93. package/scripts/package-smoke.mjs +62 -4
  94. package/scripts/plugin-host.mjs +22 -2
  95. package/scripts/plugin-host.test.mjs +9 -0
  96. package/scripts/plugin-wasm-worker.mjs +4 -1
  97. package/scripts/provenance.mjs +72 -0
  98. package/scripts/provenance.test.mjs +149 -0
  99. package/scripts/run-builtin.mjs +39 -5
  100. package/scripts/schema.mjs +29 -0
  101. package/scripts/warning-policy.mjs +161 -0
  102. package/scripts/warning-policy.test.mjs +56 -0
  103. package/src/app/Sidebar.tsx +3 -3
  104. package/src/catalog-docs.test.ts +64 -0
  105. package/src/catalog-docs.ts +35 -0
  106. package/src/catalog-error.test.ts +15 -0
  107. package/src/catalog-model.ts +48 -5
  108. package/src/catalog-validation.ts +9 -0
  109. package/src/chat/Starter.tsx +5 -11
  110. package/src/chat/tools.test.ts +27 -0
  111. package/src/chat/tools.ts +5 -9
  112. package/src/components/CatalogStamp.tsx +10 -8
  113. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  114. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  115. package/src/components/Integrations.tsx +1 -1
  116. package/src/components/MachineDocs.tsx +6 -5
  117. package/src/components/MethodRows.tsx +9 -2
  118. package/src/components/RelationEvidence.test.tsx +14 -0
  119. package/src/components/RelationEvidence.tsx +53 -0
  120. package/src/data.ts +25 -7
  121. package/src/enrich.test.ts +332 -1
  122. package/src/enrich.ts +206 -3
  123. package/src/flow/StepDetail.tsx +6 -0
  124. package/src/flow/evidence.test.ts +16 -0
  125. package/src/flow/evidence.ts +34 -0
  126. package/src/index.css +44 -0
  127. package/src/landing/DraggableReveal.tsx +3 -2
  128. package/src/landing/EvidencePipeline.tsx +105 -0
  129. package/src/landing/LandingPage.tsx +2 -59
  130. package/src/lib/catalog-diff.ts +1 -1
  131. package/src/lib/django-aggregates.d.mts +9 -0
  132. package/src/lib/django-aggregates.mjs +36 -0
  133. package/src/lib/django-aggregates.test.ts +29 -0
  134. package/src/lib/django-aggregates.ts +5 -0
  135. package/src/lib/local-api.ts +20 -2
  136. package/src/lib/setup-info.test.ts +17 -0
  137. package/src/lib/setup-info.ts +58 -0
  138. package/src/lib/warnings.test.ts +54 -0
  139. package/src/lib/warnings.ts +260 -0
  140. package/src/map/ContextMapGraph.tsx +76 -32
  141. package/src/merge.ts +16 -9
  142. package/src/pages/AggregatePage.tsx +8 -7
  143. package/src/pages/ContextPage.tsx +6 -5
  144. package/src/pages/ServicePage.tsx +4 -3
  145. package/src/pages/Settings.tsx +189 -41
  146. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  147. package/src/selection/DetailPanel.tsx +15 -0
  148. package/src/virtual-provenance.d.ts +11 -0
  149. package/vite.config.ts +5 -0
  150. package/scripts/vendor-lock.mjs +0 -58
  151. package/scripts/vendor-lock.test.mjs +0 -69
@@ -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
 
@@ -29,6 +29,8 @@ const args = [...(plugin.process.args ?? [])];
29
29
 
30
30
  if (command === "cargo" && existsSync(resolve(installRoot, "plugins/extract-rust/target/release/portolan-extract-rust"))) {
31
31
  run(resolve(installRoot, "plugins/extract-rust/target/release/portolan-extract-rust"), []);
32
+ } else if (command === "go") {
33
+ buildAndRunGo(args);
32
34
  } else {
33
35
  if (command === "cargo") {
34
36
  process.env.CARGO_TARGET_DIR = resolve(workspace, ".portolan", "bin", "cargo");
@@ -40,6 +42,38 @@ if (command === "cargo" && existsSync(resolve(installRoot, "plugins/extract-rust
40
42
  run(command, resolved);
41
43
  }
42
44
 
45
+ // `go run` changes module resolution with the current directory, but the
46
+ // analyzer must keep the scanned workspace as its cwd. Build from Portolan's
47
+ // shipped module first, outside the workspace, and execute the resulting
48
+ // native sidecar from the workspace. The Go build cache makes subsequent
49
+ // describe/extract calls cheap; rebuilding also prevents a stale sidecar after
50
+ // an npm upgrade with the same workspace cache.
51
+ function buildAndRunGo(argv) {
52
+ if (argv[0] !== "run" || typeof argv[1] !== "string" || !argv[1] || argv[1].startsWith("-")) {
53
+ console.error(`portolan: built-in ${name} has an unsupported Go command`);
54
+ process.exitCode = 2;
55
+ return;
56
+ }
57
+ const binDir = resolve(workspace, ".portolan", "bin", "go");
58
+ const executable = resolve(binDir, process.platform === "win32" ? `${name}.exe` : name);
59
+ mkdirSync(binDir, { recursive: true });
60
+ const built = spawnSync("go", ["build", "-mod=readonly", "-o", executable, argv[1]], {
61
+ cwd: installRoot,
62
+ env: { ...process.env, GOWORK: "off" },
63
+ stdio: "inherit",
64
+ });
65
+ if (built.error) {
66
+ console.error(`portolan: built-in ${name} could not build: ${built.error.message}`);
67
+ process.exitCode = 1;
68
+ return;
69
+ }
70
+ if (built.status !== 0) {
71
+ process.exitCode = built.status ?? 1;
72
+ return;
73
+ }
74
+ run(executable, argv.slice(2));
75
+ }
76
+
43
77
  function resolveArgument(executable, index) {
44
78
  if (["node", "python", "python3"].includes(executable)) return index === 0;
45
79
  if (executable === "java") return index === 1;
@@ -116,6 +116,13 @@ function compose() {
116
116
  description:
117
117
  "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
118
  },
119
+ warningPolicies: {
120
+ type: "array",
121
+ maxItems: 100,
122
+ items: { $ref: "#/$defs/warningPolicy" },
123
+ description:
124
+ "CEL policies for reviewed extraction limitations. Expressions are type-checked when the manifest is read and suppression always requires a reason.",
125
+ },
119
126
  plugins: {
120
127
  type: "array",
121
128
  items: { $ref: "#/$defs/plugin" },
@@ -139,6 +146,28 @@ function compose() {
139
146
  },
140
147
  },
141
148
  $defs: {
149
+ warningPolicy: {
150
+ type: "object",
151
+ additionalProperties: false,
152
+ required: ["when", "action", "reason"],
153
+ properties: {
154
+ when: {
155
+ type: "string",
156
+ minLength: 1,
157
+ maxLength: 1000,
158
+ description: "Boolean CEL expression over plugin, rule, severity, project, phase, ref, message and count.",
159
+ },
160
+ action: {
161
+ const: "suppress",
162
+ description: "Suppress matching diagnostics from the active view while retaining them in the report.",
163
+ },
164
+ reason: {
165
+ type: "string",
166
+ minLength: 1,
167
+ description: "Why this limitation is consciously accepted.",
168
+ },
169
+ },
170
+ },
142
171
  catalogProfile: {
143
172
  type: "object",
144
173
  additionalProperties: false,
@@ -0,0 +1,161 @@
1
+ import { Environment } from "@marcbachmann/cel-js";
2
+
3
+ const ENVIRONMENT = new Environment({
4
+ unlistedVariablesAreDyn: false,
5
+ limits: {
6
+ maxAstNodes: 256,
7
+ maxDepth: 32,
8
+ maxListElements: 32,
9
+ maxMapEntries: 32,
10
+ maxCallArguments: 8,
11
+ },
12
+ })
13
+ .registerVariable("plugin", "string")
14
+ .registerVariable("rule", "string")
15
+ .registerVariable("severity", "string")
16
+ .registerVariable("project", "string")
17
+ .registerVariable("phase", "string")
18
+ .registerVariable("ref", "string")
19
+ .registerVariable("message", "string")
20
+ .registerVariable("count", "int");
21
+ const COMPILED = new Map();
22
+
23
+ const RULES = [
24
+ rule("openapi.missing-operation-id", /\bhas no operationId\b/i, "warning", "Add stable operationId values to the OpenAPI operations."),
25
+ rule("schema.duplicate-declaration", /\bduplicate declaration\b/i, "warning", "Remove or reconcile duplicate schema declarations; Portolan currently uses the first."),
26
+ rule("analysis.typed-fallback", /\btyped call graph unavailable\b/i, "warning", "Run an extractor build with typed analysis support or inspect calls found by the syntax fallback."),
27
+ rule("catalog.unresolved-call", /\bcalls .+ which nothing in this catalog resolves\b/i, "warning", "Add or correct the provider contract, then regenerate to resolve the call."),
28
+ rule("catalog.unmapped-proto-peer", /\bmanifest names no peer for that package\b/i, "warning", "Map the protobuf package under peers, or declare it under externals."),
29
+ rule("flow.unresolved-step", /\bstep .+ is unresolved\b/i, "warning", "Declare the referenced endpoint, message, or store so this flow step can be joined."),
30
+ rule("flow.unknown-port", /\bis neither a domain port nor a use case\b/i, "warning", "Model this dependency as a domain port or use case, or accept that its calls stay outside the flow."),
31
+ rule("flow.unreached-event", /\bno flow reaches this event\b/i, "warning", "Connect the event to its publisher flow or remove the stale event declaration."),
32
+ rule("flow.unknown-event", /\breacts to the message named .+ which no event .+ declares is called\b/i, "warning", "Declare the event name used by the handler or correct the handler mapping."),
33
+ rule("django.invalid-aggregate-root", /\baggregates names .+ and no model there is called that\b/i, "warning", "Choose an existing concrete model for the application's aggregates option."),
34
+ rule("django.ambiguous-aggregate-root", /\bmodels to choose from: name the root in the aggregates option\b/i, "warning", "Choose the aggregate root explicitly in the django-domain aggregates option."),
35
+ rule("django.unknown-http-verb", /\bmounted as an HTTP view, but no HTTP verb is declared\b/i, "warning", "Declare the accepted HTTP methods on the view or route."),
36
+ rule("river.missing-worker", /\bno registered Worker\b/i, "warning", "Register the River worker in this component or remove the unmatched insert."),
37
+ rule("watermill.unresolved-topic", /\bWatermill .+ topic could not be resolved\b/i, "warning", "Use a literal, constant, or configuration default for the Watermill topic."),
38
+ rule("messaging.unresolved-subject", /\bsubject of .+ could not be resolved\b/i, "warning", "Use a literal, constant, configuration default, or visible caller argument for the subject."),
39
+ rule("store.external-migrations", /\bmigrations are applied from .+ whose schema is not in this tree\b/i, "warning", "Vendor or expose the external migrations so their tables can be included in the store."),
40
+ rule("store.missing-foreign-table", /\bcolumn .+ references .+ which no migration here creates\b/i, "warning", "Include the referenced table migration or correct the foreign-key target."),
41
+ rule("schema.unresolved-type", /\bis not declared in the protos read here\b/i, "warning", "Include the imported protobuf declaration or map the type to an external schema."),
42
+ rule("source.offline-cache", /\bnot fetched \(offline\)\b/i, "info", "Regenerate with network access when the vendored copy must be refreshed."),
43
+ rule("source.unpinned", /\bnot pinned\b/i, "warning", "Pin the source to an immutable commit or schema version."),
44
+ rule("source.parse-failed", /\bcould not (?:be read|parse|be parsed|be encoded)\b/i, "error", "Open the referenced source and fix the parse or read error."),
45
+ rule("extraction.no-match", /\b(?:no .+ (?:was|were) found|no .+ matched|declares no |no models in this application)\b/i, "info", "Confirm this capability is absent, or point the extractor at the source that declares it."),
46
+ ];
47
+
48
+ const FALLBACK_ACTION = "Inspect the referenced source and either fix the extraction gap or add a reviewed CEL policy with a reason.";
49
+
50
+ /**
51
+ * Classify, count and apply CEL policies to one step's raw warnings.
52
+ * Policy evaluation happens here, while the build still has its manifest;
53
+ * deployed sites receive decisions, not an expression runtime.
54
+ */
55
+ export function diagnoseWarnings({ plugin, warnings, policies = [], project = "", phase = "" }) {
56
+ const diagnostics = warnings.map((message) => classifyWarning(plugin, message));
57
+ const counts = new Map();
58
+ for (const diagnostic of diagnostics) {
59
+ const key = `${diagnostic.plugin}\0${diagnostic.rule}\0${diagnostic.severity}`;
60
+ counts.set(key, (counts.get(key) ?? 0) + 1);
61
+ }
62
+ const compiled = policies.map((policy) => ({ ...policy, expression: compilePolicy(policy.when) }));
63
+
64
+ return diagnostics.map((diagnostic) => {
65
+ const count = counts.get(`${diagnostic.plugin}\0${diagnostic.rule}\0${diagnostic.severity}`) ?? 1;
66
+ const context = {
67
+ plugin: diagnostic.plugin,
68
+ rule: diagnostic.rule,
69
+ severity: diagnostic.severity,
70
+ project,
71
+ phase,
72
+ ref: diagnostic.ref ?? "",
73
+ message: diagnostic.message,
74
+ count: BigInt(count),
75
+ };
76
+ const matched = compiled.find((policy) => policy.action === "suppress" && policy.expression(context) === true);
77
+ return {
78
+ ...diagnostic,
79
+ count,
80
+ project,
81
+ phase,
82
+ suppressed: Boolean(matched),
83
+ ...(matched ? { suppressionReason: matched.reason } : {}),
84
+ };
85
+ });
86
+ }
87
+
88
+ /** Validate CEL syntax, names and the boolean result type while reading the manifest. */
89
+ export function warningPolicyProblems(policies, path = "portolan.json") {
90
+ const problems = [];
91
+ for (const [index, policy] of (Array.isArray(policies) ? policies : []).entries()) {
92
+ if (!policy || typeof policy !== "object" || typeof policy.when !== "string") continue;
93
+ try {
94
+ compilePolicy(policy.when);
95
+ } catch (cause) {
96
+ problems.push(`${path} warningPolicies/${index}/when: ${cause instanceof Error ? cause.message : String(cause)}`);
97
+ }
98
+ }
99
+ return problems;
100
+ }
101
+
102
+ export function classifyWarning(plugin, message) {
103
+ const definition = RULES.find((candidate) => candidate.matches.test(message));
104
+ const ref = warningRef(message);
105
+ return {
106
+ plugin,
107
+ message,
108
+ rule: definition?.id ?? `plugin.${slug(plugin)}.other-${fingerprint(message)}`,
109
+ severity: definition?.severity ?? "warning",
110
+ action: definition?.action ?? FALLBACK_ACTION,
111
+ ...(ref ? { ref } : {}),
112
+ };
113
+ }
114
+
115
+ function compilePolicy(source) {
116
+ if (COMPILED.has(source)) return COMPILED.get(source);
117
+ let expression;
118
+ try {
119
+ expression = ENVIRONMENT.parse(source);
120
+ } catch (cause) {
121
+ throw new Error(`invalid CEL: ${cause instanceof Error ? firstLine(cause.message) : String(cause)}`);
122
+ }
123
+ const checked = expression.check();
124
+ if (!checked.valid) throw new Error(`invalid CEL: ${firstLine(checked.error?.message ?? "type check failed")}`);
125
+ if (checked.type !== "bool") throw new Error(`CEL expression must return bool, got ${checked.type}`);
126
+ COMPILED.set(source, expression);
127
+ return expression;
128
+ }
129
+
130
+ function rule(id, matches, severity, action) {
131
+ return { id, matches, severity, action };
132
+ }
133
+
134
+ function warningRef(message) {
135
+ const match = message.match(/^(.+?):\s+(?=[A-Za-z/])/);
136
+ return match?.[1]?.trim() || undefined;
137
+ }
138
+
139
+ function slug(value) {
140
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "unknown";
141
+ }
142
+
143
+ function fingerprint(message) {
144
+ const ref = warningRef(message);
145
+ const body = (ref ? message.slice(message.indexOf(":", ref.length) + 1) : message)
146
+ .toLowerCase()
147
+ .replace(/`[^`]*`|"[^"]*"|'[^']*'/g, "<value>")
148
+ .replace(/\b\d+\b/g, "#")
149
+ .replace(/\s+/g, " ")
150
+ .trim();
151
+ let hash = 0x811c9dc5;
152
+ for (let index = 0; index < body.length; index += 1) {
153
+ hash ^= body.charCodeAt(index);
154
+ hash = Math.imul(hash, 0x01000193);
155
+ }
156
+ return (hash >>> 0).toString(36);
157
+ }
158
+
159
+ function firstLine(message) {
160
+ return String(message).split("\n", 1)[0];
161
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { diagnoseWarnings, warningPolicyProblems } from "./warning-policy.mjs";
3
+
4
+ describe("warning CEL policies", () => {
5
+ const warnings = [
6
+ "api: /book POST has no operationId; listed by verb and path",
7
+ "api: /cancel POST has no operationId; listed by verb and path",
8
+ ];
9
+
10
+ it("evaluates typed CEL over the diagnostic and its repetition count", () => {
11
+ const diagnostics = diagnoseWarnings({
12
+ plugin: "openapi",
13
+ warnings,
14
+ project: "aviacore",
15
+ phase: "extract",
16
+ policies: [{
17
+ when: "plugin == 'openapi' && rule == 'openapi.missing-operation-id' && project == 'aviacore' && count >= 2",
18
+ action: "suppress",
19
+ reason: "Owned upstream.",
20
+ }],
21
+ });
22
+
23
+ expect(diagnostics).toHaveLength(2);
24
+ expect(diagnostics[0]).toMatchObject({
25
+ rule: "openapi.missing-operation-id",
26
+ count: 2,
27
+ project: "aviacore",
28
+ phase: "extract",
29
+ suppressed: true,
30
+ suppressionReason: "Owned upstream.",
31
+ });
32
+ });
33
+
34
+ it("leaves non-matching diagnostics active", () => {
35
+ const [diagnostic] = diagnoseWarnings({
36
+ plugin: "openapi",
37
+ warnings: warnings.slice(0, 1),
38
+ project: "aviasupp",
39
+ phase: "extract",
40
+ policies: [{ when: "project == 'aviacore'", action: "suppress", reason: "Only core." }],
41
+ });
42
+ expect(diagnostic?.suppressed).toBe(false);
43
+ });
44
+
45
+ it("reports unknown variables, syntax errors and non-boolean expressions", () => {
46
+ expect(warningPolicyProblems([{ when: "unknown == 1" }])).toEqual([
47
+ expect.stringContaining("Unknown variable: unknown"),
48
+ ]);
49
+ expect(warningPolicyProblems([{ when: "plugin ==" }])).toEqual([
50
+ expect.stringContaining("invalid CEL"),
51
+ ]);
52
+ expect(warningPolicyProblems([{ when: "plugin" }])).toEqual([
53
+ expect.stringContaining("must return bool"),
54
+ ]);
55
+ });
56
+ });
@@ -878,15 +878,15 @@ function AggregateNode({
878
878
  depth={2}
879
879
  open={aopen}
880
880
  onToggle={() => toggle(akey, false)}
881
- label={`aggregate ${aggregate.name}`}
881
+ label={`${aggregate.kind === "model-group" ? "model group" : "aggregate"} ${aggregate.name}`}
882
882
  selId={aggregate.id}
883
883
  under={
884
884
  <div
885
885
  className="mono truncate pr-2 text-muted"
886
886
  style={{ paddingLeft: indent(3), fontSize: 11 }}
887
- title={`aggregate root: ${aggregate.root}`}
887
+ title={aggregate.kind === "model-group" ? "Model group; aggregate boundary not specified" : `aggregate root: ${aggregate.root}`}
888
888
  >
889
- root: {aggregate.root}
889
+ {aggregate.kind === "model-group" ? "model group" : `root: ${aggregate.root}`}
890
890
  </div>
891
891
  }
892
892
  right={
@@ -0,0 +1,64 @@
1
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, 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
+ import { catalogDocs } from "./catalog-docs";
6
+ // @ts-expect-error The build's documentation mount is a plain JavaScript module.
7
+ import { siteDocs } from "../scripts/site-docs.mjs";
8
+
9
+ const roots: string[] = [];
10
+ afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })));
11
+
12
+ describe("catalog documentation URLs", () => {
13
+ it.each(["default", "avia", "portolan"])("opens the files actually published for root catalog %s", (id) => {
14
+ const root = mkdtempSync(join(tmpdir(), "catalog-docs-"));
15
+ roots.push(root);
16
+ const out = join(root, "generated", "markdown");
17
+ const dist = join(root, "dist");
18
+ mkdirSync(out, { recursive: true });
19
+ mkdirSync(dist);
20
+ writeFileSync(join(out, "llms.txt"), "# Catalog\n[Home](README.md)\n");
21
+ writeFileSync(join(out, "llms-full.txt"), "# Full catalog\n");
22
+ writeFileSync(join(out, "README.md"), "# Home\n");
23
+ const manifest = { generate: [{ plugin: "markdown", ...(id === "default" ? {} : { catalog: id }), out }] };
24
+ siteDocs({ manifest, dist });
25
+
26
+ const urls = catalogDocs(manifest, id, "/portolan/")!;
27
+ expect(urls.index).toBe("/portolan/llms.txt");
28
+ const published = (url: string) => readFileSync(join(dist, url.slice("/portolan/".length)), "utf8");
29
+ expect(published(urls.index)).toContain("[Home](docs/README.md)");
30
+ expect(published(urls.full)).toBe("# Full catalog\n");
31
+ expect(published(`${urls.pages}README.md`)).toBe("# Home\n");
32
+ });
33
+
34
+ it("uses a nested output directory even when it differs from the catalog id", () => {
35
+ const manifest = { generate: [
36
+ { plugin: "markdown", catalog: "avia", out: "./generated/docs/" },
37
+ { plugin: "markdown", catalog: "example", out: "generated/docs/samples" },
38
+ ] };
39
+ expect(catalogDocs(manifest, "example", "/portolan")).toEqual({
40
+ pages: "/portolan/docs/samples/",
41
+ index: "/portolan/docs/samples/llms.txt",
42
+ full: "/portolan/docs/samples/llms-full.txt",
43
+ });
44
+ });
45
+
46
+ it("preserves the demo's example paths and supports a site served at /", () => {
47
+ const manifest = { generate: [
48
+ { plugin: "markdown", catalog: "portolan", out: "docs" },
49
+ { plugin: "markdown", catalog: "example", out: "docs/example" },
50
+ ] };
51
+ expect(catalogDocs(manifest, "example", "/")?.index).toBe("/docs/example/llms.txt");
52
+ expect(catalogDocs(manifest, "portolan", "/")?.index).toBe("/llms.txt");
53
+ });
54
+
55
+ it("does not invent URLs for missing or unmounted documentation", () => {
56
+ expect(catalogDocs({}, "default", "/")).toBeNull();
57
+ const manifest = { generate: [
58
+ { plugin: "markdown", catalog: "avia", out: "docs" },
59
+ { plugin: "markdown", catalog: "example", out: "docs-other" },
60
+ ] };
61
+ expect(catalogDocs(manifest, "unknown", "/")).toBeNull();
62
+ expect(catalogDocs(manifest, "example", "/")).toBeNull();
63
+ });
64
+ });