@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,35 @@
1
+ export interface DocsManifest {
2
+ generate?: { plugin: string; catalog?: string; out: string }[];
3
+ }
4
+
5
+ export interface CatalogDocs {
6
+ pages: string;
7
+ index: string;
8
+ full: string;
9
+ }
10
+
11
+ /** Match site-docs.mjs: the first markdown output is mounted at docs/.
12
+ * Other catalogs are available when generated inside that output directory.
13
+ */
14
+ export function catalogDocs(
15
+ manifest: DocsManifest,
16
+ profileId: string,
17
+ base: string,
18
+ ): CatalogDocs | null {
19
+ const steps = manifest.generate?.filter((step) => step.plugin === "markdown") ?? [];
20
+ const root = steps[0];
21
+ const selected = steps.find((step) => step.catalog === profileId)
22
+ ?? steps.find((step) => !step.catalog);
23
+ if (!root || !selected) return null;
24
+
25
+ const directory = (out: string) => new URL(`${out.replace(/\/$/, "")}/`, "https://workspace.invalid/").pathname;
26
+ const rootPath = directory(root.out);
27
+ const selectedPath = directory(selected.out);
28
+ if (!selectedPath.startsWith(rootPath)) return null;
29
+
30
+ const prefix = base.endsWith("/") ? base : `${base}/`;
31
+ const suffix = selectedPath.slice(rootPath.length);
32
+ const pages = `${prefix}docs/${suffix}`;
33
+ const indexRoot = suffix ? pages : prefix;
34
+ return { pages, index: `${indexRoot}llms.txt`, full: `${indexRoot}llms-full.txt` };
35
+ }
@@ -21,6 +21,21 @@ function failureOf(catalog: Catalog): CatalogError {
21
21
  }
22
22
 
23
23
  describe("CatalogError.path", () => {
24
+ it("allows rootless source groups while preserving aggregate root validation", () => {
25
+ const data = clone();
26
+ const aggregate = data.contexts[0]!.services[0]!.aggregates[0]!;
27
+ aggregate.root = "";
28
+ delete aggregate.lifecycle;
29
+ expect(() => validateCatalog(data)).toThrow(/names no root/);
30
+ aggregate.kind = "model-group";
31
+ expect(() => validateCatalog(data)).not.toThrow();
32
+ aggregate.root = aggregate.entities[0]!.name;
33
+ expect(() => validateCatalog(data)).toThrow(/cannot declare an aggregate root/);
34
+ aggregate.root = "";
35
+ aggregate.lifecycle = { states: ["Active"], transitions: [] };
36
+ expect(() => validateCatalog(data)).toThrow(/cannot declare an aggregate root or lifecycle/);
37
+ });
38
+
24
39
  it("names the flow and the step for a step that points at a missing lane", () => {
25
40
  const bad = clone();
26
41
  const flow = bad.flows[0];
@@ -297,7 +297,11 @@ export interface RpcMethod {
297
297
  }
298
298
 
299
299
  export interface HttpRoute {
300
- /** Upper case: `POST`. */
300
+ /**
301
+ * Upper case: `POST`. Empty when a framework extractor proved the mount but
302
+ * no declaration proved the verb; such a route is never matched against an
303
+ * outbound call, and renderers show the path alone.
304
+ */
301
305
  method: string;
302
306
  /** As templated in the document: `/v1/users/{id}`. */
303
307
  path: string;
@@ -361,7 +365,32 @@ export interface EdgeVia {
361
365
  flow: string; // Flow.slug
362
366
  step: string; // Step.id
363
367
  }
368
+ /** Source facts remain separate from the merge's choice of provider. */
369
+ export interface HTTPDestination {
370
+ callSite: string;
371
+ endpointExpression: string;
372
+ method: string;
373
+ localPath?: string;
374
+ baseURL?: HTTPBaseURL;
375
+ serviceDiscoveryAlias?: string;
376
+ fullPath?: string;
377
+ join?: { expression: string; source: string };
378
+ /** Runtime URL modifiers after the proven join; not evaluated statically. */
379
+ transforms?: { expression: string; source: string }[];
380
+ resolution?: { basis: "full-path" | "exact-route" | "unique-suffix"; provider: string; route: string };
381
+ }
382
+ export interface HTTPBaseURL {
383
+ expression: string;
384
+ configField?: string;
385
+ environmentVariable?: string;
386
+ value?: string;
387
+ kind: "literal" | "config-default" | "symbolic";
388
+ source: string;
389
+ optionSource?: string;
390
+ }
364
391
  export interface RpcCall {
392
+ evidence?: RelationEvidence[];
393
+ destination?: HTTPDestination;
365
394
  id: string; // "<proto.package.Service>/<Method>"
366
395
  peer: string; // service id if resolved, else raw name
367
396
  status: Status;
@@ -447,7 +476,9 @@ export interface Aggregate {
447
476
  slug: string;
448
477
  name: string;
449
478
  readme: string;
450
- /** Name of the entity that is the aggregate root; must be one of `entities`. */
479
+ /** A source grouping has no confirmed aggregate boundary. Omitted for aggregates. */
480
+ kind?: "model-group";
481
+ /** Name of the root entity; empty only for a model-group. */
451
482
  root: string;
452
483
  entities: Entity[];
453
484
  valueObjects: ValueObject[];
@@ -734,7 +765,7 @@ export interface RedisKeyspace {
734
765
  value?: string;
735
766
  source?: string;
736
767
  /** Aggregate or block whose value this key family holds, when provable. */
737
- persists?: { aggregate?: string; block?: string };
768
+ persists?: { aggregate?: string; block?: string; evidence?: RelationEvidence[] };
738
769
  /** Individual client calls, before they are folded into `operations`. */
739
770
  accesses?: RedisAccess[];
740
771
  }
@@ -767,13 +798,14 @@ export const TABLE_ROLES: readonly TableRole[] = [
767
798
  ] as const;
768
799
 
769
800
  export interface Table {
801
+ evidence?: RelationEvidence[];
770
802
  id: string; // "<store id>.<table>"
771
803
  name: string;
772
804
  doc?: string;
773
805
  columns: Column[];
774
806
  indexes?: TableIndex[];
775
807
  /** The domain object this table holds: an aggregate id, and optionally a block id. */
776
- persists?: { aggregate?: string; block?: string };
808
+ persists?: { aggregate?: string; block?: string; evidence?: RelationEvidence[] };
777
809
  role?: TableRole;
778
810
  /** Source-backed repository methods that read or write this table. */
779
811
  accesses?: TableAccess[];
@@ -853,7 +885,7 @@ export interface View {
853
885
  /** The SELECT, as the migration declares it. Shown, never parsed. */
854
886
  definition?: string;
855
887
  /** The domain object this view presents, when it presents exactly one. */
856
- persists?: { aggregate?: string; block?: string };
888
+ persists?: { aggregate?: string; block?: string; evidence?: RelationEvidence[] };
857
889
  /** Migration or model file, as a reader would open it. */
858
890
  source?: string;
859
891
  }
@@ -908,7 +940,18 @@ export interface Participant {
908
940
  label?: string;
909
941
  }
910
942
  export type FlowNode = Step | Parallel | Alt | Loop;
943
+ /** Source facts used to derive a relationship; not a runtime trace. */
944
+ export interface RelationEvidence {
945
+ kind: "call-site" | "function" | "binding" | "contract" | "resolution" | "unresolved";
946
+ rule: string;
947
+ source?: string;
948
+ symbol?: string;
949
+ candidates?: string[];
950
+ }
951
+
911
952
  export interface Step {
953
+ evidence?: RelationEvidence[];
954
+ destination?: HTTPDestination;
912
955
  type: "step";
913
956
  id: string;
914
957
  from: string;
@@ -1352,6 +1352,15 @@ function validateBlocks(catalog: Catalog, aggregate: Aggregate): void {
1352
1352
 
1353
1353
  validateEnums(aggregate);
1354
1354
 
1355
+ if (aggregate.kind !== undefined && aggregate.kind !== "model-group") {
1356
+ fail(`aggregate "${aggregate.id}" has an unknown kind`, `aggregate ${aggregate.id}`);
1357
+ }
1358
+ if (aggregate.kind === "model-group") {
1359
+ if (aggregate.root !== "" || aggregate.lifecycle) {
1360
+ fail(`model group "${aggregate.id}" cannot declare an aggregate root or lifecycle`, `aggregate ${aggregate.id}`);
1361
+ }
1362
+ return;
1363
+ }
1355
1364
  if (!aggregate.root) {
1356
1365
  fail(
1357
1366
  `aggregate "${aggregate.id}" names no root entity`,
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { KindIcon } from "../components/kind";
8
8
  import type { Kind } from "../lib/kinds";
9
- import { activeCatalogProfile, catalog } from "../data";
9
+ import { activeCatalogDocs, catalog } from "../data";
10
10
  import type { ChatPageContext } from "./page-context";
11
11
  import { contextQuestions } from "./page-context";
12
12
 
@@ -36,8 +36,6 @@ function examples(): Example[] {
36
36
  return out;
37
37
  }
38
38
 
39
- const base = import.meta.env.BASE_URL;
40
-
41
39
  export function Starter({
42
40
  onAsk,
43
41
  page,
@@ -51,10 +49,6 @@ export function Starter({
51
49
  question,
52
50
  }))
53
51
  : examples();
54
- const profileSuffix =
55
- activeCatalogProfile.id === "portolan" ? "" : `${activeCatalogProfile.id}/`;
56
- const docsRoot = `${base}docs/${profileSuffix}`;
57
- const indexHref = profileSuffix ? `${docsRoot}llms.txt` : `${base}llms.txt`;
58
52
 
59
53
  return (
60
54
  <div className="pt-1">
@@ -79,10 +73,10 @@ export function Starter({
79
73
  <div className="mono truncate text-muted" title={page.id}>{page.id}</div>
80
74
  </div>
81
75
  </div>
82
- <div className="mono mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-line pt-2 text-muted">
76
+ {activeCatalogDocs ? <div className="mono mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-line pt-2 text-muted">
83
77
  <span className="text-faint">model context</span>
84
78
  <a
85
- href={indexHref}
79
+ href={activeCatalogDocs.index}
86
80
  target="_blank"
87
81
  rel="noreferrer"
88
82
  className="rounded-control text-accent hover:underline"
@@ -91,7 +85,7 @@ export function Starter({
91
85
  </a>
92
86
  {page.docPath ? (
93
87
  <a
94
- href={`${docsRoot}${page.docPath}`}
88
+ href={`${activeCatalogDocs.pages}${page.docPath}`}
95
89
  target="_blank"
96
90
  rel="noreferrer"
97
91
  className="max-w-full truncate rounded-control text-accent hover:underline"
@@ -100,7 +94,7 @@ export function Starter({
100
94
  current catalog page
101
95
  </a>
102
96
  ) : null}
103
- </div>
97
+ </div> : null}
104
98
  </div>
105
99
  ) : null}
106
100
  <div className="label mt-5 mb-1">try one</div>
@@ -0,0 +1,27 @@
1
+ import { afterEach, expect, it, vi } from "vitest";
2
+ import { catalogDocs } from "../catalog-docs";
3
+
4
+ afterEach(() => {
5
+ vi.unstubAllGlobals();
6
+ vi.doUnmock("../data");
7
+ vi.resetModules();
8
+ });
9
+
10
+ it.each([
11
+ { id: "default", generate: [{ plugin: "markdown", out: "docs" }], index: "/portolan/llms.txt", pages: "/portolan/docs/" },
12
+ { id: "example", generate: [
13
+ { plugin: "markdown", catalog: "portolan", out: "docs" },
14
+ { plugin: "markdown", catalog: "example", out: "docs/example" },
15
+ ], index: "/portolan/docs/example/llms.txt", pages: "/portolan/docs/example/" },
16
+ ])("loads the index and catalog pages for $id", async ({ id, generate, index, pages }) => {
17
+ vi.doMock("../data", () => ({ activeCatalogDocs: catalogDocs({ generate }, id, "/portolan/") }));
18
+ const fetcher = vi.fn().mockResolvedValue(new Response("# Catalog documentation"));
19
+ vi.stubGlobal("fetch", fetcher);
20
+ const tools = await import("./tools");
21
+
22
+ expect(await tools.loadIndex()).toBe("# Catalog documentation");
23
+ expect(fetcher).toHaveBeenLastCalledWith(index);
24
+ fetcher.mockResolvedValue(new Response("# Auth service"));
25
+ expect(await tools.readPage("docs/auth/README.md")).toBe("# Auth service");
26
+ expect(fetcher).toHaveBeenLastCalledWith(`${pages}auth/README.md`);
27
+ });
package/src/chat/tools.ts CHANGED
@@ -6,19 +6,14 @@
6
6
 
7
7
  import { jsonSchema, tool } from "ai";
8
8
  import { clipPage, pagePath, TOOL_SPECS } from "./prompt";
9
- import { activeCatalogProfile } from "../data";
10
-
11
- const base = import.meta.env.BASE_URL;
12
- const profileSuffix =
13
- activeCatalogProfile.id === "portolan" ? "" : `${activeCatalogProfile.id}/`;
14
- const docsBase = `${base}docs/${profileSuffix}`;
15
- const indexUrl = profileSuffix ? `${docsBase}llms.txt` : `${base}llms.txt`;
9
+ import { activeCatalogDocs } from "../data";
16
10
 
17
11
  /** One page of the generated docs, as the model gets it. */
18
12
  export async function readPage(raw: unknown): Promise<string> {
19
13
  const path = pagePath(raw);
20
14
  if (!path) return "That is not a page of the catalog. Use a path from the index.";
21
- const response = await fetch(`${docsBase}${path.slice("docs/".length)}`);
15
+ if (!activeCatalogDocs) return "No documentation is configured for this catalog.";
16
+ const response = await fetch(`${activeCatalogDocs.pages}${path.slice("docs/".length)}`);
22
17
  if (!response.ok) return `No page at ${path}. Use a path from the index.`;
23
18
  return clipPage(await response.text());
24
19
  }
@@ -26,7 +21,8 @@ export async function readPage(raw: unknown): Promise<string> {
26
21
  /** llms.txt, once per session. */
27
22
  let indexPromise: Promise<string> | null = null;
28
23
  export function loadIndex(): Promise<string> {
29
- indexPromise ??= fetch(indexUrl).then((response) => {
24
+ if (!activeCatalogDocs) return Promise.reject(new Error("No documentation is configured for this catalog."));
25
+ indexPromise ??= fetch(activeCatalogDocs.index).then((response) => {
30
26
  if (!response.ok) {
31
27
  indexPromise = null;
32
28
  throw new Error("the catalog index (llms.txt) could not be read");
@@ -6,23 +6,25 @@ import { absoluteTime, plural, relativeTime } from "../lib/format";
6
6
  /**
7
7
  * Where the catalog on this page came from, and when.
8
8
  *
9
- * The header says one date and one commit, and both are summaries of a corpus:
10
- * the date is the OLDEST of the sources, because a merged catalog is exactly
11
- * as fresh as its stalest part, and the commit is a count whenever the sources
12
- * do not agree on one. A summary of many numbers is the right thing to put in
13
- * a header and the wrong thing to leave a reader with - "6 sources · 15 hours
9
+ * A source is dated by the commit that last changed it, read off the
10
+ * checkout's history rather than out of the file (portolan.0010). The header
11
+ * says one date and one commit, and both are summaries of a corpus: the date
12
+ * is the OLDEST of the sources, because a merged catalog is exactly as fresh
13
+ * as its stalest part, and the commit is a count whenever the sources do not
14
+ * agree on one. A summary of many numbers is the right thing to put in a
15
+ * header and the wrong thing to leave a reader with - "6 sources · 15 hours
14
16
  * ago" answers "is this current" and refuses "which part is not", which is the
15
17
  * question anyone who did not like the first answer asks next.
16
18
  *
17
19
  * So the stamp opens. Behind it is one row per commit, newest first, and the
18
20
  * row the header is quoting says so. Rows are per COMMIT and not per file
19
- * because twenty-eight fragments written by one commit are one source of
21
+ * because twenty-eight fragments changed by one commit are one source of
20
22
  * facts, and it is the same count the header prints.
21
23
  */
22
24
  export function CatalogStamp() {
23
25
  const groups = byCommit(catalogSources);
24
26
  const authored = catalogSources.filter((source) => !source.commit);
25
- const summary = `catalog generated ${absoluteTime(catalog.generatedAt)} from commit ${catalog.commit}`;
27
+ const summary = `catalog last changed ${absoluteTime(catalog.generatedAt)}, ${catalog.commit}`;
26
28
 
27
29
  return (
28
30
  <Popover className="ml-auto">
@@ -43,7 +45,7 @@ export function CatalogStamp() {
43
45
  >
44
46
  <div className="label mb-1.5 px-1">
45
47
  {groups.length} {plural(groups.length, "source")}, by the commit each
46
- was generated from
48
+ last changed in
47
49
  </div>
48
50
  <dl className="mono grid grid-cols-[auto_1fr_auto] items-baseline gap-x-3 gap-y-1 px-1">
49
51
  {groups.map((group, i) => (
@@ -0,0 +1,23 @@
1
+ import { renderToStaticMarkup } from "react-dom/server";
2
+ import { expect, it } from "vitest";
3
+ import { HTTPDestinationEvidence } from "./HTTPDestinationEvidence";
4
+
5
+ it("shows the join and the config default behind a resolved destination", () => {
6
+ const html = renderToStaticMarkup(<HTTPDestinationEvidence destination={{
7
+ callSite: "client.go:10", endpointExpression: "c.baseURL + path", method: "POST",
8
+ localPath: "/get-admin-settings", fullPath: "/settings/get-admin-settings",
9
+ baseURL: { expression: "cfg.SettingAddr", configField: "Config.SettingAddr", environmentVariable: "SETTINGS_ADDR", kind: "config-default", value: "http://localhost:8000/settings", source: "config.go:84", optionSource: "modules_tree.go:212" },
10
+ join: { expression: "c.baseURL + path", source: "client.go:10" },
11
+ resolution: { basis: "full-path", provider: "avia.aviaadmin", route: "/settings/get-admin-settings" },
12
+ }} />);
13
+ for (const value of ["Recovered full path", "Config default (runtime may override)", "SETTINGS_ADDR", "modules_tree.go:212", "Config.SettingAddr", "/settings/get-admin-settings", "avia.aviaadmin"]) expect(html).toContain(value);
14
+ expect(html).not.toContain("heuristic");
15
+ });
16
+
17
+ it("labels suffix resolution as a heuristic", () => {
18
+ const html = renderToStaticMarkup(<HTTPDestinationEvidence destination={{
19
+ callSite: "client.go:10", endpointExpression: "/settings", method: "POST",
20
+ resolution: { basis: "unique-suffix", provider: "admin", route: "/admin/settings" },
21
+ }} />);
22
+ expect(html).toContain("Unique route suffix (heuristic)");
23
+ });
@@ -0,0 +1,31 @@
1
+ import type { HTTPDestination } from "../catalog";
2
+
3
+ /** Display the facts for this invocation, including the limits of defaults. */
4
+ export function HTTPDestinationEvidence({ destination: d }: { destination: HTTPDestination }) {
5
+ const basis = d.resolution?.basis;
6
+ const rows = [
7
+ ["Linked by", basis === "full-path" ? "Recovered full path" : basis === "exact-route" ? "Exact route" : basis === "unique-suffix" ? "Unique route suffix (heuristic)" : "Unresolved"],
8
+ ["Call site", d.callSite],
9
+ ["Endpoint expression", d.endpointExpression],
10
+ ["Local path", d.localPath],
11
+ ["Base URL expression", d.baseURL?.expression],
12
+ ["Config field", d.baseURL?.configField],
13
+ ["Environment variable", d.baseURL?.environmentVariable],
14
+ [d.baseURL?.kind === "config-default" ? "Config default (runtime may override)" : "Base URL", d.baseURL?.value],
15
+ ["Config source", d.baseURL?.source],
16
+ ["Option call site", d.baseURL?.optionSource],
17
+ ["Service discovery alias", d.serviceDiscoveryAlias],
18
+ ["Join expression", d.join?.expression],
19
+ ["Join source", d.join?.source],
20
+ ["Full path", d.fullPath],
21
+ ...(d.transforms ?? []).map((transform) => ["Runtime URL modifier (not evaluated)", `${transform.expression} · ${transform.source}`]),
22
+ ["Provider", d.resolution?.provider],
23
+ ["Provider route", d.resolution?.route],
24
+ ].filter(([, value]) => value);
25
+ return <section aria-label="HTTP destination evidence" className="border-t border-line px-3 py-3">
26
+ <h3 className="label mb-2">HTTP destination evidence</h3>
27
+ <dl className="grid min-w-0 grid-cols-1 gap-y-3">
28
+ {rows.map(([label, value], index) => <div key={`${label}-${index}`} className="min-w-0"><dt className="text-muted">{label}</dt><dd className="mono break-all text-ink">{value}</dd></div>)}
29
+ </dl>
30
+ </section>;
31
+ }
@@ -210,7 +210,7 @@ export function Integrations({ groups }: { groups: IntegrationGroup[] }) {
210
210
  <RowActions copy={call.id} label={call.id} />
211
211
  {method?.http ? (
212
212
  <p className="mono w-full text-muted">
213
- {method.http.method} {method.http.path}
213
+ {method.http.method ? `${method.http.method} ${method.http.path}` : method.http.path}
214
214
  </p>
215
215
  ) : null}
216
216
  {call.note ? (
@@ -1,5 +1,5 @@
1
1
  import { FileText } from "lucide-react";
2
- import { activeCatalogProfile } from "../data";
2
+ import { activeCatalogDocs, activeCatalogProfile } from "../data";
3
3
 
4
4
  // The site as a language model reads it. The build (scripts/site-docs.mjs)
5
5
  // places the generated markdown under docs/ and llms.txt at the root, in the
@@ -10,11 +10,12 @@ import { activeCatalogProfile } from "../data";
10
10
  const base = import.meta.env.BASE_URL;
11
11
 
12
12
  const suffix = activeCatalogProfile.id === "portolan" ? "" : `${activeCatalogProfile.id}/`;
13
- const docs = `${base}docs/${suffix}`;
14
13
  const FILES = [
15
- { href: suffix ? `${docs}llms.txt` : `${base}llms.txt`, label: "llms.txt", title: "An index of every page, for a model that fetches on demand" },
16
- { href: suffix ? `${docs}llms-full.txt` : `${base}llms-full.txt`, label: "llms-full.txt", title: "Every page in one file, for a model with a context window" },
17
- { href: docs, label: "docs/", title: "The generated markdown, page by page" },
14
+ ...(activeCatalogDocs ? [
15
+ { href: activeCatalogDocs.index, label: "llms.txt", title: "An index of every page, for a model that fetches on demand" },
16
+ { href: activeCatalogDocs.full, label: "llms-full.txt", title: "Every page in one file, for a model with a context window" },
17
+ { href: activeCatalogDocs.pages, label: "docs/", title: "The generated markdown, page by page" },
18
+ ] : []),
18
19
  { href: `${base}exports/mermaid/${suffix}`, label: "Mermaid", title: "Standalone sequence diagrams for every architecture flow" },
19
20
  ...(activeCatalogProfile.id === "example"
20
21
  ? [{ href: `${base}exports/backstage/catalog-info.yaml`, label: "Backstage", title: "Components, systems, APIs and resources for a Backstage catalog" }]
@@ -167,8 +167,15 @@ export function MethodRows({
167
167
  className={method.deprecated ? "line-through" : undefined}
168
168
  />
169
169
  {method.http ? (
170
- <span className="chip mono" title="the route this operation answers on">
171
- {method.http.method} {method.http.path}
170
+ <span
171
+ className="chip mono"
172
+ title={
173
+ method.http.method
174
+ ? "the route this operation answers on"
175
+ : "the route this operation answers on; the HTTP verb is not declared in source"
176
+ }
177
+ >
178
+ {method.http.method ? `${method.http.method} ${method.http.path}` : method.http.path}
172
179
  </span>
173
180
  ) : null}
174
181
  {method.soap ? (
@@ -0,0 +1,14 @@
1
+ import { renderToStaticMarkup } from "react-dom/server";
2
+ import { expect, it } from "vitest";
3
+ import { RelationEvidencePanel } from "./RelationEvidence";
4
+
5
+ it("explains an ambiguous binding without choosing a candidate", () => {
6
+ const html = renderToStaticMarkup(<RelationEvidencePanel items={[
7
+ { kind: "binding", rule: "provider-signature", source: "di.go:12", symbol: "Build" },
8
+ { kind: "unresolved", rule: "ambiguous-binding", candidates: ["First", "Second"] },
9
+ ]} />);
10
+ for (const value of ["Why this relation exists", "inferred", "di.go:12", "Several implementations remain possible", "First", "Second"]) expect(html).toContain(value);
11
+ });
12
+ it("shows the absence of evidence explicitly", () => {
13
+ expect(renderToStaticMarkup(<RelationEvidencePanel items={[]} />)).toContain("No source evidence recorded");
14
+ });
@@ -0,0 +1,53 @@
1
+ import type { ReactNode } from "react";
2
+ import type { RelationEvidence } from "../catalog";
3
+
4
+ const labels: Record<RelationEvidence["kind"], string> = {
5
+ "call-site": "Call site", function: "Function", binding: "Dependency binding",
6
+ contract: "Contract", resolution: "Catalog resolution", unresolved: "Unresolved",
7
+ };
8
+ const rules: Record<string, string> = {
9
+ "source-expression": "Expression in source",
10
+ "source-function": "Enclosing source function",
11
+ "provider-signature": "Provider signature (inferred)",
12
+ "store-port-convention": "Storage field, type or package name (inferred)",
13
+ "domain-port-convention": "Domain port mapped to the configured store (inferred)",
14
+ "ambiguous-binding": "Several implementations remain possible",
15
+ "generated-client-method": "Method declared by generated client",
16
+ "http-expression": "Outbound HTTP expression",
17
+ "analyzed-call-path": "Static analysis path; not a runtime trace",
18
+ "client-contract": "Client contract",
19
+ "catalog-contract": "Matching interface and method",
20
+ "event-declaration": "Declared event",
21
+ "repository-sql-call": "SQL operation inside repository",
22
+ "full-path": "Recovered full HTTP path",
23
+ "exact-route": "Exact HTTP route",
24
+ "unique-suffix": "Unique route suffix (heuristic)",
25
+ "continuation-entrypoint": "Matched continuation entrypoint",
26
+ "response-to-request": "Response associated with request",
27
+ "unresolved-target": "No unique target established",
28
+ "sql-alter-table": "ALTER TABLE in migration",
29
+ "sql-view-definition": "SQL view definition",
30
+ "sql-create-table": "CREATE TABLE in migration",
31
+ "domain-root-and-repository-layout": "Domain root and repository convention (inferred)",
32
+ "migration-aggregate-annotation": "Explicit aggregate annotation in migration",
33
+ };
34
+
35
+ /** One explanation surface for all flow kinds and schema relationships. */
36
+ export function RelationEvidencePanel({ items, renderSource }: {
37
+ items: readonly RelationEvidence[];
38
+ renderSource?: (source: string) => ReactNode;
39
+ }) {
40
+ return <section aria-label="Why this relation exists" className="my-3 rounded-card border border-line bg-surface p-3">
41
+ <h3 className="label mb-2">Why this relation exists</h3>
42
+ {items.length === 0 ? <p className="text-muted">No source evidence recorded in this fragment.</p> :
43
+ <ol className="space-y-3">
44
+ {items.map((item, i) => <li key={`${item.kind}-${i}`} className="min-w-0 border-l-2 border-line pl-3">
45
+ <div className={item.kind === "unresolved" ? "text-[var(--status-unresolved)]" : "text-ink"}>{labels[item.kind]}</div>
46
+ <div className="text-muted">{rules[item.rule] ?? item.rule}</div>
47
+ {item.symbol ? <div className="mono break-all">{item.symbol}</div> : null}
48
+ {item.source ? <div className="mono break-all text-muted">{renderSource ? renderSource(item.source) : item.source}</div> : null}
49
+ {item.candidates?.length ? <div className="mt-1"><span className="text-muted">Candidates</span><ul>{item.candidates.map((candidate) => <li className="mono break-all" key={candidate}>{candidate}</li>)}</ul></div> : null}
50
+ </li>)}
51
+ </ol>}
52
+ </section>;
53
+ }
package/src/data.ts CHANGED
@@ -32,6 +32,11 @@ import type {
32
32
  SourceStamp,
33
33
  } from "./merge";
34
34
  import manifestJson from "../portolan.json";
35
+ import { catalogDocs } from "./catalog-docs";
36
+ // When each source last changed, as the checkout's history says - served by
37
+ // scripts/provenance.mjs at build time, because a fragment carries no
38
+ // provenance of its own (portolan.0010) and a browser cannot ask git.
39
+ import provenance from "virtual:portolan-provenance";
35
40
 
36
41
  const manifest = manifestJson as CatalogProfileManifest & { sources: string[] };
37
42
  export const catalogProfiles: CatalogProfile[] = profilesFromManifest(manifest);
@@ -56,6 +61,8 @@ export const activeCatalogProfile = catalogProfileNamed(
56
61
  requestedProfileFromUrl(),
57
62
  );
58
63
 
64
+ export const activeCatalogDocs = catalogDocs(manifestJson, activeCatalogProfile.id, import.meta.env.BASE_URL);
65
+
59
66
  /**
60
67
  * Where sources are looked for. The patterns are written out because
61
68
  * import.meta.glob resolves at build time and needs literals - and because
@@ -120,15 +127,26 @@ interface Loaded {
120
127
 
121
128
  function load(): Loaded {
122
129
  const sources: CatalogSource[] = Object.entries(modules)
123
- .map(([path, catalog]) => ({
130
+ .map(([key, catalog]) => {
124
131
  // Vite keys a glob by its pattern-relative path; the leading ../ is an
125
132
  // artefact of this file's location, not part of where anything lives.
126
- path: path.replace(/^\.\.\//, ""),
127
- // A SOURCE, not a catalog: the two stamps are optional in a file, and
128
- // the estate's authored facts carry neither.
129
- catalog: catalog as SourceCatalog,
130
- }))
131
- .filter((source) => profileIncludesSource(activeCatalogProfile, source.path));
133
+ const imported = key.replace(/^\.\.\//, "");
134
+ // A SOURCE, not a catalog: a file carries no stamp of its own, and the
135
+ // history's travels beside it (portolan.0010).
136
+ const { source, ...stamp } = provenance[imported] ?? {};
137
+ return {
138
+ imported,
139
+ // A staged site imports a source under a flattened name; the file is
140
+ // still the one in the workspace, and that is the path a project's
141
+ // root is a prefix of and a link to the file names.
142
+ path: source ?? imported,
143
+ catalog: catalog as SourceCatalog,
144
+ stamp: "commit" in stamp ? stamp : undefined,
145
+ };
146
+ })
147
+ // The profile spells its sources the way the site imports them.
148
+ .filter((source) => profileIncludesSource(activeCatalogProfile, source.imported))
149
+ .map(({ imported: _imported, ...source }) => source);
132
150
 
133
151
  const merged = mergeCatalogs(sources);
134
152
  // Enriched before it is validated: the edges the flows imply are part of