@shortlink-org/portolan 0.2.4 → 0.4.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 +174 -6
- package/catalog/enum_test.go +46 -0
- package/catalog/evidence_test.go +35 -0
- package/catalog/model.go +1213 -0
- package/catalog/roundtrip_test.go +210 -0
- package/catalog/via_test.go +38 -0
- package/cli/init.test.mjs +6 -1
- package/cli/portolan.mjs +14 -1
- package/cli/portolan.test.mjs +49 -0
- package/go.mod +21 -0
- package/go.sum +34 -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 +28 -15
- package/plugin/describe.go +156 -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 +404 -50
- package/plugins/cmd/portolan-http-clients/main.go +19 -0
- package/plugins/extract-argocd/options.schema.json +44 -0
- package/plugins/extract-celery/extract.py +0 -2
- package/plugins/extract-celery/extract_test.py +1 -1
- package/plugins/extract-celery/main.py +1 -0
- package/plugins/extract-csharp-ddd/README.md +213 -0
- package/plugins/extract-csharp-ddd/options.schema.json +55 -0
- package/plugins/extract-django/README.md +52 -19
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +30 -18
- package/plugins/extract-django/extract.py +21 -7
- package/plugins/extract-django/extract_test.py +68 -2
- package/plugins/extract-django/lifecycle.py +4 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/operations.py +1 -1
- package/plugins/extract-django/routing_test.py +109 -1
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- 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-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +20 -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/Main.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-k8s/options.schema.json +32 -0
- package/plugins/extract-laravel/Cargo.lock +962 -0
- package/plugins/extract-laravel/Cargo.toml +20 -0
- package/plugins/extract-laravel/README.md +200 -0
- package/plugins/extract-laravel/options.schema.json +68 -0
- package/plugins/extract-laravel/rustfmt.toml +1 -0
- package/plugins/extract-php-ddd/Cargo.lock +972 -0
- package/plugins/extract-php-ddd/Cargo.toml +22 -0
- package/plugins/extract-php-ddd/README.md +141 -0
- package/plugins/extract-php-ddd/options.schema.json +50 -0
- package/plugins/extract-php-ddd/rustfmt.toml +1 -0
- package/plugins/extract-python-kafka/extract.py +0 -2
- package/plugins/extract-python-kafka/extract_test.py +1 -1
- package/plugins/extract-python-kafka/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- 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/extract-ts/main.ts +1 -0
- package/plugins/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -0
- package/plugins/phpscan/Cargo.lock +953 -0
- package/plugins/phpscan/Cargo.toml +19 -0
- package/plugins/phpscan/rustfmt.toml +1 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +24 -1
- package/plugins/pyplugin/protocol.py +1 -5
- package/plugins/verify-otel/options.schema.json +12 -0
- package/portolan.json +132 -4
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +905 -4
- package/scripts/README.md +21 -13
- package/scripts/catalog-sources.mjs +6 -0
- package/scripts/delivery-presets.mjs +57 -14
- package/scripts/diff.mjs +5 -1
- package/scripts/django-aggregates.test.mjs +58 -0
- package/scripts/gen-likec4.mjs +150 -17
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +148 -118
- package/scripts/gitops-example.test.mjs +108 -0
- 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-argocd.mjs +338 -0
- package/scripts/host-plugins/fetch-argocd.options.json +49 -0
- package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
- package/scripts/host-plugins/fetch-bsr.mjs +1 -0
- package/scripts/host-plugins/fetch-csr.mjs +1 -0
- package/scripts/host-plugins/fetch-git.mjs +78 -21
- package/scripts/host-plugins/fetch-git.test.mjs +62 -8
- package/scripts/host-plugins/fetch-k8s.mjs +263 -0
- package/scripts/host-plugins/fetch-k8s.options.json +50 -0
- package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
- package/scripts/host-plugins/k8s-topology.mjs +183 -0
- package/scripts/json-format.mjs +192 -0
- package/scripts/json-format.test.mjs +97 -0
- package/scripts/local-api.mjs +287 -12
- package/scripts/local-api.test.mjs +150 -5
- package/scripts/local-discovery.mjs +94 -9
- package/scripts/manifest.mjs +14 -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 +45 -3
- package/scripts/plugin-host.test.mjs +9 -0
- package/scripts/plugin-wasm-worker.mjs +4 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/provenance.mjs +72 -0
- package/scripts/provenance.test.mjs +149 -0
- package/scripts/run-builtin.mjs +62 -7
- package/scripts/schema.mjs +167 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +167 -0
- package/scripts/warning-policy.test.mjs +93 -0
- package/src/app/Breadcrumbs.test.ts +3 -0
- package/src/app/Breadcrumbs.tsx +3 -0
- package/src/app/CatalogApp.tsx +2 -0
- package/src/app/Sidebar.tsx +3 -3
- package/src/app/SidebarFooter.tsx +20 -4
- 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-index.ts +25 -0
- package/src/catalog-model.ts +293 -5
- package/src/catalog-validation.ts +113 -2
- package/src/catalog.test.ts +40 -1
- 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/ChannelRows.test.tsx +76 -1
- package/src/components/ChannelRows.tsx +223 -60
- package/src/components/DeploymentRows.tsx +131 -0
- package/src/components/DocsLinks.test.tsx +27 -0
- package/src/components/DocsLinks.tsx +56 -0
- package/src/components/FieldTree.tsx +5 -2
- 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/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +46 -7
- package/src/enrich.test.ts +459 -4
- package/src/enrich.ts +308 -7
- package/src/er/ErCanvas.tsx +218 -12
- package/src/er/GroupNode.tsx +57 -0
- package/src/er/StoreHeader.tsx +1 -0
- package/src/er/layout.test.ts +85 -2
- package/src/er/layout.ts +140 -5
- package/src/er/spec.test.ts +17 -0
- package/src/er/spec.ts +23 -10
- package/src/flow/Recordings.test.tsx +52 -0
- package/src/flow/Recordings.tsx +236 -0
- package/src/flow/StepDetail.tsx +59 -0
- package/src/flow/TraceTrial.tsx +419 -0
- package/src/flow/evidence.test.ts +16 -0
- package/src/flow/evidence.ts +34 -0
- package/src/flow/examples.test.ts +33 -0
- package/src/flow/examples.ts +37 -0
- package/src/flow/outline.test.ts +28 -0
- package/src/flow/outline.ts +7 -2
- package/src/flow/trace-trial-resume.test.ts +42 -0
- package/src/flow/trace-trial-resume.ts +74 -0
- package/src/graph/elk.ts +78 -0
- package/src/index.css +68 -0
- package/src/landing/DraggableReveal.tsx +3 -2
- package/src/landing/EvidencePipeline.tsx +105 -0
- package/src/landing/LandingPage.tsx +17 -70
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- package/src/lib/catalog-diff.ts +1 -1
- package/src/lib/centrality.test.ts +251 -0
- package/src/lib/centrality.ts +232 -0
- package/src/lib/confluence.test.ts +37 -0
- package/src/lib/confluence.ts +41 -0
- package/src/lib/context-color.ts +1 -1
- package/src/lib/deployment-drift.ts +22 -0
- package/src/lib/derive.ts +43 -69
- 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/environments.test.ts +83 -0
- package/src/lib/environments.ts +57 -0
- package/src/lib/integration-url.test.ts +30 -0
- package/src/lib/integration-url.ts +63 -0
- package/src/lib/kafka-ui.ts +3 -45
- package/src/lib/local-api.ts +116 -4
- package/src/lib/notion.ts +13 -0
- package/src/lib/plugin-index.json +2765 -0
- package/src/lib/plugins.test.ts +68 -0
- package/src/lib/plugins.ts +264 -0
- package/src/lib/problem-flows.test.ts +61 -0
- package/src/lib/problem-flows.ts +78 -0
- package/src/lib/problem-rules-cel.d.mts +43 -0
- package/src/lib/problem-rules-cel.mjs +407 -0
- package/src/lib/problem-rules.test.ts +287 -0
- package/src/lib/problem-rules.ts +271 -0
- package/src/lib/problem-subjects.ts +737 -0
- package/src/lib/rule-entries.ts +39 -0
- package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
- package/src/lib/rules-deploy.test.ts +133 -0
- package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
- package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
- package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
- package/src/lib/rules.test.ts +51 -0
- package/src/lib/rules.ts +86 -0
- package/src/lib/setup-info.test.ts +17 -0
- package/src/lib/setup-info.ts +58 -0
- package/src/lib/shape.test.ts +32 -0
- package/src/lib/shape.ts +30 -6
- package/src/lib/tech.ts +16 -0
- package/src/lib/trace-project.test.ts +34 -0
- package/src/lib/trace-project.ts +50 -0
- package/src/lib/use-problems.ts +23 -0
- package/src/lib/warnings.test.ts +63 -0
- package/src/lib/warnings.ts +260 -0
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/map/ContextMapGraph.tsx +76 -32
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +189 -18
- package/src/pages/AggregatePage.tsx +65 -14
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +8 -5
- package/src/pages/EventPage.tsx +15 -5
- package/src/pages/FlowDetail.tsx +23 -2
- package/src/pages/GraphPage.tsx +40 -3
- package/src/pages/Overview.tsx +152 -12
- package/src/pages/PluginIndex.tsx +190 -0
- package/src/pages/Problems.tsx +396 -128
- package/src/pages/ServicePage.tsx +62 -5
- package/src/pages/Settings.tsx +217 -43
- package/src/pages/settings/AboutSettings.tsx +8 -1
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- package/src/pages/settings/IntegrationsSettings.tsx +63 -17
- package/src/pages/settings/RecordingSettings.tsx +138 -0
- package/src/pages/settings/RulesSettings.tsx +825 -0
- package/src/routes.test.ts +9 -0
- package/src/routes.ts +22 -1
- 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/lib/data-problems.ts +0 -314
- package/src/lib/proto-problems.ts +0 -237
- package/src/lib/wire-problems.ts +0 -342
package/scripts/local-api.mjs
CHANGED
|
@@ -20,7 +20,11 @@ import { basename, dirname, join, posix, relative, resolve, sep } from "node:pat
|
|
|
20
20
|
|
|
21
21
|
import { loadManifest, readManifest, readManifestText } from "./manifest.mjs";
|
|
22
22
|
import { builtinPluginNames } from "./builtin-plugins.mjs";
|
|
23
|
+
import { pluginsFresh } from "./plugins-fresh.mjs";
|
|
24
|
+
import { djangoAggregateCandidates } from "../src/lib/django-aggregates.mjs";
|
|
23
25
|
import { installDeliveryPreset, planDeliveryPreset, publicDeliveryPreset } from "./delivery-presets.mjs";
|
|
26
|
+
import { formatLike } from "./json-format.mjs";
|
|
27
|
+
import { UPLOAD_LIMIT, checkRecording, manifestWithTraceStep, recordingPath, stepWithMappings, summarizeTraceTrial, traceStepFor } from "./trace-trials.mjs";
|
|
24
28
|
import {
|
|
25
29
|
discoverProject,
|
|
26
30
|
matches,
|
|
@@ -310,7 +314,16 @@ function pluginOptions(plugin, project, detectedOptions = {}) {
|
|
|
310
314
|
out: "project.json",
|
|
311
315
|
};
|
|
312
316
|
}
|
|
313
|
-
if (
|
|
317
|
+
if (plugin === "php-ddd" || plugin === "csharp-ddd") {
|
|
318
|
+
// The tree names its own contexts and services; the manifest only says
|
|
319
|
+
// where the code lives and how core it is.
|
|
320
|
+
return {
|
|
321
|
+
...(project.repository ? { repo: repositoryParts(project.repository).web } : {}),
|
|
322
|
+
...(detectedOptions?.classification ? { classification: detectedOptions.classification } : {}),
|
|
323
|
+
out: "domain.json",
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain", "laravel-domain"].includes(plugin)) {
|
|
314
327
|
return { ...common, ...(project.repository ? { repo: repositoryParts(project.repository).web } : {}), ...detectedOptions, serviceName: project.name, out: "domain.json" };
|
|
315
328
|
}
|
|
316
329
|
if (plugin === "sql") return { ...common, store: "pg", ...detectedOptions, out: "stores.json" };
|
|
@@ -325,7 +338,7 @@ function pluginOptions(plugin, project, detectedOptions = {}) {
|
|
|
325
338
|
if (plugin === "graphql") return { ...common, ...detectedOptions, out: "graphql.json" };
|
|
326
339
|
if (plugin === "proto") return { ...common, ...detectedOptions, out: "proto.json" };
|
|
327
340
|
if (plugin === "glossary") return { context: group, ...detectedOptions, out: "glossary.json" };
|
|
328
|
-
if (plugin === "adr") return { ...detectedOptions, out: "adr.json" };
|
|
341
|
+
if (plugin === "adr") return { scope: [group, component].filter(Boolean).join(".") || "org", ...detectedOptions, out: "adr.json" };
|
|
329
342
|
return {};
|
|
330
343
|
}
|
|
331
344
|
|
|
@@ -430,7 +443,7 @@ export function planProject(workspace, manifest, request) {
|
|
|
430
443
|
};
|
|
431
444
|
const out = posix.join(finalRoot, "portolan");
|
|
432
445
|
const detectionByPlugin = new Map(discovery.detections.map((item) => [item.plugin, item]));
|
|
433
|
-
const hasDomainModel = plugins.some((plugin) => ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain"].includes(plugin));
|
|
446
|
+
const hasDomainModel = plugins.some((plugin) => ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain", "laravel-domain", "php-ddd", "csharp-ddd"].includes(plugin));
|
|
434
447
|
const projectDetectionOptions = {
|
|
435
448
|
groupKind: splitDeployables ? "system" : hasDomainModel ? "bounded-context" : "system",
|
|
436
449
|
...(hasDomainModel ? { componentKind: "service" } : {}),
|
|
@@ -455,7 +468,7 @@ export function planProject(workspace, manifest, request) {
|
|
|
455
468
|
if (!splitDeployables) {
|
|
456
469
|
const options = plugin === "project"
|
|
457
470
|
? projectDetectionOptions
|
|
458
|
-
: ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain"].includes(plugin)
|
|
471
|
+
: ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain", "laravel-domain", "php-ddd", "csharp-ddd"].includes(plugin)
|
|
459
472
|
? domainDetectionOptions(plugin)
|
|
460
473
|
: detectionByPlugin.get(plugin)?.options;
|
|
461
474
|
return [{ plugin, in: finalRoot, out, options: pluginOptions(plugin, project, options) }];
|
|
@@ -604,11 +617,19 @@ function projectRequestPlan(workspace, manifest, request) {
|
|
|
604
617
|
return { base, plan, starter };
|
|
605
618
|
}
|
|
606
619
|
|
|
620
|
+
/**
|
|
621
|
+
* Writes the manifest in the style the file already has: an unchanged
|
|
622
|
+
* subtree is copied byte for byte, a changed array keeps the shape its old
|
|
623
|
+
* self had, so that what the page changed is what the diff shows.
|
|
624
|
+
*/
|
|
607
625
|
export function writeManifest(path, manifest) {
|
|
626
|
+
let before = "";
|
|
627
|
+
try { before = readFileSync(path, "utf8"); } catch {}
|
|
628
|
+
const text = before.trim() ? formatLike(before.replace(/\n$/, ""), manifest) : JSON.stringify(manifest, null, 2);
|
|
608
629
|
const staging = mkdtempSync(join(dirname(path), ".portolan-manifest-"));
|
|
609
630
|
const temp = join(staging, "portolan.json");
|
|
610
631
|
try {
|
|
611
|
-
writeFileSync(temp, `${
|
|
632
|
+
writeFileSync(temp, `${text}\n`, { flag: "wx" });
|
|
612
633
|
const validation = loadManifest(temp);
|
|
613
634
|
if (validation.problems.length) throw new Error(validation.problems.join("\n"));
|
|
614
635
|
renameSync(temp, path);
|
|
@@ -617,6 +638,86 @@ export function writeManifest(path, manifest) {
|
|
|
617
638
|
}
|
|
618
639
|
}
|
|
619
640
|
|
|
641
|
+
export function djangoAggregateProposals(workspace) {
|
|
642
|
+
const path = join(workspace, "portolan.json");
|
|
643
|
+
const text = readFileSync(path, "utf8");
|
|
644
|
+
const manifest = readManifestText(text, path);
|
|
645
|
+
const revision = createHash("sha256").update(text).digest("hex");
|
|
646
|
+
let report;
|
|
647
|
+
try { report = JSON.parse(readFileSync(join(workspace, ".portolan/build-report.json"), "utf8")); } catch {}
|
|
648
|
+
const proposals = [];
|
|
649
|
+
for (const step of report?.steps ?? []) {
|
|
650
|
+
if (step.phase !== "extract") continue;
|
|
651
|
+
const matches = (manifest.extract ?? []).map((entry, index) => ({ entry, index }))
|
|
652
|
+
.filter(({ entry }) => entry.plugin === step.plugin && entry.in === step.input && entry.out === step.output);
|
|
653
|
+
if (matches.length !== 1) continue;
|
|
654
|
+
const { entry, index } = matches[0];
|
|
655
|
+
for (const message of step.warnings ?? []) {
|
|
656
|
+
const candidates = djangoAggregateCandidates(message);
|
|
657
|
+
if (!candidates) continue;
|
|
658
|
+
const id = `${index}:${candidates.app}`;
|
|
659
|
+
if (proposals.some((proposal) => proposal.id === id)) continue;
|
|
660
|
+
proposals.push({ id, step: index, plugin: entry.plugin, input: entry.in, output: entry.out, message, ...candidates });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return { revision, stale: !report || report.manifestSha256 !== revision || report.status === "running", proposals };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export function saveDjangoAggregates(workspace, request) {
|
|
667
|
+
const current = djangoAggregateProposals(workspace);
|
|
668
|
+
if (current.stale || request.revision !== current.revision) {
|
|
669
|
+
throw new Error("The manifest or extraction report has changed. Regenerate and review the candidates again.");
|
|
670
|
+
}
|
|
671
|
+
if (!Array.isArray(request.selections) || !request.selections.length) throw new Error("Choose at least one aggregate root.");
|
|
672
|
+
const manifest = readManifest(join(workspace, "portolan.json"));
|
|
673
|
+
const seen = new Set();
|
|
674
|
+
for (const choice of request.selections) {
|
|
675
|
+
const proposal = current.proposals.find((candidate) => candidate.id === choice?.id);
|
|
676
|
+
if (!proposal || !proposal.models.some((model) => model.name === choice.model) || seen.has(choice.id)) {
|
|
677
|
+
throw new Error("Choose one of the reported models for each application.");
|
|
678
|
+
}
|
|
679
|
+
seen.add(choice.id);
|
|
680
|
+
const step = manifest.extract[proposal.step];
|
|
681
|
+
step.options = { ...step.options, aggregates: { ...step.options?.aggregates, [proposal.app]: choice.model } };
|
|
682
|
+
}
|
|
683
|
+
writeManifest(join(workspace, "portolan.json"), manifest);
|
|
684
|
+
return { saved: request.selections.length };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* The manifest's `problemRules` as written, with a revision of the whole file:
|
|
689
|
+
* a save quotes it, so two pages editing the rules cannot overwrite each
|
|
690
|
+
* other, and neither can a hand edit made while the page was open.
|
|
691
|
+
*/
|
|
692
|
+
export function problemRulesState(workspace) {
|
|
693
|
+
const path = join(workspace, "portolan.json");
|
|
694
|
+
const text = readFileSync(path, "utf8");
|
|
695
|
+
const manifest = readManifestText(text, path);
|
|
696
|
+
return {
|
|
697
|
+
revision: createHash("sha256").update(text).digest("hex"),
|
|
698
|
+
rules: Array.isArray(manifest.problemRules) ? manifest.problemRules : [],
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Replaces `problemRules`. The expressions are type-checked and the shape is
|
|
704
|
+
* checked against the schema by writeManifest, through the same loader gen
|
|
705
|
+
* uses, so a rule the page accepts is a rule gen accepts.
|
|
706
|
+
*/
|
|
707
|
+
export function saveProblemRules(workspace, request) {
|
|
708
|
+
const current = problemRulesState(workspace);
|
|
709
|
+
if (request?.revision !== current.revision) {
|
|
710
|
+
throw new Error("portolan.json has changed since the rules were read. Reload the page and try again.");
|
|
711
|
+
}
|
|
712
|
+
if (!Array.isArray(request.rules)) throw new Error("rules must be an array.");
|
|
713
|
+
const path = join(workspace, "portolan.json");
|
|
714
|
+
const manifest = readManifest(path);
|
|
715
|
+
if (request.rules.length) manifest.problemRules = request.rules;
|
|
716
|
+
else delete manifest.problemRules;
|
|
717
|
+
writeManifest(path, manifest);
|
|
718
|
+
return problemRulesState(workspace);
|
|
719
|
+
}
|
|
720
|
+
|
|
620
721
|
export function writeProject(workspace, request) {
|
|
621
722
|
const manifestPath = join(workspace, "portolan.json");
|
|
622
723
|
const before = readFileSync(manifestPath, "utf8");
|
|
@@ -715,6 +816,18 @@ async function body(req) {
|
|
|
715
816
|
return text ? JSON.parse(text) : {};
|
|
716
817
|
}
|
|
717
818
|
|
|
819
|
+
/** The bytes of an upload, as they came, up to a limit that is said out loud. */
|
|
820
|
+
async function rawBody(req, limit) {
|
|
821
|
+
const chunks = [];
|
|
822
|
+
let size = 0;
|
|
823
|
+
for await (const chunk of req) {
|
|
824
|
+
size += chunk.length;
|
|
825
|
+
if (size > limit) throw new Error(`The upload is larger than ${Math.round(limit / 1024 / 1024)} MB.`);
|
|
826
|
+
chunks.push(chunk);
|
|
827
|
+
}
|
|
828
|
+
return Buffer.concat(chunks);
|
|
829
|
+
}
|
|
830
|
+
|
|
718
831
|
function localRequest(req) {
|
|
719
832
|
const address = req.socket.remoteAddress ?? "";
|
|
720
833
|
const localAddress = address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
|
|
@@ -747,11 +860,21 @@ function feed(job, stream, chunk) {
|
|
|
747
860
|
}
|
|
748
861
|
}
|
|
749
862
|
|
|
750
|
-
|
|
863
|
+
// The tree with nothing in it, which every repository has. Before the first
|
|
864
|
+
// commit there is no HEAD to diff against, and the index diffed against this
|
|
865
|
+
// tree is exactly what has been staged.
|
|
866
|
+
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
867
|
+
|
|
868
|
+
export function workspaceFingerprint(workspace) {
|
|
751
869
|
const hash = createHash("sha256");
|
|
752
870
|
try {
|
|
753
|
-
|
|
754
|
-
|
|
871
|
+
// Git speaks to nobody here: a workspace that is not a checkout, or one
|
|
872
|
+
// without a commit yet, is a case this function handles, not an error to
|
|
873
|
+
// print from the dev server.
|
|
874
|
+
const options = { cwd: workspace, encoding: "buffer", maxBuffer: 128 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] };
|
|
875
|
+
let base = EMPTY_TREE;
|
|
876
|
+
try { base = execFileSync("git", ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], options).toString().trim() || EMPTY_TREE; } catch {}
|
|
877
|
+
hash.update(execFileSync("git", ["diff", "--binary", base, "--", "."], options));
|
|
755
878
|
const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], options)
|
|
756
879
|
.toString().split("\0").filter(Boolean).sort();
|
|
757
880
|
for (const name of untracked) {
|
|
@@ -865,6 +988,7 @@ export function summarizeProjectTrial(snapshot, plan, events) {
|
|
|
865
988
|
fileCount: event.fileCount,
|
|
866
989
|
changedCount: event.changedCount,
|
|
867
990
|
warnings: event.warnings ?? [],
|
|
991
|
+
diagnostics: event.diagnostics ?? [],
|
|
868
992
|
...(event.message ? { message: event.message } : {}),
|
|
869
993
|
}));
|
|
870
994
|
const facts = new Map(TRIAL_FACTS.map(([key]) => [key, new Set()]));
|
|
@@ -912,10 +1036,12 @@ export function summarizeProjectTrial(snapshot, plan, events) {
|
|
|
912
1036
|
for (const term of fragment.terms ?? []) add("terms", term.id ?? term.slug ?? term.name);
|
|
913
1037
|
}
|
|
914
1038
|
const warnings = steps.flatMap((step) => step.warnings.map((message) => ({ plugin: step.plugin, message })));
|
|
1039
|
+
const diagnostics = steps.flatMap((step) => step.diagnostics);
|
|
915
1040
|
return {
|
|
916
1041
|
steps,
|
|
917
1042
|
facts: TRIAL_FACTS.map(([key, label]) => ({ key, label, count: facts.get(key)?.size ?? 0 })).filter((fact) => fact.count > 0),
|
|
918
1043
|
warnings,
|
|
1044
|
+
diagnostics,
|
|
919
1045
|
generatedFiles: files.length,
|
|
920
1046
|
};
|
|
921
1047
|
}
|
|
@@ -933,6 +1059,65 @@ function prepareProjectTrial(workspace, request) {
|
|
|
933
1059
|
return { ...snapshot, fingerprint, plan };
|
|
934
1060
|
}
|
|
935
1061
|
|
|
1062
|
+
/**
|
|
1063
|
+
* A recording uploaded from the page, staged where the verifier will read it
|
|
1064
|
+
* - the project's recordings directory, in a snapshot of the workspace - with
|
|
1065
|
+
* the verify step that reads it, added to the snapshot's manifest when the
|
|
1066
|
+
* project has none. The workspace itself is not touched until the trial is
|
|
1067
|
+
* applied.
|
|
1068
|
+
*/
|
|
1069
|
+
function prepareTraceTrial(workspace, { projectId, name, content }) {
|
|
1070
|
+
const manifest = readManifest(join(workspace, "portolan.json"));
|
|
1071
|
+
const project = (manifest.projects ?? []).find((candidate) => candidate.id === projectId);
|
|
1072
|
+
if (!project) throw new Error(`Project "${projectId}" does not exist. Add the project before recording it.`);
|
|
1073
|
+
const known = builtinPluginNames().has("otel") || (manifest.plugins ?? []).some((plugin) => plugin.name === "otel");
|
|
1074
|
+
if (!known) throw new Error("The otel verifier is not available in this installation.");
|
|
1075
|
+
const { batches, spans } = checkRecording(content);
|
|
1076
|
+
const recording = recordingPath(name, { taken: (candidate) => lstatExists(join(workspace, project.root, candidate)) });
|
|
1077
|
+
const fingerprint = workspaceFingerprint(workspace);
|
|
1078
|
+
const snapshot = snapshotWorkspace(workspace);
|
|
1079
|
+
if (workspaceFingerprint(workspace) !== fingerprint) {
|
|
1080
|
+
rmSync(snapshot.holder, { recursive: true, force: true });
|
|
1081
|
+
throw new Error("Files changed while the trial workspace was being created. Try again.");
|
|
1082
|
+
}
|
|
1083
|
+
const target = join(snapshot.snapshot, project.root, recording);
|
|
1084
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1085
|
+
writeFileSync(target, content);
|
|
1086
|
+
const next = manifestWithTraceStep(manifest, project);
|
|
1087
|
+
if (next.changed) writeManifest(join(snapshot.snapshot, "portolan.json"), next.manifest);
|
|
1088
|
+
const trace = { projectId, root: project.root, recording, step: next.step, stepAdded: next.changed, stepChange: next.change, batches, spans, content };
|
|
1089
|
+
return { ...snapshot, fingerprint, trace };
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* The recording written beside the project, and the manifest with the step
|
|
1094
|
+
* that reads it and the names the page mapped. Undoable the way a project
|
|
1095
|
+
* is: the manifest before is remembered for a while.
|
|
1096
|
+
*/
|
|
1097
|
+
function applyTraceTrial(workspace, trial, { services, events, routes } = {}) {
|
|
1098
|
+
const manifestPath = join(workspace, "portolan.json");
|
|
1099
|
+
const before = readFileSync(manifestPath, "utf8");
|
|
1100
|
+
const manifest = readManifestText(before, manifestPath);
|
|
1101
|
+
const project = (manifest.projects ?? []).find((candidate) => candidate.id === trial.trace.projectId);
|
|
1102
|
+
if (!project) throw new Error(`Project "${trial.trace.projectId}" no longer exists.`);
|
|
1103
|
+
const target = join(workspace, project.root, trial.trace.recording);
|
|
1104
|
+
if (lstatExists(target)) throw new Error(`${posix.join(project.root, trial.trace.recording)} appeared while the trial ran. Run it again.`);
|
|
1105
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1106
|
+
writeFileSync(target, trial.trace.content, { flag: "wx" });
|
|
1107
|
+
const next = manifestWithTraceStep(manifest, project);
|
|
1108
|
+
const found = traceStepFor(next.manifest, project);
|
|
1109
|
+
const mapped = stepWithMappings(found.step, { services, events, routes });
|
|
1110
|
+
const changed = next.changed || JSON.stringify(mapped) !== JSON.stringify(found.step);
|
|
1111
|
+
let undoToken = null;
|
|
1112
|
+
if (changed) {
|
|
1113
|
+
const verify = [...next.manifest.verify];
|
|
1114
|
+
verify[found.index] = mapped;
|
|
1115
|
+
writeManifest(manifestPath, { ...next.manifest, verify });
|
|
1116
|
+
undoToken = rememberManifestUndo(workspace, before, readFileSync(manifestPath, "utf8"));
|
|
1117
|
+
}
|
|
1118
|
+
return { recording: posix.join(project.root, trial.trace.recording), project: project.id, stepAdded: next.changed, manifestChanged: changed, undoToken };
|
|
1119
|
+
}
|
|
1120
|
+
|
|
936
1121
|
function freeLocalPort() {
|
|
937
1122
|
return new Promise((resolvePort, reject) => {
|
|
938
1123
|
const server = createNetServer();
|
|
@@ -993,9 +1178,31 @@ async function startProjectPreview(job) {
|
|
|
993
1178
|
return job.previewUrl;
|
|
994
1179
|
}
|
|
995
1180
|
|
|
1181
|
+
/**
|
|
1182
|
+
* Rebuilds src/likec4/generated.jsx from likec4/ in the workspace, the way
|
|
1183
|
+
* `npm run likec4:gen` does before `dev`. Said in the run's log either way;
|
|
1184
|
+
* a bundle that fails to build leaves the last one in place.
|
|
1185
|
+
*/
|
|
1186
|
+
function refreshLikeC4Bundle(job) {
|
|
1187
|
+
return new Promise((done) => {
|
|
1188
|
+
const bin = join(job.runRoot, "node_modules/likec4/bin/likec4.mjs");
|
|
1189
|
+
if (!lstatExists(bin)) return done();
|
|
1190
|
+
emit(job, { type: "log", stream: "stdout", message: "likec4 → src/likec4/generated.jsx" });
|
|
1191
|
+
const child = spawn(process.execPath, [bin, "gen", "react", "likec4", "-o", "src/likec4/generated.jsx", "--no-use-dot"], { cwd: job.runRoot, stdio: ["ignore", "pipe", "pipe"] });
|
|
1192
|
+
let output = "";
|
|
1193
|
+
child.stdout.on("data", (chunk) => { output += chunk; });
|
|
1194
|
+
child.stderr.on("data", (chunk) => { output += chunk; });
|
|
1195
|
+
child.on("error", (error) => { emit(job, { type: "log", stream: "stderr", message: `likec4: ${error.message}` }); done(); });
|
|
1196
|
+
child.on("close", (code) => {
|
|
1197
|
+
if (code !== 0) emit(job, { type: "log", stream: "stderr", message: `likec4 gen react exited with ${code}:\n${output.trim()}` });
|
|
1198
|
+
done();
|
|
1199
|
+
});
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
|
|
996
1203
|
function startJob(workspace, mode, approvedPreview, preparedTrial) {
|
|
997
1204
|
const id = randomUUID();
|
|
998
|
-
const preview = mode === "preview" || mode === "project-preview";
|
|
1205
|
+
const preview = mode === "preview" || mode === "project-preview" || mode === "trace-preview";
|
|
999
1206
|
const fingerprint = preparedTrial?.fingerprint ?? (preview ? workspaceFingerprint(workspace) : approvedPreview?.fingerprint);
|
|
1000
1207
|
const snapshot = preparedTrial ?? (preview ? snapshotWorkspace(workspace) : null);
|
|
1001
1208
|
if (preview && !preparedTrial && workspaceFingerprint(workspace) !== fingerprint) {
|
|
@@ -1004,13 +1211,21 @@ function startJob(workspace, mode, approvedPreview, preparedTrial) {
|
|
|
1004
1211
|
}
|
|
1005
1212
|
const generatedAt = preview ? new Date().toISOString() : approvedPreview?.generatedAt;
|
|
1006
1213
|
const gitAuth = gitAuthEnvironment();
|
|
1007
|
-
const job = { id, mode, status: "running", events: [], subscribers: new Set(), buffers: { stdout: "", stderr: "" }, child: null, gitAuth, runRoot: snapshot?.snapshot ?? workspace, snapshotHolder: snapshot?.holder ?? null, fingerprint, generatedAt, projectPlan: preparedTrial?.plan ?? null, projectRequest: preparedTrial ? structuredClone(preparedTrial.request) : null };
|
|
1214
|
+
const job = { id, mode, status: "running", events: [], subscribers: new Set(), buffers: { stdout: "", stderr: "" }, child: null, gitAuth, runRoot: snapshot?.snapshot ?? workspace, snapshotHolder: snapshot?.holder ?? null, fingerprint, generatedAt, projectPlan: preparedTrial?.plan ?? null, projectRequest: preparedTrial?.request ? structuredClone(preparedTrial.request) : null, trace: preparedTrial?.trace ?? null };
|
|
1008
1215
|
jobs.set(id, job);
|
|
1009
1216
|
const cli = process.env.PORTOLAN_CLI;
|
|
1010
|
-
|
|
1217
|
+
// `npm run gen` builds the plugins first, which a checkout whose plugin
|
|
1218
|
+
// sources have not moved since the last build does not need: the fourth
|
|
1219
|
+
// trial of a recording in a row learns nothing from a minute of javac.
|
|
1220
|
+
// When every artefact is at least as new as its sources the generator
|
|
1221
|
+
// runs on its own; anything doubtful takes the road that builds.
|
|
1222
|
+
const fresh = !cli && pluginsFresh(job.runRoot);
|
|
1223
|
+
const command = cli || fresh ? process.execPath : process.platform === "win32" ? "npm.cmd" : "npm";
|
|
1011
1224
|
const args = cli
|
|
1012
1225
|
? [cli, mode === "check" ? "check" : "generate", "--cwd", job.runRoot]
|
|
1013
|
-
:
|
|
1226
|
+
: fresh
|
|
1227
|
+
? [join(job.runRoot, "scripts/gen.mjs"), ...(mode === "check" ? ["--check"] : [])]
|
|
1228
|
+
: ["run", mode === "check" ? "gen:check" : "gen"];
|
|
1014
1229
|
let child;
|
|
1015
1230
|
try {
|
|
1016
1231
|
child = spawn(command, args, {
|
|
@@ -1047,6 +1262,18 @@ function startJob(workspace, mode, approvedPreview, preparedTrial) {
|
|
|
1047
1262
|
}
|
|
1048
1263
|
catch (cause) { job.status = "failed"; emit(job, { type: "log", stream: "stderr", message: `Could not summarise project trial: ${cause instanceof Error ? cause.message : String(cause)}` }); }
|
|
1049
1264
|
}
|
|
1265
|
+
if (mode === "trace-preview" && job.status === "ok") {
|
|
1266
|
+
try {
|
|
1267
|
+
job.traceTrial = summarizeTraceTrial(job.runRoot, job.trace, job.events);
|
|
1268
|
+
emit(job, { type: "trace-trial-ready", ...job.traceTrial });
|
|
1269
|
+
}
|
|
1270
|
+
catch (cause) { job.status = "failed"; emit(job, { type: "log", stream: "stderr", message: `Could not summarise the recording: ${cause instanceof Error ? cause.message : String(cause)}` }); }
|
|
1271
|
+
}
|
|
1272
|
+
// The catalog is written, and the pictures' sources with it; the bundle
|
|
1273
|
+
// the dev server draws them from is built once before it starts, so a
|
|
1274
|
+
// write from the page rebuilds it here, or the new flow has no picture
|
|
1275
|
+
// until the next start.
|
|
1276
|
+
if (mode === "write" && job.status === "ok" && !process.env.PORTOLAN_CLI) await refreshLikeC4Bundle(job);
|
|
1050
1277
|
emit(job, { type: "process-finished", status: job.status, code, signal });
|
|
1051
1278
|
for (const response of job.subscribers) response.end();
|
|
1052
1279
|
job.subscribers.clear();
|
|
@@ -1077,6 +1304,12 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
|
|
|
1077
1304
|
const active = [...jobs.values()].find((job) => job.status === "running");
|
|
1078
1305
|
return send(res, 200, { local: true, workspace: realpathSync(workspace), setup: setup(workspace, publicSetupFrom), activeRun: active ? { id: active.id, mode: active.mode } : null });
|
|
1079
1306
|
}
|
|
1307
|
+
if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/django-aggregates`) {
|
|
1308
|
+
return send(res, 200, djangoAggregateProposals(workspace));
|
|
1309
|
+
}
|
|
1310
|
+
if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/rules`) {
|
|
1311
|
+
return send(res, 200, problemRulesState(workspace));
|
|
1312
|
+
}
|
|
1080
1313
|
if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/delivery-presets`) {
|
|
1081
1314
|
const features = url.searchParams.has("features")
|
|
1082
1315
|
? url.searchParams.get("features").split(",").filter(Boolean)
|
|
@@ -1096,10 +1329,31 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
|
|
|
1096
1329
|
req.on("close", () => job.subscribers.delete(res));
|
|
1097
1330
|
return;
|
|
1098
1331
|
}
|
|
1332
|
+
if (req.method === "POST" && url.pathname === `${LOCAL_API_PREFIX}/traces/trials` && req.headers["x-portolan-local"] === "1") {
|
|
1333
|
+
// The one upload the local API takes: a recording, as bytes,
|
|
1334
|
+
// named by headers rather than wrapped in JSON.
|
|
1335
|
+
if ([...jobs.values()].some((job) => job.status === "running")) return send(res, 409, { error: "A generator run is already active." });
|
|
1336
|
+
const content = await rawBody(req, UPLOAD_LIMIT);
|
|
1337
|
+
const projectId = String(req.headers["x-portolan-project"] ?? "");
|
|
1338
|
+
const name = decodeURIComponent(String(req.headers["x-portolan-filename"] ?? "recording.jsonl"));
|
|
1339
|
+
const prepared = prepareTraceTrial(workspace, { projectId, name, content });
|
|
1340
|
+
let job;
|
|
1341
|
+
try { job = startJob(workspace, "trace-preview", null, prepared); }
|
|
1342
|
+
catch (cause) { rmSync(prepared.holder, { recursive: true, force: true }); throw cause; }
|
|
1343
|
+
return send(res, 202, { runId: job.id, mode: job.mode, recording: posix.join(prepared.trace.root, prepared.trace.recording), project: projectId, stepAdded: prepared.trace.stepAdded, stepChange: prepared.trace.stepChange, spans: prepared.trace.spans });
|
|
1344
|
+
}
|
|
1099
1345
|
if (req.method !== "POST" || req.headers["content-type"]?.split(";")[0] !== "application/json" || req.headers["x-portolan-local"] !== "1") {
|
|
1100
1346
|
return send(res, 405, { error: "Use a local JSON request." });
|
|
1101
1347
|
}
|
|
1102
1348
|
const input = await body(req);
|
|
1349
|
+
if (url.pathname === `${LOCAL_API_PREFIX}/django-aggregates`) {
|
|
1350
|
+
if ([...jobs.values()].some((job) => job.status === "running")) throw new Error("Wait for the current generation to finish before saving aggregate roots.");
|
|
1351
|
+
return send(res, 200, saveDjangoAggregates(workspace, input));
|
|
1352
|
+
}
|
|
1353
|
+
if (url.pathname === `${LOCAL_API_PREFIX}/rules`) {
|
|
1354
|
+
if ([...jobs.values()].some((job) => job.status === "running")) throw new Error("Wait for the current generation to finish before saving rules.");
|
|
1355
|
+
return send(res, 200, saveProblemRules(workspace, input));
|
|
1356
|
+
}
|
|
1103
1357
|
if (url.pathname === `${LOCAL_API_PREFIX}/delivery-presets/install`) {
|
|
1104
1358
|
return send(res, 201, installDeliveryPreset(workspace, input));
|
|
1105
1359
|
}
|
|
@@ -1134,6 +1388,27 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
|
|
|
1134
1388
|
disposeProjectTrial(trial);
|
|
1135
1389
|
return send(res, 200, { runId: trial.id, status: "disposed" });
|
|
1136
1390
|
}
|
|
1391
|
+
const applyTraceMatch = url.pathname.match(/^\/__portolan\/traces\/trials\/([^/]+)\/apply$/);
|
|
1392
|
+
if (applyTraceMatch) {
|
|
1393
|
+
if ([...jobs.values()].some((job) => job.status === "running")) return send(res, 409, { error: "A generator run is already active." });
|
|
1394
|
+
const trial = jobs.get(applyTraceMatch[1]);
|
|
1395
|
+
if (!trial?.trace || trial.status !== "ok" || !trial.traceTrial) return send(res, 409, { error: "Run a successful recording trial before keeping it." });
|
|
1396
|
+
if (trial.applied) return send(res, 409, { error: "This recording was already kept." });
|
|
1397
|
+
if (workspaceFingerprint(workspace) !== trial.fingerprint) return send(res, 409, { error: "Files changed after this trial. Upload the recording again." });
|
|
1398
|
+
const result = applyTraceTrial(workspace, trial, { services: input.services, events: input.events, routes: input.routes });
|
|
1399
|
+
trial.applied = true;
|
|
1400
|
+
const generation = input.generate ? startJob(workspace, "write", trial) : null;
|
|
1401
|
+
return send(res, 201, { ...result, setup: setup(workspace, publicSetupFrom), run: generation ? { runId: generation.id, mode: generation.mode } : null });
|
|
1402
|
+
}
|
|
1403
|
+
const disposeTraceMatch = url.pathname.match(/^\/__portolan\/traces\/trials\/([^/]+)\/dispose$/);
|
|
1404
|
+
if (disposeTraceMatch) {
|
|
1405
|
+
const trial = jobs.get(disposeTraceMatch[1]);
|
|
1406
|
+
if (!trial?.trace) return send(res, 404, { error: "Recording trial not found." });
|
|
1407
|
+
if (trial.status === "running") return send(res, 409, { error: "Cancel the running trial first." });
|
|
1408
|
+
trial.trace.content = null;
|
|
1409
|
+
disposeProjectTrial(trial);
|
|
1410
|
+
return send(res, 200, { runId: trial.id, status: "disposed" });
|
|
1411
|
+
}
|
|
1137
1412
|
if (url.pathname === `${LOCAL_API_PREFIX}/projects/preview`) {
|
|
1138
1413
|
const manifest = readManifest(join(workspace, "portolan.json"));
|
|
1139
1414
|
return send(res, 200, projectRequestPlan(workspace, manifest, input).plan);
|
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { afterEach, describe, expect, it } from "vitest";
|
|
6
6
|
import { rmSync } from "node:fs";
|
|
7
7
|
|
|
8
|
-
import { classifyRepositoryFailure, diffGeneratedFiles, discoverProject, externalProjectDefaults, forgetRepositoryCredential, inspectionRoot, localApiPath, manifestWithoutProject, manifestWithProject, planProject, readLocalSource, removeProject, resolveRepositoryCommit, starterManifestProject, storeRepositoryCredential, summarizeProjectTrial, undoProjectRemoval, writeProject } from "./local-api.mjs";
|
|
8
|
+
import { classifyRepositoryFailure, diffGeneratedFiles, discoverProject, externalProjectDefaults, forgetRepositoryCredential, inspectionRoot, localApiPath, manifestWithoutProject, manifestWithProject, planProject, problemRulesState, readLocalSource, removeProject, saveProblemRules, resolveRepositoryCommit, starterManifestProject, storeRepositoryCredential, summarizeProjectTrial, undoProjectRemoval, workspaceFingerprint, writeManifest, writeProject } from "./local-api.mjs";
|
|
9
9
|
import { installDeliveryPreset, planDeliveryPreset, providerFromRemote, publicDeliveryPreset } from "./delivery-presets.mjs";
|
|
10
10
|
|
|
11
11
|
const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
@@ -48,7 +48,10 @@ describe("local API base path", () => {
|
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
// Each delivery-preset test spawns a dozen git processes; while the whole
|
|
52
|
+
// suite is starting, one spawn takes hundreds of milliseconds, so the
|
|
53
|
+
// default five seconds is a coin flip rather than a limit.
|
|
54
|
+
describe("local project setup", { timeout: 30_000 }, () => {
|
|
52
55
|
it("detects GitHub and GitLab remotes without treating arbitrary hosts as a forge", () => {
|
|
53
56
|
expect(providerFromRemote("git@github.com:acme/shop.git")).toBe("github");
|
|
54
57
|
expect(providerFromRemote("https://gitlab.example.com/acme/shop.git")).toBe("gitlab");
|
|
@@ -140,13 +143,18 @@ describe("local project setup", () => {
|
|
|
140
143
|
writeFileSync(join(root, ".gitlab-ci.yml"), "lint:\n script: echo lint\n");
|
|
141
144
|
const preview = planDeliveryPreset(root);
|
|
142
145
|
expect(preview).toMatchObject({ provider: "gitlab", detectedProvider: "gitlab", status: "available" });
|
|
143
|
-
expect(preview.
|
|
146
|
+
expect(preview.features.filter((feature) => feature.selected).map((feature) => feature.id)).toEqual(["pages"]);
|
|
147
|
+
expect(preview.files[0].diff).not.toContain("+\"portolan:check\":");
|
|
144
148
|
const installed = installDeliveryPreset(root, { provider: "gitlab", revision: preview.revision });
|
|
145
149
|
expect(installed.status).toBe("installed");
|
|
146
150
|
const pipeline = readFileSync(join(root, ".gitlab-ci.yml"), "utf8");
|
|
147
151
|
expect(pipeline).toContain("lint:\n script: echo lint");
|
|
148
152
|
expect(pipeline).toContain("# >>> Portolan delivery preset >>>");
|
|
149
|
-
expect(pipeline).toContain("
|
|
153
|
+
expect(pipeline).toContain("\npages:\n");
|
|
154
|
+
expect(pipeline).toContain("stage: deploy");
|
|
155
|
+
expect(pipeline).toContain("tags:\n - runner-type:docker");
|
|
156
|
+
expect(pipeline).toContain("portolan build --output public");
|
|
157
|
+
expect(pipeline).toContain("artifacts:\n paths:\n - public");
|
|
150
158
|
expect(existsSync(join(root, ".github"))).toBe(false);
|
|
151
159
|
});
|
|
152
160
|
|
|
@@ -239,6 +247,33 @@ describe("local project setup", () => {
|
|
|
239
247
|
expect(discovery.detections.find((item) => item.plugin === "adr")?.selected).toBe(false);
|
|
240
248
|
});
|
|
241
249
|
|
|
250
|
+
it("recognizes common ADR markdown variants and previews extracted fields", () => {
|
|
251
|
+
const root = workspace();
|
|
252
|
+
const adrRoot = join(root, "services/billing/docs/adr");
|
|
253
|
+
rmSync(join(adrRoot, "0001.md"));
|
|
254
|
+
writeFileSync(join(adrRoot, "0001-store-invoices.md"), "# 1. Store invoices\n\nDate: 2025-01-02\n\n## Status\n\nAccepted\n\n## Decision\n\nUse Postgres.\n");
|
|
255
|
+
writeFileSync(join(adrRoot, "02-package-layout.md"), "# ADR-2. Архитектура пакетов\n\n#### Статус: на рассмотрении\n\n### Решение\n\nУпростить структуру.\n");
|
|
256
|
+
|
|
257
|
+
const discovery = discoverProject(root, "services/billing");
|
|
258
|
+
const adr = discovery.detections.find((item) => item.plugin === "adr");
|
|
259
|
+
expect(adr).toMatchObject({
|
|
260
|
+
selected: true,
|
|
261
|
+
confidence: "high",
|
|
262
|
+
evidence: "docs/adr/*.md",
|
|
263
|
+
options: { files: ["docs/adr/*.md"] },
|
|
264
|
+
});
|
|
265
|
+
expect(adr?.preview).toEqual([
|
|
266
|
+
{ file: "docs/adr/0001-store-invoices.md", fields: { number: "1", title: "Store invoices", status: "accepted", date: "2025-01-02" } },
|
|
267
|
+
{ file: "docs/adr/02-package-layout.md", fields: { number: "2", title: "Архитектура пакетов", status: "proposed", date: "from git history" } },
|
|
268
|
+
]);
|
|
269
|
+
|
|
270
|
+
const manifest = JSON.parse(readFileSync(join(root, "portolan.json"), "utf8"));
|
|
271
|
+
const plan = planProject(root, manifest, {
|
|
272
|
+
root: "services/billing", id: "billing", name: "Billing", group: "finance", component: "billing", plugins: ["adr"],
|
|
273
|
+
});
|
|
274
|
+
expect(plan.steps[0].options).toEqual({ files: ["docs/adr/*.md"], scope: "finance.billing", out: "adr.json" });
|
|
275
|
+
});
|
|
276
|
+
|
|
242
277
|
it("names external projects from the repository or selected component instead of the inspection cache", () => {
|
|
243
278
|
expect(externalProjectDefaults("https://github.com/batazor/microservice-template-ddd")).toEqual({
|
|
244
279
|
id: "microservice-template-ddd",
|
|
@@ -691,7 +726,7 @@ describe("local project setup", () => {
|
|
|
691
726
|
plugins: ["openapi", "redis"],
|
|
692
727
|
steps: [{ plugin: "openapi", out: output }, { plugin: "redis", out: output }],
|
|
693
728
|
}, [
|
|
694
|
-
{ type: "step-finished", phase: "extract", plugin: "openapi", output, status: "written", durationMs: 4, fileCount: 1, changedCount: 1, files: [`${output}/api.json`], warnings: ["one route has no description"] },
|
|
729
|
+
{ type: "step-finished", phase: "extract", plugin: "openapi", output, status: "written", durationMs: 4, fileCount: 1, changedCount: 1, files: [`${output}/api.json`], warnings: ["one route has no description"], diagnostics: [{ plugin: "openapi", rule: "plugin.openapi.other-test", severity: "warning", action: "Inspect it.", message: "one route has no description", count: 1, project: "billing", phase: "extract", suppressed: true, suppressionReason: "Owned upstream." }] },
|
|
695
730
|
{ type: "step-finished", phase: "extract", plugin: "redis", output, status: "written", durationMs: 3, fileCount: 1, changedCount: 1, files: [`${output}/redis.json`], warnings: [] },
|
|
696
731
|
{ type: "step-finished", phase: "generate", plugin: "markdown", output: "docs", status: "written", durationMs: 2, fileCount: 10, changedCount: 10, files: ["docs/index.md"], warnings: [] },
|
|
697
732
|
]);
|
|
@@ -709,6 +744,116 @@ describe("local project setup", () => {
|
|
|
709
744
|
});
|
|
710
745
|
expect(result.steps.map((step) => step.plugin)).toEqual(["openapi", "redis"]);
|
|
711
746
|
expect(result.warnings).toEqual([{ plugin: "openapi", message: "one route has no description" }]);
|
|
747
|
+
expect(result.diagnostics).toEqual([expect.objectContaining({ rule: "plugin.openapi.other-test", suppressed: true })]);
|
|
712
748
|
expect(result.generatedFiles).toBe(2);
|
|
713
749
|
});
|
|
714
750
|
});
|
|
751
|
+
|
|
752
|
+
describe("workspaceFingerprint", () => {
|
|
753
|
+
it("reads a repository before its first commit without complaint, and moves when a file does", () => {
|
|
754
|
+
const root = workspace();
|
|
755
|
+
execFileSync("git", ["init", "-q", "-b", "main"], { cwd: root });
|
|
756
|
+
execFileSync("git", ["add", "services/billing/go.mod"], { cwd: root });
|
|
757
|
+
|
|
758
|
+
const before = workspaceFingerprint(root);
|
|
759
|
+
expect(workspaceFingerprint(root)).toBe(before);
|
|
760
|
+
|
|
761
|
+
writeFileSync(join(root, "services/billing/go.mod"), "module example.com/billing\n\ngo 1.22\n");
|
|
762
|
+
const staged = workspaceFingerprint(root);
|
|
763
|
+
expect(staged).not.toBe(before);
|
|
764
|
+
|
|
765
|
+
writeFileSync(join(root, "services/billing/api/handlers.go"), "package api\n");
|
|
766
|
+
expect(workspaceFingerprint(root)).not.toBe(staged);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("is the same whether or not the repository has a commit, for the same tree", () => {
|
|
770
|
+
const root = workspace();
|
|
771
|
+
execFileSync("git", ["init", "-q", "-b", "main"], { cwd: root });
|
|
772
|
+
const unborn = workspaceFingerprint(root);
|
|
773
|
+
execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "--allow-empty", "-m", "empty"], { cwd: root });
|
|
774
|
+
expect(workspaceFingerprint(root)).toBe(unborn);
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
describe("writing the manifest", () => {
|
|
779
|
+
it("keeps the file's own formatting and changes only what changed", () => {
|
|
780
|
+
const root = workspace();
|
|
781
|
+
const path = join(root, "portolan.json");
|
|
782
|
+
const before = [
|
|
783
|
+
"{",
|
|
784
|
+
' "sources": ["data/*.json", "services/*/portolan/*.json"],',
|
|
785
|
+
' "projects": [],',
|
|
786
|
+
' "plugins": [{ "name": "otel", "process": { "command": "true" } }],',
|
|
787
|
+
' "extract": [],',
|
|
788
|
+
' "verify": [',
|
|
789
|
+
" {",
|
|
790
|
+
' "plugin": "otel",',
|
|
791
|
+
' "in": "services/billing",',
|
|
792
|
+
' "out": "services/billing/portolan",',
|
|
793
|
+
' "options": {',
|
|
794
|
+
' "traces": [',
|
|
795
|
+
' "telemetry/traces.jsonl"',
|
|
796
|
+
" ]",
|
|
797
|
+
" }",
|
|
798
|
+
" }",
|
|
799
|
+
" ]",
|
|
800
|
+
"}",
|
|
801
|
+
"",
|
|
802
|
+
].join("\n");
|
|
803
|
+
writeFileSync(path, before);
|
|
804
|
+
|
|
805
|
+
const manifest = JSON.parse(before);
|
|
806
|
+
manifest.verify[0].options.traces.push("telemetry/recordings/*.jsonl");
|
|
807
|
+
writeManifest(path, manifest);
|
|
808
|
+
|
|
809
|
+
const after = readFileSync(path, "utf8");
|
|
810
|
+
expect(JSON.parse(after)).toEqual(manifest);
|
|
811
|
+
expect(after).toContain(' "sources": ["data/*.json", "services/*/portolan/*.json"],');
|
|
812
|
+
expect(after).toContain(' "plugins": [{ "name": "otel", "process": { "command": "true" } }],');
|
|
813
|
+
expect(after).toContain(' "telemetry/traces.jsonl",\n "telemetry/recordings/*.jsonl"\n ]');
|
|
814
|
+
expect(after.endsWith("}\n")).toBe(true);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it("still refuses a manifest the schema rejects, and leaves the file as it was", () => {
|
|
818
|
+
const root = workspace();
|
|
819
|
+
const path = join(root, "portolan.json");
|
|
820
|
+
const before = readFileSync(path, "utf8");
|
|
821
|
+
expect(() => writeManifest(path, { ...JSON.parse(before), verify: [{ plugin: "nope", in: "x", out: "y" }] })).toThrow();
|
|
822
|
+
expect(readFileSync(path, "utf8")).toBe(before);
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
describe("problem rules", () => {
|
|
827
|
+
it("reads the manifest's rules with a revision, and writes them back through the schema", () => {
|
|
828
|
+
const root = workspace();
|
|
829
|
+
const before = problemRulesState(root);
|
|
830
|
+
expect(before.rules).toEqual([]);
|
|
831
|
+
expect(before.revision).toMatch(/^[0-9a-f]{64}$/);
|
|
832
|
+
|
|
833
|
+
const rules = [
|
|
834
|
+
{ id: "shared-store", enabled: false, reason: "one database by design" },
|
|
835
|
+
{ id: "team.quiet-event", over: "event", severity: "warning", title: "Quiet event", when: "size(event.consumers) == 0", message: "'nothing consumes ' + event.id" },
|
|
836
|
+
];
|
|
837
|
+
const after = saveProblemRules(root, { revision: before.revision, rules });
|
|
838
|
+
expect(after.rules).toEqual(rules);
|
|
839
|
+
expect(after.revision).not.toBe(before.revision);
|
|
840
|
+
expect(JSON.parse(readFileSync(join(root, "portolan.json"), "utf8")).problemRules).toEqual(rules);
|
|
841
|
+
|
|
842
|
+
// Emptying the list removes the key, so a manifest that never had one
|
|
843
|
+
// does not gain an empty array.
|
|
844
|
+
const emptied = saveProblemRules(root, { revision: after.revision, rules: [] });
|
|
845
|
+
expect(emptied.rules).toEqual([]);
|
|
846
|
+
expect(JSON.parse(readFileSync(join(root, "portolan.json"), "utf8"))).not.toHaveProperty("problemRules");
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
it("refuses a stale revision and a rule the type check rejects, and leaves the file alone", () => {
|
|
850
|
+
const root = workspace();
|
|
851
|
+
const { revision } = problemRulesState(root);
|
|
852
|
+
const text = readFileSync(join(root, "portolan.json"), "utf8");
|
|
853
|
+
expect(() => saveProblemRules(root, { revision: "0".repeat(64), rules: [] })).toThrow(/changed since/);
|
|
854
|
+
expect(() => saveProblemRules(root, { revision, rules: [{ id: "team.a", over: "event", title: "A", when: "event.nme == 'x'", message: "'m'" }] })).toThrow(/nme/);
|
|
855
|
+
expect(() => saveProblemRules(root, { revision, rules: [{ id: "rpc", when: "true" }] })).toThrow(/built-in/);
|
|
856
|
+
expect(() => saveProblemRules(root, { revision, rules: [{ id: "team.b", over: "event", title: "B", when: "true", message: "'m'", extra: 1 }] })).toThrow();
|
|
857
|
+
expect(readFileSync(join(root, "portolan.json"), "utf8")).toBe(text);
|
|
858
|
+
});
|
|
859
|
+
});
|