@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
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="position"
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
- <div className="mt-12 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
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
 
@@ -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";
@@ -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");
@@ -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
+ });