@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.
- package/README.md +23 -0
- package/catalog/enum_test.go +46 -0
- package/catalog/evidence_test.go +35 -0
- package/catalog/model.go +1066 -0
- package/catalog/roundtrip_test.go +203 -0
- package/catalog/via_test.go +38 -0
- package/cli/init.test.mjs +6 -1
- package/cli/portolan.mjs +8 -0
- package/cli/portolan.test.mjs +49 -0
- package/go.mod +14 -0
- package/go.sum +20 -0
- package/internal/gocall/README.md +19 -0
- package/internal/gocall/analyze.go +189 -0
- package/internal/gocall/analyze_test.go +107 -0
- package/internal/gohttp/analyze.go +2562 -0
- package/internal/gohttp/destination.go +373 -0
- package/internal/gohttp/endpoints.go +1067 -0
- package/internal/gohttp/roots.go +320 -0
- package/internal/gohttp/typed.go +96 -0
- package/internal/goscan/constants.go +85 -0
- package/internal/goscan/goscan_test.go +227 -0
- package/internal/goscan/index.go +629 -0
- package/internal/goscan/index_test.go +66 -0
- package/internal/goscan/names.go +52 -0
- package/internal/goscan/parse_test.go +11 -0
- package/internal/goscan/source.go +37 -0
- package/internal/goscan/tree.go +284 -0
- package/internal/goscan/types.go +99 -0
- package/internal/wsdl/ids.go +127 -0
- package/internal/wsdl/ids_test.go +21 -0
- package/internal/wsdl/model.go +70 -0
- package/internal/wsdl/parse.go +949 -0
- package/internal/wsdl/parse_test.go +170 -0
- package/package.json +22 -10
- package/plugin/describe.go +118 -0
- package/plugin/describe_test.go +114 -0
- package/plugin/protocol.go +141 -0
- package/plugin/schematest/schematest.go +126 -0
- package/plugins/README.md +109 -46
- package/plugins/cmd/portolan-http-clients/main.go +19 -0
- package/plugins/extract-celery/extract.py +0 -2
- package/plugins/extract-celery/extract_test.py +1 -1
- package/plugins/extract-django/README.md +39 -17
- package/plugins/extract-django/domain.py +28 -17
- package/plugins/extract-django/extract.py +21 -7
- package/plugins/extract-django/extract_test.py +55 -2
- package/plugins/extract-django/lifecycle.py +2 -0
- package/plugins/extract-django/operations.py +1 -1
- package/plugins/extract-django/routing_test.py +109 -1
- package/plugins/extract-django/store.py +1 -1
- package/plugins/extract-django/transport.py +101 -55
- package/plugins/extract-django/verbs.py +241 -0
- package/plugins/extract-go/README.md +47 -0
- package/plugins/extract-http-clients/describe.go +19 -0
- package/plugins/extract-http-clients/describe_test.go +11 -0
- package/plugins/extract-http-clients/extract.go +740 -0
- package/plugins/extract-http-clients/extract_test.go +1561 -0
- package/plugins/extract-http-clients/main.go +41 -0
- package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
- package/plugins/extract-python-kafka/extract.py +0 -2
- package/plugins/extract-python-kafka/extract_test.py +1 -1
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/extract.ts +4 -5
- package/plugins/extract-ts/graphql.test.ts +1 -1
- package/plugins/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/protocol.py +1 -5
- package/portolan.json +3 -2
- package/schema/portolan.schema.json +34 -0
- package/scripts/README.md +18 -12
- package/scripts/catalog-sources.mjs +6 -0
- package/scripts/delivery-presets.mjs +21 -11
- package/scripts/diff.mjs +5 -1
- package/scripts/django-aggregates.test.mjs +58 -0
- package/scripts/gen-likec4.mjs +1 -1
- package/scripts/gen.mjs +118 -115
- package/scripts/go-discovery.test.mjs +30 -0
- package/scripts/history.mjs +186 -3
- package/scripts/history.test.mjs +1 -1
- package/scripts/host-plugins/fetch-git.mjs +77 -21
- package/scripts/host-plugins/fetch-git.test.mjs +62 -8
- package/scripts/local-api.mjs +71 -4
- package/scripts/local-api.test.mjs +63 -4
- package/scripts/local-discovery.mjs +82 -9
- package/scripts/manifest.mjs +5 -3
- package/scripts/manifest.test.mjs +24 -0
- package/scripts/output-diff.mjs +94 -0
- package/scripts/output-diff.test.mjs +36 -0
- package/scripts/package-smoke.mjs +62 -4
- package/scripts/plugin-host.mjs +22 -2
- package/scripts/plugin-host.test.mjs +9 -0
- package/scripts/plugin-wasm-worker.mjs +4 -1
- package/scripts/provenance.mjs +72 -0
- package/scripts/provenance.test.mjs +149 -0
- package/scripts/run-builtin.mjs +39 -5
- package/scripts/schema.mjs +29 -0
- package/scripts/warning-policy.mjs +161 -0
- package/scripts/warning-policy.test.mjs +56 -0
- package/src/app/Sidebar.tsx +3 -3
- package/src/catalog-docs.test.ts +64 -0
- package/src/catalog-docs.ts +35 -0
- package/src/catalog-error.test.ts +15 -0
- package/src/catalog-model.ts +48 -5
- package/src/catalog-validation.ts +9 -0
- package/src/chat/Starter.tsx +5 -11
- package/src/chat/tools.test.ts +27 -0
- package/src/chat/tools.ts +5 -9
- package/src/components/CatalogStamp.tsx +10 -8
- package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
- package/src/components/HTTPDestinationEvidence.tsx +31 -0
- package/src/components/Integrations.tsx +1 -1
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/data.ts +25 -7
- package/src/enrich.test.ts +332 -1
- package/src/enrich.ts +206 -3
- package/src/flow/StepDetail.tsx +6 -0
- package/src/flow/evidence.test.ts +16 -0
- package/src/flow/evidence.ts +34 -0
- package/src/index.css +44 -0
- package/src/landing/DraggableReveal.tsx +3 -2
- package/src/landing/EvidencePipeline.tsx +105 -0
- package/src/landing/LandingPage.tsx +2 -59
- package/src/lib/catalog-diff.ts +1 -1
- package/src/lib/django-aggregates.d.mts +9 -0
- package/src/lib/django-aggregates.mjs +36 -0
- package/src/lib/django-aggregates.test.ts +29 -0
- package/src/lib/django-aggregates.ts +5 -0
- package/src/lib/local-api.ts +20 -2
- package/src/lib/setup-info.test.ts +17 -0
- package/src/lib/setup-info.ts +58 -0
- package/src/lib/warnings.test.ts +54 -0
- package/src/lib/warnings.ts +260 -0
- package/src/map/ContextMapGraph.tsx +76 -32
- package/src/merge.ts +16 -9
- package/src/pages/AggregatePage.tsx +8 -7
- package/src/pages/ContextPage.tsx +6 -5
- package/src/pages/ServicePage.tsx +4 -3
- package/src/pages/Settings.tsx +189 -41
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- package/src/selection/DetailPanel.tsx +15 -0
- package/src/virtual-provenance.d.ts +11 -0
- package/vite.config.ts +5 -0
- package/scripts/vendor-lock.mjs +0 -58
- 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];
|
package/src/catalog-model.ts
CHANGED
|
@@ -297,7 +297,11 @@ export interface RpcMethod {
|
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
export interface HttpRoute {
|
|
300
|
-
/**
|
|
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
|
-
/**
|
|
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`,
|
package/src/chat/Starter.tsx
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { KindIcon } from "../components/kind";
|
|
8
8
|
import type { Kind } from "../lib/kinds";
|
|
9
|
-
import {
|
|
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={
|
|
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={`${
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
171
|
-
|
|
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(([
|
|
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
|
-
|
|
127
|
-
// A SOURCE, not a catalog:
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|