@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
package/src/index.css
CHANGED
|
@@ -681,6 +681,50 @@
|
|
|
681
681
|
outline: 2px solid color-mix(in srgb, var(--accent) 55%, white);
|
|
682
682
|
outline-offset: 3px;
|
|
683
683
|
}
|
|
684
|
+
.product-danger {
|
|
685
|
+
color: white;
|
|
686
|
+
border-color: #a33a4b;
|
|
687
|
+
background:
|
|
688
|
+
radial-gradient(
|
|
689
|
+
circle at 18% 0%,
|
|
690
|
+
rgb(255 255 255 / 0.16),
|
|
691
|
+
transparent 42%
|
|
692
|
+
),
|
|
693
|
+
linear-gradient(
|
|
694
|
+
to bottom,
|
|
695
|
+
#8e2b3d,
|
|
696
|
+
#651827
|
|
697
|
+
);
|
|
698
|
+
box-shadow:
|
|
699
|
+
inset 0 1px 0 rgb(255 255 255 / 0.2),
|
|
700
|
+
inset 0 -1px 0 rgb(0 0 0 / 0.28),
|
|
701
|
+
0 1px 2px rgb(0 0 0 / 0.24),
|
|
702
|
+
0 12px 28px -18px color-mix(in srgb, var(--status-unresolved) 84%, transparent);
|
|
703
|
+
text-shadow: 0 1px 0 rgb(0 0 0 / 0.32);
|
|
704
|
+
}
|
|
705
|
+
.product-danger:hover {
|
|
706
|
+
color: white;
|
|
707
|
+
border-color: #c45a6b;
|
|
708
|
+
background:
|
|
709
|
+
radial-gradient(
|
|
710
|
+
circle at 18% 0%,
|
|
711
|
+
rgb(255 255 255 / 0.2),
|
|
712
|
+
transparent 42%
|
|
713
|
+
),
|
|
714
|
+
linear-gradient(
|
|
715
|
+
to bottom,
|
|
716
|
+
#a13246,
|
|
717
|
+
#741b2c
|
|
718
|
+
);
|
|
719
|
+
box-shadow:
|
|
720
|
+
inset 0 1px 0 rgb(255 255 255 / 0.26),
|
|
721
|
+
inset 0 -1px 0 rgb(0 0 0 / 0.3),
|
|
722
|
+
0 2px 4px rgb(0 0 0 / 0.25),
|
|
723
|
+
0 16px 34px -17px color-mix(in srgb, var(--status-unresolved) 96%, transparent);
|
|
724
|
+
}
|
|
725
|
+
.product-danger:focus-visible {
|
|
726
|
+
outline-color: color-mix(in srgb, var(--status-unresolved) 62%, white);
|
|
727
|
+
}
|
|
684
728
|
.product-primary:disabled,
|
|
685
729
|
.tbtn:disabled {
|
|
686
730
|
cursor: not-allowed;
|
|
@@ -105,7 +105,9 @@ export function DraggableReveal({
|
|
|
105
105
|
className={`pointer-events-none absolute inset-0 z-0 flex ${catPlacement[revealEdge]}`}
|
|
106
106
|
>
|
|
107
107
|
<m.img
|
|
108
|
-
layout
|
|
108
|
+
// Deliberately not a layout animation: the preview's children can
|
|
109
|
+
// reflow independently, and their tab changes must not send this
|
|
110
|
+
// hidden illustration across the background.
|
|
109
111
|
src={cat}
|
|
110
112
|
alt=""
|
|
111
113
|
width={800}
|
|
@@ -118,7 +120,6 @@ export function DraggableReveal({
|
|
|
118
120
|
y: dragging ? -3 : 0,
|
|
119
121
|
}}
|
|
120
122
|
transition={{
|
|
121
|
-
layout: { type: "spring", stiffness: 260, damping: 28 },
|
|
122
123
|
opacity: { duration: 0.22 },
|
|
123
124
|
scale: { duration: 0.28 },
|
|
124
125
|
y: { duration: 0.28 },
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { ArrowRight, Braces, CircleDotDashed, Code2, Network } from "lucide-react";
|
|
2
|
+
import { useRef } from "react";
|
|
3
|
+
import { useInView } from "motion/react";
|
|
4
|
+
import { m, useReducedMotion } from "../lib/motion";
|
|
5
|
+
|
|
6
|
+
const steps = [
|
|
7
|
+
{
|
|
8
|
+
icon: Code2,
|
|
9
|
+
label: "01 / read",
|
|
10
|
+
title: "Code and specifications",
|
|
11
|
+
copy: "Existing source, schemas, contracts, ADRs and traces.",
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
icon: Braces,
|
|
15
|
+
label: "02 / extract",
|
|
16
|
+
title: "Local fragments",
|
|
17
|
+
copy: "Each service publishes the architectural facts it owns.",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
icon: CircleDotDashed,
|
|
21
|
+
label: "03 / validate",
|
|
22
|
+
title: "One estate model",
|
|
23
|
+
copy: "Portolan merges the union and checks every relationship.",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
icon: Network,
|
|
27
|
+
label: "04 / publish",
|
|
28
|
+
title: "A static catalog",
|
|
29
|
+
copy: "Browse the site or export Markdown, Mermaid and Backstage entities.",
|
|
30
|
+
},
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
// One shared cycle keeps the arrows in sync, including the quiet pause.
|
|
34
|
+
const cycle = 6.8;
|
|
35
|
+
const pulse = (index: number) => {
|
|
36
|
+
const start = 0.4 + index * 0.85;
|
|
37
|
+
return {
|
|
38
|
+
duration: cycle,
|
|
39
|
+
repeat: Infinity,
|
|
40
|
+
ease: "easeInOut" as const,
|
|
41
|
+
times: [0, start, start + 0.2, start + 0.65, start + 1.15, cycle].map(
|
|
42
|
+
(time) => time / cycle,
|
|
43
|
+
),
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function EvidencePipeline() {
|
|
48
|
+
const ref = useRef<HTMLOListElement>(null);
|
|
49
|
+
const inView = useInView(ref, { amount: 0.2 });
|
|
50
|
+
const reduced = useReducedMotion();
|
|
51
|
+
const playing = inView && !reduced;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<ol
|
|
55
|
+
ref={ref}
|
|
56
|
+
aria-label="How Portolan turns evidence into architecture documentation"
|
|
57
|
+
className="mt-12 grid gap-4 md:grid-cols-2 xl:grid-cols-4"
|
|
58
|
+
>
|
|
59
|
+
{steps.map((item, index) => {
|
|
60
|
+
const Icon = item.icon;
|
|
61
|
+
const continues = index < steps.length - 1;
|
|
62
|
+
const connectsWithinRow = index === 0 || index === 2;
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<li
|
|
66
|
+
key={item.title}
|
|
67
|
+
className="relative rounded-card border border-line bg-canvas p-5 shadow-xs"
|
|
68
|
+
>
|
|
69
|
+
<div className="flex items-center justify-between">
|
|
70
|
+
<span className="flow-tile text-accent">
|
|
71
|
+
<Icon size={14} aria-hidden />
|
|
72
|
+
</span>
|
|
73
|
+
<span className="mono text-faint">{item.label}</span>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<h3 className="mt-8 text-md font-semibold">{item.title}</h3>
|
|
77
|
+
<p className="mt-2 text-sm text-muted">{item.copy}</p>
|
|
78
|
+
|
|
79
|
+
{continues ? (
|
|
80
|
+
<span
|
|
81
|
+
aria-hidden
|
|
82
|
+
className={`absolute top-[22px] -right-[17px] z-10 h-[18px] w-4 items-center justify-center bg-canvas text-faint ${
|
|
83
|
+
connectsWithinRow ? "hidden md:flex" : "hidden xl:flex"
|
|
84
|
+
}`}
|
|
85
|
+
>
|
|
86
|
+
<ArrowRight size={11} />
|
|
87
|
+
<m.span
|
|
88
|
+
className="absolute inset-0 flex items-center justify-center text-accent drop-shadow-[0_0_4px_var(--color-accent)]"
|
|
89
|
+
initial={{ opacity: 0, x: -4 }}
|
|
90
|
+
animate={playing ? {
|
|
91
|
+
opacity: [0, 0, 1, 1, 0, 0],
|
|
92
|
+
x: [-4, -4, -2, 2, 4, 4],
|
|
93
|
+
} : { opacity: 0, x: -4 }}
|
|
94
|
+
transition={playing ? pulse(index + 0.65) : { duration: 0.2 }}
|
|
95
|
+
>
|
|
96
|
+
<ArrowRight size={11} />
|
|
97
|
+
</m.span>
|
|
98
|
+
</span>
|
|
99
|
+
) : null}
|
|
100
|
+
</li>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
</ol>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ArrowRight,
|
|
3
3
|
Bot,
|
|
4
|
-
Braces,
|
|
5
4
|
Check,
|
|
6
|
-
CircleDotDashed,
|
|
7
|
-
Code2,
|
|
8
5
|
Copy,
|
|
9
6
|
FileSearch,
|
|
10
7
|
Files,
|
|
11
8
|
FileText,
|
|
12
9
|
GitBranch,
|
|
13
10
|
Moon,
|
|
14
|
-
Network,
|
|
15
11
|
ShieldCheck,
|
|
16
12
|
Sun,
|
|
17
13
|
Terminal,
|
|
@@ -27,6 +23,7 @@ import { m } from "../lib/motion";
|
|
|
27
23
|
import { paths } from "../routes";
|
|
28
24
|
import { catalogTo } from "./catalog";
|
|
29
25
|
import { DraggableReveal } from "./DraggableReveal";
|
|
26
|
+
import { EvidencePipeline } from "./EvidencePipeline";
|
|
30
27
|
import { heroColumn, heroLine, Reveal } from "./motion";
|
|
31
28
|
import { ProductFrame } from "./ProductFrame";
|
|
32
29
|
import { ProductTour } from "./ProductTour";
|
|
@@ -249,33 +246,6 @@ function CopyCommand({
|
|
|
249
246
|
);
|
|
250
247
|
}
|
|
251
248
|
|
|
252
|
-
const pipeline = [
|
|
253
|
-
{
|
|
254
|
-
icon: Code2,
|
|
255
|
-
label: "01 / read",
|
|
256
|
-
title: "Code and specifications",
|
|
257
|
-
copy: "Existing source, schemas, contracts, ADRs and traces.",
|
|
258
|
-
},
|
|
259
|
-
{
|
|
260
|
-
icon: Braces,
|
|
261
|
-
label: "02 / extract",
|
|
262
|
-
title: "Local fragments",
|
|
263
|
-
copy: "Each service publishes the architectural facts it owns.",
|
|
264
|
-
},
|
|
265
|
-
{
|
|
266
|
-
icon: CircleDotDashed,
|
|
267
|
-
label: "03 / validate",
|
|
268
|
-
title: "One estate model",
|
|
269
|
-
copy: "Portolan merges the union and checks every relationship.",
|
|
270
|
-
},
|
|
271
|
-
{
|
|
272
|
-
icon: Network,
|
|
273
|
-
label: "04 / publish",
|
|
274
|
-
title: "A static catalog",
|
|
275
|
-
copy: "Browse the site or export Markdown, Mermaid and Backstage entities.",
|
|
276
|
-
},
|
|
277
|
-
];
|
|
278
|
-
|
|
279
249
|
export function LandingPage() {
|
|
280
250
|
useDocumentTitle("Architecture from code");
|
|
281
251
|
|
|
@@ -422,34 +392,7 @@ export function LandingPage() {
|
|
|
422
392
|
Architecture documentation that begins with evidence.
|
|
423
393
|
</h2>
|
|
424
394
|
</Reveal>
|
|
425
|
-
<
|
|
426
|
-
{pipeline.map((item, index) => {
|
|
427
|
-
const Icon = item.icon;
|
|
428
|
-
return (
|
|
429
|
-
<Reveal
|
|
430
|
-
key={item.title}
|
|
431
|
-
delay={index * 0.07}
|
|
432
|
-
className="relative rounded-card border border-line bg-canvas p-5 shadow-xs"
|
|
433
|
-
>
|
|
434
|
-
<div className="flex items-center justify-between">
|
|
435
|
-
<span className="flow-tile text-accent">
|
|
436
|
-
<Icon size={14} />
|
|
437
|
-
</span>
|
|
438
|
-
<span className="mono text-faint">{item.label}</span>
|
|
439
|
-
</div>
|
|
440
|
-
<h3 className="mt-8 text-md font-semibold">{item.title}</h3>
|
|
441
|
-
<p className="mt-2 text-sm text-muted">{item.copy}</p>
|
|
442
|
-
{index < pipeline.length - 1 ? (
|
|
443
|
-
<ArrowRight
|
|
444
|
-
size={16}
|
|
445
|
-
className="absolute top-1/2 -right-[11px] z-10 hidden -translate-y-1/2 rounded-full bg-canvas text-faint xl:block"
|
|
446
|
-
aria-hidden
|
|
447
|
-
/>
|
|
448
|
-
) : null}
|
|
449
|
-
</Reveal>
|
|
450
|
-
);
|
|
451
|
-
})}
|
|
452
|
-
</div>
|
|
395
|
+
<EvidencePipeline />
|
|
453
396
|
</div>
|
|
454
397
|
</section>
|
|
455
398
|
|
package/src/lib/catalog-diff.ts
CHANGED
|
@@ -245,7 +245,7 @@ function diffOwners(before: Service, after: Service, add: Add): void {
|
|
|
245
245
|
function signature(method: RpcMethod): string {
|
|
246
246
|
const parts = [method.request ?? "?", method.response ?? "?"];
|
|
247
247
|
if (method.streaming) parts.push(method.streaming);
|
|
248
|
-
if (method.http) parts.push(`${method.http.method} ${method.http.path}`);
|
|
248
|
+
if (method.http) parts.push(`${method.http.method || "?"} ${method.http.path}`);
|
|
249
249
|
if (method.soap) {
|
|
250
250
|
parts.push(
|
|
251
251
|
[
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface DjangoAggregateCandidates {
|
|
2
|
+
app: string;
|
|
3
|
+
models: Array<{ name: string; path: string; line: number }>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Evidence emitted by extract-django, carried through the warning protocol. */
|
|
7
|
+
export function djangoAggregateCandidates(message: string): DjangoAggregateCandidates | null;
|
|
8
|
+
|
|
9
|
+
export function djangoAggregateMessage(message: string): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Plain JavaScript on purpose: the CLI's `init` runs this from node_modules,
|
|
2
|
+
// where Node strips no types, so the one parser the host and the browser
|
|
3
|
+
// share cannot be a .ts file. Types live in django-aggregates.d.mts.
|
|
4
|
+
|
|
5
|
+
const MARKER = "; aggregate candidates: ";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Evidence emitted by extract-django, carried through the warning protocol.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} message
|
|
11
|
+
* @returns {import("./django-aggregates.d.mts").DjangoAggregateCandidates | null}
|
|
12
|
+
*/
|
|
13
|
+
export function djangoAggregateCandidates(message) {
|
|
14
|
+
const start = message.lastIndexOf(MARKER);
|
|
15
|
+
if (start < 0) return null;
|
|
16
|
+
try {
|
|
17
|
+
const value = JSON.parse(message.slice(start + MARKER.length));
|
|
18
|
+
if (!value || typeof value.app !== "string" || !/^[\p{L}_][\p{L}\p{N}_]*(?:\.[\p{L}_][\p{L}\p{N}_]*)*$/u.test(value.app)
|
|
19
|
+
|| !Array.isArray(value.models) || !value.models.length) return null;
|
|
20
|
+
const models = [];
|
|
21
|
+
for (const model of value.models) {
|
|
22
|
+
if (!model || typeof model.name !== "string" || !/^[\p{L}_][\p{L}\p{N}_]*$/u.test(model.name)
|
|
23
|
+
|| typeof model.path !== "string" || !model.path || !Number.isSafeInteger(model.line) || model.line < 1) return null;
|
|
24
|
+
models.push({ name: model.name, path: model.path, line: model.line });
|
|
25
|
+
}
|
|
26
|
+
// The extractor's option accepts a class name, so duplicate names cannot
|
|
27
|
+
// be resolved safely by this picker.
|
|
28
|
+
if (new Set(models.map((model) => model.name)).size !== models.length) return null;
|
|
29
|
+
return { app: value.app, models };
|
|
30
|
+
} catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @param {string} message */
|
|
34
|
+
export function djangoAggregateMessage(message) {
|
|
35
|
+
return djangoAggregateCandidates(message) ? message.slice(0, message.lastIndexOf(MARKER)) : message;
|
|
36
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { djangoAggregateCandidates } from "./django-aggregates";
|
|
3
|
+
import { warningDiagnostic } from "./warnings";
|
|
4
|
+
import { publicSetupFrom } from "./setup-info";
|
|
5
|
+
|
|
6
|
+
describe("Django aggregate evidence", () => {
|
|
7
|
+
const candidates = { app: "billing.records", models: Array.from({ length: 29 }, (_, index) => ({ name: `Record${index}`, path: "source/billing/records/models.py", line: index + 1 })) };
|
|
8
|
+
const message = `source/billing/records: no model called Records, and 29 models to choose from: name the root in the aggregates option; aggregate candidates: ${JSON.stringify(candidates)}`;
|
|
9
|
+
|
|
10
|
+
it("retains all candidates beyond the public warning text limit", () => {
|
|
11
|
+
const warning = warningDiagnostic({ plugin: "django-domain", message });
|
|
12
|
+
expect(warning.aggregateCandidates).toEqual(candidates);
|
|
13
|
+
expect(warning.message).not.toContain("aggregate candidates:");
|
|
14
|
+
const setup = publicSetupFrom({ sources: [], extract: [{ plugin: "django-domain", in: "source", out: "data" }] }, {
|
|
15
|
+
version: 1, manifestSha256: "current", mode: "write", status: "ok", startedAt: "2026-09-10T00:00:00Z", finishedAt: "2026-09-10T00:00:01Z", durationMs: 1000,
|
|
16
|
+
steps: [{ ordinal: 0, phase: "extract", plugin: "django-domain", input: "source", output: "data", status: "written", durationMs: 10, fileCount: 1, changedCount: 1, files: ["data/domain.json"], warnings: [message], diagnostics: [{ ...warning, message, count: 1, project: "", phase: "extract" }] }],
|
|
17
|
+
}, "current");
|
|
18
|
+
expect(setup.run?.steps[0]?.diagnostics[0]?.aggregateCandidates).toEqual(candidates);
|
|
19
|
+
expect(setup.run?.steps[0]?.diagnostics[0]?.message.length).toBeLessThan(500);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("leaves old warnings usable and rejects malformed or duplicate choices", () => {
|
|
23
|
+
expect(djangoAggregateCandidates("legacy warning")).toBeNull();
|
|
24
|
+
expect(djangoAggregateCandidates("; aggregate candidates: {broken")).toBeNull();
|
|
25
|
+
for (const value of [{ app: "../records", models: candidates.models }, { ...candidates, models: [candidates.models[0], candidates.models[0]] }, { ...candidates, models: [{ name: "Record", path: "models.py", line: 0 }] }]) {
|
|
26
|
+
expect(djangoAggregateCandidates(`; aggregate candidates: ${JSON.stringify(value)}`)).toBeNull();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The parser itself is django-aggregates.mjs, which the CLI's `init` reads
|
|
2
|
+
// from node_modules, where Node strips no types. This name is what the site
|
|
3
|
+
// imports, so the .mjs stays the one place it is written.
|
|
4
|
+
export { djangoAggregateCandidates, djangoAggregateMessage } from "./django-aggregates.mjs";
|
|
5
|
+
export type { DjangoAggregateCandidates } from "./django-aggregates.mjs";
|
package/src/lib/local-api.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { SetupInfo, SetupPhase, SetupProject, SetupRunStepStatus } from "./setup-info";
|
|
1
|
+
import type { SetupDiagnostic, SetupInfo, SetupPhase, SetupProject, SetupRunStepStatus } from "./setup-info";
|
|
2
|
+
import type { DjangoAggregateCandidates } from "./django-aggregates";
|
|
2
3
|
|
|
3
4
|
const ROOT = `${import.meta.env.BASE_URL}__portolan`;
|
|
4
5
|
const LOCAL_HEADER = { "Content-Type": "application/json", "X-Portolan-Local": "1" };
|
|
@@ -10,6 +11,7 @@ export interface Detection {
|
|
|
10
11
|
candidates: string[];
|
|
11
12
|
options: Record<string, unknown>;
|
|
12
13
|
selected: boolean;
|
|
14
|
+
preview?: Array<{ file: string; fields: Record<string, string> }>;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export interface Discovery {
|
|
@@ -113,6 +115,7 @@ export interface ProjectTrialStep {
|
|
|
113
115
|
fileCount: number;
|
|
114
116
|
changedCount: number;
|
|
115
117
|
warnings: string[];
|
|
118
|
+
diagnostics: SetupDiagnostic[];
|
|
116
119
|
message?: string;
|
|
117
120
|
}
|
|
118
121
|
|
|
@@ -121,6 +124,7 @@ export interface ProjectTrial {
|
|
|
121
124
|
steps: ProjectTrialStep[];
|
|
122
125
|
facts: ProjectTrialFact[];
|
|
123
126
|
warnings: Array<{ plugin: string; message: string }>;
|
|
127
|
+
diagnostics: SetupDiagnostic[];
|
|
124
128
|
generatedFiles: number;
|
|
125
129
|
previewUrl?: string;
|
|
126
130
|
previewError?: string;
|
|
@@ -130,7 +134,7 @@ export type RunEvent =
|
|
|
130
134
|
| { type: "run-started"; at: string; runId: string; mode: "write" | "check" | "preview" | "project-preview" }
|
|
131
135
|
| { type: "pipeline-ready"; at: string; stepCount: number }
|
|
132
136
|
| { type: "step-started"; at: string; ordinal: number; phase: SetupPhase; plugin: string; input?: string; output: string }
|
|
133
|
-
| { type: "step-finished"; at: string; ordinal: number; phase: SetupPhase; plugin: string; status: SetupRunStepStatus; durationMs: number; fileCount: number; changedCount: number; changes: Array<{ kind: "added" | "changed" | "removed"; path: string }>; files: string[]; warnings?: string[]; message?: string }
|
|
137
|
+
| { type: "step-finished"; at: string; ordinal: number; phase: SetupPhase; plugin: string; status: SetupRunStepStatus; durationMs: number; fileCount: number; changedCount: number; changes: Array<{ kind: "added" | "changed" | "removed"; path: string }>; files: string[]; warnings?: string[]; diagnostics?: SetupDiagnostic[]; message?: string }
|
|
134
138
|
| { type: "preview-ready"; at: string; files: GeneratedFileDiff[]; totalFiles: number; truncated: boolean }
|
|
135
139
|
| ({ type: "project-trial-ready"; at: string } & ProjectTrial)
|
|
136
140
|
| { type: "run-finished"; at: string; status: string; durationMs?: number; message?: string }
|
|
@@ -178,6 +182,20 @@ export async function localStatus(): Promise<{ local: true; workspace: string; s
|
|
|
178
182
|
return json("/status");
|
|
179
183
|
}
|
|
180
184
|
|
|
185
|
+
export interface DjangoAggregateProposals {
|
|
186
|
+
revision: string;
|
|
187
|
+
stale: boolean;
|
|
188
|
+
proposals: Array<DjangoAggregateCandidates & { id: string; step: number; plugin: string; input: string; output: string; message: string }>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function djangoAggregateProposals(): Promise<DjangoAggregateProposals> {
|
|
192
|
+
return json("/django-aggregates");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function saveDjangoAggregates(revision: string, selections: Array<{ id: string; model: string }>): Promise<{ saved: number }> {
|
|
196
|
+
return json("/django-aggregates", { method: "POST", headers: LOCAL_HEADER, body: JSON.stringify({ revision, selections }) });
|
|
197
|
+
}
|
|
198
|
+
|
|
181
199
|
export async function previewDeliveryPreset(provider?: DeliveryProvider, features?: DeliveryFeatureId[]): Promise<DeliveryPreset> {
|
|
182
200
|
const query = new URLSearchParams();
|
|
183
201
|
if (provider) query.set("provider", provider);
|
|
@@ -174,6 +174,18 @@ describe("publicSetupFrom", () => {
|
|
|
174
174
|
changedCount: 0,
|
|
175
175
|
files: ["data/shop/catalog.json", "../../private-key"],
|
|
176
176
|
warnings: ["shop.cart: internal/domain/errors has no struct called Errors", "", 42],
|
|
177
|
+
diagnostics: [{
|
|
178
|
+
plugin: "domain",
|
|
179
|
+
rule: "domain.missing-root",
|
|
180
|
+
severity: "warning",
|
|
181
|
+
action: "Name the root.",
|
|
182
|
+
message: "shop.cart: internal/domain/errors has no struct called Errors",
|
|
183
|
+
count: 1,
|
|
184
|
+
project: "shop",
|
|
185
|
+
phase: "extract",
|
|
186
|
+
suppressed: true,
|
|
187
|
+
suppressionReason: "Legacy package.",
|
|
188
|
+
}],
|
|
177
189
|
error: "secret token",
|
|
178
190
|
},
|
|
179
191
|
],
|
|
@@ -187,6 +199,11 @@ describe("publicSetupFrom", () => {
|
|
|
187
199
|
status: "failed",
|
|
188
200
|
files: ["data/shop/catalog.json"],
|
|
189
201
|
warnings: ["shop.cart: internal/domain/errors has no struct called Errors"],
|
|
202
|
+
diagnostics: [{
|
|
203
|
+
rule: "domain.missing-root",
|
|
204
|
+
suppressed: true,
|
|
205
|
+
suppressionReason: "Legacy package.",
|
|
206
|
+
}],
|
|
190
207
|
});
|
|
191
208
|
expect(JSON.stringify(setup)).not.toContain("secret token");
|
|
192
209
|
expect(JSON.stringify(setup)).not.toContain("private-key");
|
package/src/lib/setup-info.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* bundle.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { djangoAggregateCandidates, djangoAggregateMessage } from "./django-aggregates.ts";
|
|
11
|
+
|
|
10
12
|
export type SetupPhase = "extract" | "verify" | "generate";
|
|
11
13
|
|
|
12
14
|
export interface SetupProject {
|
|
@@ -59,6 +61,24 @@ export interface SetupRunStep extends SetupStep {
|
|
|
59
61
|
* fix; a failed step's error stays out, because an error may quote anything.
|
|
60
62
|
*/
|
|
61
63
|
warnings: string[];
|
|
64
|
+
diagnostics: SetupDiagnostic[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type SetupDiagnosticSeverity = "error" | "warning" | "info";
|
|
68
|
+
|
|
69
|
+
export interface SetupDiagnostic {
|
|
70
|
+
aggregateCandidates?: import("./django-aggregates").DjangoAggregateCandidates;
|
|
71
|
+
plugin: string;
|
|
72
|
+
rule: string;
|
|
73
|
+
severity: SetupDiagnosticSeverity;
|
|
74
|
+
action: string;
|
|
75
|
+
message: string;
|
|
76
|
+
count: number;
|
|
77
|
+
project: string;
|
|
78
|
+
phase: SetupPhase;
|
|
79
|
+
ref?: string;
|
|
80
|
+
suppressed: boolean;
|
|
81
|
+
suppressionReason?: string;
|
|
62
82
|
}
|
|
63
83
|
|
|
64
84
|
export interface SetupRun {
|
|
@@ -249,6 +269,10 @@ function runStepFrom(
|
|
|
249
269
|
.filter((warning): warning is string => typeof warning === "string" && warning.trim() !== "")
|
|
250
270
|
.slice(0, MAX_WARNINGS)
|
|
251
271
|
.map((warning) => warning.slice(0, MAX_WARNING_LENGTH));
|
|
272
|
+
const diagnostics = array(item.diagnostics)
|
|
273
|
+
.map((diagnostic) => diagnosticFrom(diagnostic, item.plugin as string, item.phase as SetupPhase))
|
|
274
|
+
.filter((diagnostic): diagnostic is SetupDiagnostic => diagnostic !== null)
|
|
275
|
+
.slice(0, MAX_WARNINGS);
|
|
252
276
|
|
|
253
277
|
return {
|
|
254
278
|
ordinal: item.ordinal,
|
|
@@ -267,6 +291,40 @@ function runStepFrom(
|
|
|
267
291
|
changedCount: item.changedCount,
|
|
268
292
|
files,
|
|
269
293
|
warnings,
|
|
294
|
+
diagnostics,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function diagnosticFrom(value: unknown, plugin: string, phase: SetupPhase): SetupDiagnostic | null {
|
|
299
|
+
const item = record(value);
|
|
300
|
+
if (
|
|
301
|
+
item.plugin !== plugin || item.phase !== phase ||
|
|
302
|
+
typeof item.rule !== "string" || !/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(item.rule) ||
|
|
303
|
+
!["error", "warning", "info"].includes(item.severity as string) ||
|
|
304
|
+
typeof item.action !== "string" || item.action.trim() === "" ||
|
|
305
|
+
typeof item.message !== "string" || item.message.trim() === "" ||
|
|
306
|
+
!wholeNumber(item.count) || item.count < 1 ||
|
|
307
|
+
typeof item.project !== "string" ||
|
|
308
|
+
typeof item.suppressed !== "boolean"
|
|
309
|
+
) return null;
|
|
310
|
+
const ref = typeof item.ref === "string" && item.ref.trim() !== "" ? item.ref.slice(0, MAX_WARNING_LENGTH) : undefined;
|
|
311
|
+
const suppressionReason = typeof item.suppressionReason === "string" && item.suppressionReason.trim() !== ""
|
|
312
|
+
? item.suppressionReason.slice(0, MAX_WARNING_LENGTH)
|
|
313
|
+
: undefined;
|
|
314
|
+
if (item.suppressed && !suppressionReason) return null;
|
|
315
|
+
return {
|
|
316
|
+
plugin,
|
|
317
|
+
rule: item.rule,
|
|
318
|
+
severity: item.severity as SetupDiagnosticSeverity,
|
|
319
|
+
action: item.action.slice(0, MAX_WARNING_LENGTH),
|
|
320
|
+
message: djangoAggregateMessage(item.message).slice(0, MAX_WARNING_LENGTH),
|
|
321
|
+
...(djangoAggregateCandidates(item.message) ? { aggregateCandidates: djangoAggregateCandidates(item.message)! } : {}),
|
|
322
|
+
count: item.count,
|
|
323
|
+
project: item.project.slice(0, MAX_WARNING_LENGTH),
|
|
324
|
+
phase,
|
|
325
|
+
...(ref ? { ref } : {}),
|
|
326
|
+
suppressed: item.suppressed,
|
|
327
|
+
...(suppressionReason ? { suppressionReason } : {}),
|
|
270
328
|
};
|
|
271
329
|
}
|
|
272
330
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { groupDiagnostics, groupWarnings, warningDiagnostic } from "./warnings";
|
|
3
|
+
|
|
4
|
+
describe("warning diagnostics", () => {
|
|
5
|
+
it("assigns a stable rule, severity and action to known warnings", () => {
|
|
6
|
+
const diagnostic = warningDiagnostic({
|
|
7
|
+
plugin: "openapi",
|
|
8
|
+
message: "aviacore-api: no operationId on 2 of 9 operations; listed by verb and path: POST /book, POST /cancel",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
expect(diagnostic).toMatchObject({
|
|
12
|
+
plugin: "openapi",
|
|
13
|
+
rule: "openapi.missing-operation-id",
|
|
14
|
+
severity: "warning",
|
|
15
|
+
ref: "aviacore-api",
|
|
16
|
+
suppressed: false,
|
|
17
|
+
});
|
|
18
|
+
expect(diagnostic.action).toContain("operationId");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("groups repetitions by plugin, rule and severity", () => {
|
|
22
|
+
const groups = groupWarnings([
|
|
23
|
+
{ plugin: "openapi", message: "api.v1: no operationId on 1 of 3 operations; listed by verb and path: POST /book" },
|
|
24
|
+
{ plugin: "openapi", message: "api.v2: no operationId on 1 of 3 operations; listed by verb and path: POST /cancel" },
|
|
25
|
+
{ plugin: "wsdl", message: "b/schema.xsd: duplicate declaration CodeType and 2 more in namespace urn:common; the declarations in a/schema.xsd are used" },
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
expect(groups.map(({ plugin, rule, count }) => ({ plugin, rule, count }))).toEqual([
|
|
29
|
+
{ plugin: "openapi", rule: "openapi.missing-operation-id", count: 2 },
|
|
30
|
+
{ plugin: "wsdl", rule: "schema.duplicate-declaration", count: 1 },
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("marks read and parse failures as errors and expected absence as info", () => {
|
|
35
|
+
expect(warningDiagnostic({ plugin: "sql", message: "schema.sql could not be parsed" }).severity).toBe("error");
|
|
36
|
+
expect(warningDiagnostic({ plugin: "river", message: "root: no River jobs were found" }).severity).toBe("info");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("groups persisted policy decisions without evaluating policies in the browser", () => {
|
|
40
|
+
const diagnostic = warningDiagnostic({ plugin: "openapi", message: "api: no operationId on 1 of 3 operations; listed by verb and path: POST /book" });
|
|
41
|
+
const groups = groupDiagnostics([{ ...diagnostic, suppressed: true, suppressionReason: "Owned upstream." }]);
|
|
42
|
+
expect(groups[0]).toMatchObject({ suppressed: true, suppressionReason: "Owned upstream.", count: 1 });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("keeps unrelated unknown warnings in separate fallback rules", () => {
|
|
46
|
+
const groups = groupWarnings([
|
|
47
|
+
{ plugin: "custom", message: "service: frobnicator needs attention" },
|
|
48
|
+
{ plugin: "custom", message: "service: another limitation was observed" },
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
expect(groups).toHaveLength(2);
|
|
52
|
+
expect(groups.every((group) => group.rule.startsWith("plugin.custom.other-"))).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|