@shortlink-org/portolan 0.3.0 → 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 +151 -6
- package/catalog/model.go +147 -0
- package/catalog/roundtrip_test.go +7 -0
- package/cli/portolan.mjs +6 -1
- package/go.mod +7 -0
- package/go.sum +20 -6
- package/package.json +10 -9
- package/plugin/describe.go +38 -0
- package/plugins/README.md +296 -5
- package/plugins/extract-argocd/options.schema.json +44 -0
- 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 +13 -2
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +2 -1
- package/plugins/extract-django/extract_test.py +13 -0
- package/plugins/extract-django/lifecycle.py +2 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- package/plugins/extract-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +5 -4
- package/plugins/extract-java/build/org/portolan/extract/Main.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/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- package/plugins/extract-ts/main.ts +1 -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/verify-otel/options.schema.json +12 -0
- package/portolan.json +130 -3
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +880 -13
- package/scripts/README.md +3 -1
- package/scripts/delivery-presets.mjs +36 -3
- package/scripts/gen-likec4.mjs +149 -16
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +30 -3
- package/scripts/gitops-example.test.mjs +108 -0
- 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 +1 -0
- 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 +216 -8
- package/scripts/local-api.test.mjs +88 -2
- package/scripts/local-discovery.mjs +12 -0
- package/scripts/manifest.mjs +10 -1
- package/scripts/plugin-host.mjs +23 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/run-builtin.mjs +26 -5
- package/scripts/schema.mjs +138 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +12 -6
- package/scripts/warning-policy.test.mjs +40 -3
- 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/SidebarFooter.tsx +20 -4
- package/src/catalog-index.ts +25 -0
- package/src/catalog-model.ts +246 -1
- package/src/catalog-validation.ts +104 -2
- package/src/catalog.test.ts +40 -1
- 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.tsx +1 -1
- package/src/components/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +21 -0
- package/src/enrich.test.ts +127 -3
- package/src/enrich.ts +104 -6
- 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 +53 -0
- package/src/flow/TraceTrial.tsx +419 -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 +24 -0
- package/src/landing/LandingPage.tsx +15 -11
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- 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/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 +96 -2
- 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/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 +9 -0
- package/src/lib/warnings.ts +10 -10
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +173 -9
- package/src/pages/AggregatePage.tsx +57 -7
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +2 -0
- 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 +58 -2
- package/src/pages/Settings.tsx +28 -2
- package/src/pages/settings/AboutSettings.tsx +8 -1
- 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/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,8 +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";
|
|
23
24
|
import { djangoAggregateCandidates } from "../src/lib/django-aggregates.mjs";
|
|
24
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";
|
|
25
28
|
import {
|
|
26
29
|
discoverProject,
|
|
27
30
|
matches,
|
|
@@ -311,7 +314,16 @@ function pluginOptions(plugin, project, detectedOptions = {}) {
|
|
|
311
314
|
out: "project.json",
|
|
312
315
|
};
|
|
313
316
|
}
|
|
314
|
-
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)) {
|
|
315
327
|
return { ...common, ...(project.repository ? { repo: repositoryParts(project.repository).web } : {}), ...detectedOptions, serviceName: project.name, out: "domain.json" };
|
|
316
328
|
}
|
|
317
329
|
if (plugin === "sql") return { ...common, store: "pg", ...detectedOptions, out: "stores.json" };
|
|
@@ -431,7 +443,7 @@ export function planProject(workspace, manifest, request) {
|
|
|
431
443
|
};
|
|
432
444
|
const out = posix.join(finalRoot, "portolan");
|
|
433
445
|
const detectionByPlugin = new Map(discovery.detections.map((item) => [item.plugin, item]));
|
|
434
|
-
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));
|
|
435
447
|
const projectDetectionOptions = {
|
|
436
448
|
groupKind: splitDeployables ? "system" : hasDomainModel ? "bounded-context" : "system",
|
|
437
449
|
...(hasDomainModel ? { componentKind: "service" } : {}),
|
|
@@ -456,7 +468,7 @@ export function planProject(workspace, manifest, request) {
|
|
|
456
468
|
if (!splitDeployables) {
|
|
457
469
|
const options = plugin === "project"
|
|
458
470
|
? projectDetectionOptions
|
|
459
|
-
: ["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)
|
|
460
472
|
? domainDetectionOptions(plugin)
|
|
461
473
|
: detectionByPlugin.get(plugin)?.options;
|
|
462
474
|
return [{ plugin, in: finalRoot, out, options: pluginOptions(plugin, project, options) }];
|
|
@@ -605,11 +617,19 @@ function projectRequestPlan(workspace, manifest, request) {
|
|
|
605
617
|
return { base, plan, starter };
|
|
606
618
|
}
|
|
607
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
|
+
*/
|
|
608
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);
|
|
609
629
|
const staging = mkdtempSync(join(dirname(path), ".portolan-manifest-"));
|
|
610
630
|
const temp = join(staging, "portolan.json");
|
|
611
631
|
try {
|
|
612
|
-
writeFileSync(temp, `${
|
|
632
|
+
writeFileSync(temp, `${text}\n`, { flag: "wx" });
|
|
613
633
|
const validation = loadManifest(temp);
|
|
614
634
|
if (validation.problems.length) throw new Error(validation.problems.join("\n"));
|
|
615
635
|
renameSync(temp, path);
|
|
@@ -664,6 +684,40 @@ export function saveDjangoAggregates(workspace, request) {
|
|
|
664
684
|
return { saved: request.selections.length };
|
|
665
685
|
}
|
|
666
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
|
+
|
|
667
721
|
export function writeProject(workspace, request) {
|
|
668
722
|
const manifestPath = join(workspace, "portolan.json");
|
|
669
723
|
const before = readFileSync(manifestPath, "utf8");
|
|
@@ -762,6 +816,18 @@ async function body(req) {
|
|
|
762
816
|
return text ? JSON.parse(text) : {};
|
|
763
817
|
}
|
|
764
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
|
+
|
|
765
831
|
function localRequest(req) {
|
|
766
832
|
const address = req.socket.remoteAddress ?? "";
|
|
767
833
|
const localAddress = address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
|
|
@@ -993,6 +1059,65 @@ function prepareProjectTrial(workspace, request) {
|
|
|
993
1059
|
return { ...snapshot, fingerprint, plan };
|
|
994
1060
|
}
|
|
995
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
|
+
|
|
996
1121
|
function freeLocalPort() {
|
|
997
1122
|
return new Promise((resolvePort, reject) => {
|
|
998
1123
|
const server = createNetServer();
|
|
@@ -1053,9 +1178,31 @@ async function startProjectPreview(job) {
|
|
|
1053
1178
|
return job.previewUrl;
|
|
1054
1179
|
}
|
|
1055
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
|
+
|
|
1056
1203
|
function startJob(workspace, mode, approvedPreview, preparedTrial) {
|
|
1057
1204
|
const id = randomUUID();
|
|
1058
|
-
const preview = mode === "preview" || mode === "project-preview";
|
|
1205
|
+
const preview = mode === "preview" || mode === "project-preview" || mode === "trace-preview";
|
|
1059
1206
|
const fingerprint = preparedTrial?.fingerprint ?? (preview ? workspaceFingerprint(workspace) : approvedPreview?.fingerprint);
|
|
1060
1207
|
const snapshot = preparedTrial ?? (preview ? snapshotWorkspace(workspace) : null);
|
|
1061
1208
|
if (preview && !preparedTrial && workspaceFingerprint(workspace) !== fingerprint) {
|
|
@@ -1064,13 +1211,21 @@ function startJob(workspace, mode, approvedPreview, preparedTrial) {
|
|
|
1064
1211
|
}
|
|
1065
1212
|
const generatedAt = preview ? new Date().toISOString() : approvedPreview?.generatedAt;
|
|
1066
1213
|
const gitAuth = gitAuthEnvironment();
|
|
1067
|
-
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 };
|
|
1068
1215
|
jobs.set(id, job);
|
|
1069
1216
|
const cli = process.env.PORTOLAN_CLI;
|
|
1070
|
-
|
|
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";
|
|
1071
1224
|
const args = cli
|
|
1072
1225
|
? [cli, mode === "check" ? "check" : "generate", "--cwd", job.runRoot]
|
|
1073
|
-
:
|
|
1226
|
+
: fresh
|
|
1227
|
+
? [join(job.runRoot, "scripts/gen.mjs"), ...(mode === "check" ? ["--check"] : [])]
|
|
1228
|
+
: ["run", mode === "check" ? "gen:check" : "gen"];
|
|
1074
1229
|
let child;
|
|
1075
1230
|
try {
|
|
1076
1231
|
child = spawn(command, args, {
|
|
@@ -1107,6 +1262,18 @@ function startJob(workspace, mode, approvedPreview, preparedTrial) {
|
|
|
1107
1262
|
}
|
|
1108
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)}` }); }
|
|
1109
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);
|
|
1110
1277
|
emit(job, { type: "process-finished", status: job.status, code, signal });
|
|
1111
1278
|
for (const response of job.subscribers) response.end();
|
|
1112
1279
|
job.subscribers.clear();
|
|
@@ -1140,6 +1307,9 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
|
|
|
1140
1307
|
if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/django-aggregates`) {
|
|
1141
1308
|
return send(res, 200, djangoAggregateProposals(workspace));
|
|
1142
1309
|
}
|
|
1310
|
+
if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/rules`) {
|
|
1311
|
+
return send(res, 200, problemRulesState(workspace));
|
|
1312
|
+
}
|
|
1143
1313
|
if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/delivery-presets`) {
|
|
1144
1314
|
const features = url.searchParams.has("features")
|
|
1145
1315
|
? url.searchParams.get("features").split(",").filter(Boolean)
|
|
@@ -1159,6 +1329,19 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
|
|
|
1159
1329
|
req.on("close", () => job.subscribers.delete(res));
|
|
1160
1330
|
return;
|
|
1161
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
|
+
}
|
|
1162
1345
|
if (req.method !== "POST" || req.headers["content-type"]?.split(";")[0] !== "application/json" || req.headers["x-portolan-local"] !== "1") {
|
|
1163
1346
|
return send(res, 405, { error: "Use a local JSON request." });
|
|
1164
1347
|
}
|
|
@@ -1167,6 +1350,10 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
|
|
|
1167
1350
|
if ([...jobs.values()].some((job) => job.status === "running")) throw new Error("Wait for the current generation to finish before saving aggregate roots.");
|
|
1168
1351
|
return send(res, 200, saveDjangoAggregates(workspace, input));
|
|
1169
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
|
+
}
|
|
1170
1357
|
if (url.pathname === `${LOCAL_API_PREFIX}/delivery-presets/install`) {
|
|
1171
1358
|
return send(res, 201, installDeliveryPreset(workspace, input));
|
|
1172
1359
|
}
|
|
@@ -1201,6 +1388,27 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
|
|
|
1201
1388
|
disposeProjectTrial(trial);
|
|
1202
1389
|
return send(res, 200, { runId: trial.id, status: "disposed" });
|
|
1203
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
|
+
}
|
|
1204
1412
|
if (url.pathname === `${LOCAL_API_PREFIX}/projects/preview`) {
|
|
1205
1413
|
const manifest = readManifest(join(workspace, "portolan.json"));
|
|
1206
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, workspaceFingerprint, 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");
|
|
@@ -771,3 +774,86 @@ describe("workspaceFingerprint", () => {
|
|
|
771
774
|
expect(workspaceFingerprint(root)).toBe(unborn);
|
|
772
775
|
});
|
|
773
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
|
+
});
|
|
@@ -387,6 +387,15 @@ function detectionsFor(root, files) {
|
|
|
387
387
|
const tsDomain = files.has("package.json") ? laidOutDomainEvidence(root, files, "typescript") : "";
|
|
388
388
|
const rustDomain = files.has("Cargo.toml") ? laidOutDomainEvidence(root, files, "rust") : "";
|
|
389
389
|
const javaDomain = ["pom.xml", "build.gradle", "build.gradle.kts"].some((name) => files.has(name)) ? laidOutDomainEvidence(root, files, "java") : "";
|
|
390
|
+
// A Laravel application keeps its Eloquent models under app/Models, or
|
|
391
|
+
// under each package's src/Models when it is built from packages.
|
|
392
|
+
const laravelDomain = files.has("composer.json") ? matches(files, /^(?:app|packages\/[^/]+\/[^/]+\/src)\/Models\/[^/]+\.php$/)[0] ?? "" : "";
|
|
393
|
+
// A PHP tree laid out by bounded context keeps each module's model under
|
|
394
|
+
// src/<Context>/<Module>/Domain, with Shared beside the contexts.
|
|
395
|
+
const phpDdd = files.has("composer.json") ? matches(files, /^src\/(?!Shared\/)[^/]+\/(?!Shared\/)[^/]+\/Domain\/[^/]+\.php$/)[0] ?? "" : "";
|
|
396
|
+
// A .NET tree laid out by module keeps each module's model under
|
|
397
|
+
// src/Modules/<Module>/Domain, with the HTTP host under src/API.
|
|
398
|
+
const csharpDdd = matches(files, /^src\/Modules\/[^/]+\/Domain\/.+\.cs$/)[0] ?? "";
|
|
390
399
|
return [
|
|
391
400
|
detected("project", projectEvidence, {}, projectEvidence.join(", ")),
|
|
392
401
|
detected("go-domain", goDomain ? [goDomain] : [], {}, goDomain),
|
|
@@ -394,6 +403,9 @@ function detectionsFor(root, files) {
|
|
|
394
403
|
detected("rust-domain", rustDomain ? [rustDomain] : [], {}, rustDomain),
|
|
395
404
|
detected("java-domain", javaDomain ? [javaDomain] : [], {}, javaDomain),
|
|
396
405
|
detected("django-domain", files.has("manage.py") && matches(files, /(^|\/)models(?:\/[^/]+)?\.py$/i).length ? ["manage.py"] : []),
|
|
406
|
+
detected("laravel-domain", laravelDomain ? [laravelDomain] : [], {}, laravelDomain),
|
|
407
|
+
detected("php-ddd", phpDdd ? [phpDdd] : [], {}, phpDdd),
|
|
408
|
+
detected("csharp-ddd", csharpDdd ? [csharpDdd] : [], {}, csharpDdd),
|
|
397
409
|
detected("celery", celery, {}, celery[0]),
|
|
398
410
|
detected("openapi", openapi, openapi[0] ? { spec: openapi[0] } : {}, openapi[0], true),
|
|
399
411
|
detected(
|
package/scripts/manifest.mjs
CHANGED
|
@@ -13,6 +13,12 @@ import { normalize } from "node:path";
|
|
|
13
13
|
|
|
14
14
|
import Ajv from "ajv/dist/2020.js";
|
|
15
15
|
import { warningPolicyProblems } from "./warning-policy.mjs";
|
|
16
|
+
import { problemRuleProblems } from "../src/lib/problem-rules-cel.mjs";
|
|
17
|
+
|
|
18
|
+
// The rules that have a reader, by id, so that an entry naming one is read as
|
|
19
|
+
// a switch and any other as a rule of its own. Read once, like the schema:
|
|
20
|
+
// the file is part of the package, and a manifest cannot add to it.
|
|
21
|
+
const builtinRuleIds = () => JSON.parse(readFileSync(new URL("../rules/builtin.json", import.meta.url), "utf8")).map((rule) => rule.id);
|
|
16
22
|
|
|
17
23
|
// Read when asked, not when loaded: the CLI sets PORTOLAN_SCHEMA after its imports.
|
|
18
24
|
const schemaFile = () => process.env.PORTOLAN_SCHEMA || "schema/portolan.schema.json";
|
|
@@ -39,7 +45,10 @@ export function loadManifest(path = "portolan.json") {
|
|
|
39
45
|
*/
|
|
40
46
|
export function parseManifest(text, path = "portolan.json") {
|
|
41
47
|
const manifest = JSON.parse(text);
|
|
42
|
-
const policyProblems =
|
|
48
|
+
const policyProblems = [
|
|
49
|
+
...warningPolicyProblems(manifest.warningPolicies, path),
|
|
50
|
+
...problemRuleProblems(manifest.problemRules, builtinRuleIds(), path),
|
|
51
|
+
];
|
|
43
52
|
|
|
44
53
|
let schema;
|
|
45
54
|
try {
|
package/scripts/plugin-host.mjs
CHANGED
|
@@ -90,7 +90,7 @@ export async function runPlugin(plugin, request, requestedLimits = {}, access =
|
|
|
90
90
|
// can be named, so a manifest cannot point the host at arbitrary code.
|
|
91
91
|
// ---------------------------------------------------------------------------
|
|
92
92
|
|
|
93
|
-
const HOST_PLUGINS = new Set(["fetch-git", "fetch-bsr", "fetch-csr"]);
|
|
93
|
+
const HOST_PLUGINS = new Set(["fetch-git", "fetch-bsr", "fetch-csr", "fetch-k8s", "fetch-argocd"]);
|
|
94
94
|
|
|
95
95
|
async function runHost(plugin, request) {
|
|
96
96
|
const name = String(plugin.host);
|
|
@@ -215,6 +215,25 @@ function safeFileName(name) {
|
|
|
215
215
|
return parts.every((part) => part !== "" && part !== "." && part !== "..");
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
/**
|
|
219
|
+
* What a plugin reads, or what it makes - the groups of the plugin index. A
|
|
220
|
+
* phase says where in a run a plugin goes; the category says what kind of
|
|
221
|
+
* fact it is after, which is the question a reader choosing extractors asks.
|
|
222
|
+
* Mirrors the Category constants in plugin/describe.go.
|
|
223
|
+
*/
|
|
224
|
+
export const PLUGIN_CATEGORIES = [
|
|
225
|
+
"code",
|
|
226
|
+
"contracts",
|
|
227
|
+
"messaging",
|
|
228
|
+
"data",
|
|
229
|
+
"infrastructure",
|
|
230
|
+
"repository",
|
|
231
|
+
"documents",
|
|
232
|
+
"evidence",
|
|
233
|
+
"sources",
|
|
234
|
+
"exports",
|
|
235
|
+
];
|
|
236
|
+
|
|
218
237
|
function validateDescriptor(pluginName, descriptor) {
|
|
219
238
|
if (!descriptor || typeof descriptor !== "object" || Array.isArray(descriptor)) {
|
|
220
239
|
throw new Error(`plugin ${pluginName}: describe is not an object`);
|
|
@@ -225,6 +244,9 @@ function validateDescriptor(pluginName, descriptor) {
|
|
|
225
244
|
if (typeof descriptor.summary !== "string") {
|
|
226
245
|
throw new Error(`plugin ${pluginName}: describe.summary is missing`);
|
|
227
246
|
}
|
|
247
|
+
if (!PLUGIN_CATEGORIES.includes(descriptor.category)) {
|
|
248
|
+
throw new Error(`plugin ${pluginName}: describe.category must be one of ${PLUGIN_CATEGORIES.join(", ")}`);
|
|
249
|
+
}
|
|
228
250
|
if (!Array.isArray(descriptor.phases) || descriptor.phases.some((phase) => !["extract", "verify", "generate"].includes(phase))) {
|
|
229
251
|
throw new Error(`plugin ${pluginName}: describe.phases is invalid`);
|
|
230
252
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Says whether the built plugins are newer than their sources, so that a run
|
|
2
|
+
// started from the page can skip building them.
|
|
3
|
+
//
|
|
4
|
+
// `npm run gen` builds the wasm and the Java extractor first, every time,
|
|
5
|
+
// which is right for a checkout somebody just changed and wrong for the
|
|
6
|
+
// fourth trial of a recording in a row: a minute of javac and the Go
|
|
7
|
+
// toolchain to learn nothing changed. The page asks here first and runs the
|
|
8
|
+
// generator alone when the answer is yes. Anything doubtful - a missing
|
|
9
|
+
// artefact, a source newer than it, a tree that cannot be read - is "no",
|
|
10
|
+
// and the build runs as it always did.
|
|
11
|
+
|
|
12
|
+
import { readdirSync, statSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
/** Where the Go sources the wasm is built from live, relative to the workspace. */
|
|
16
|
+
const GO_ROOTS = ["plugins", "catalog", "plugin", "internal", "go.mod", "go.sum"];
|
|
17
|
+
const GO_WASM = "plugins/portolan-go.wasm";
|
|
18
|
+
const JAVA_SOURCES = "plugins/extract-java/src";
|
|
19
|
+
const JAVA_BUILD = "plugins/extract-java/build";
|
|
20
|
+
|
|
21
|
+
/** Directories under a source root that hold no sources of ours. */
|
|
22
|
+
const SKIP = new Set(["node_modules", "target", "build", "testdata", ".git", "vendor"]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The newest modification time under a path, counting only files whose
|
|
26
|
+
* name passes `keep`, or -1 when nothing does. A path that cannot be read is
|
|
27
|
+
* treated as newer than anything, which sends the caller down the safe road.
|
|
28
|
+
*/
|
|
29
|
+
export function newestMtime(root, keep) {
|
|
30
|
+
let newest = -1;
|
|
31
|
+
const pending = [root];
|
|
32
|
+
while (pending.length) {
|
|
33
|
+
const path = pending.pop();
|
|
34
|
+
let stat;
|
|
35
|
+
try {
|
|
36
|
+
stat = statSync(path);
|
|
37
|
+
} catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (stat.isDirectory()) {
|
|
41
|
+
let entries;
|
|
42
|
+
try {
|
|
43
|
+
entries = readdirSync(path, { withFileTypes: true });
|
|
44
|
+
} catch {
|
|
45
|
+
return Number.POSITIVE_INFINITY;
|
|
46
|
+
}
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (entry.isDirectory() && SKIP.has(entry.name)) continue;
|
|
49
|
+
pending.push(join(path, entry.name));
|
|
50
|
+
}
|
|
51
|
+
} else if (keep(path)) {
|
|
52
|
+
newest = Math.max(newest, stat.mtimeMs);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return newest;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function oldestArtefact(root, keep) {
|
|
59
|
+
let oldest = Number.POSITIVE_INFINITY;
|
|
60
|
+
let any = false;
|
|
61
|
+
const pending = [root];
|
|
62
|
+
while (pending.length) {
|
|
63
|
+
const path = pending.pop();
|
|
64
|
+
let stat;
|
|
65
|
+
try {
|
|
66
|
+
stat = statSync(path);
|
|
67
|
+
} catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (stat.isDirectory()) {
|
|
71
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) pending.push(join(path, entry.name));
|
|
72
|
+
} else if (keep(path)) {
|
|
73
|
+
any = true;
|
|
74
|
+
oldest = Math.min(oldest, stat.mtimeMs);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return any ? oldest : -1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether every built plugin is at least as new as every source it is built
|
|
82
|
+
* from. `true` means `node scripts/gen.mjs` can run on its own; anything
|
|
83
|
+
* else means `npm run gen`, which builds first.
|
|
84
|
+
*/
|
|
85
|
+
export function pluginsFresh(workspace) {
|
|
86
|
+
const wasm = oldestArtefact(join(workspace, GO_WASM), () => true);
|
|
87
|
+
if (wasm < 0) return false;
|
|
88
|
+
const goSources = Math.max(
|
|
89
|
+
...GO_ROOTS.map((root) => newestMtime(join(workspace, root), (path) => /\.(go|mod|sum)$/.test(path))),
|
|
90
|
+
);
|
|
91
|
+
if (!(goSources <= wasm)) return false;
|
|
92
|
+
|
|
93
|
+
const classes = oldestArtefact(join(workspace, JAVA_BUILD), (path) => path.endsWith(".class"));
|
|
94
|
+
if (classes < 0) return false;
|
|
95
|
+
const javaSources = newestMtime(join(workspace, JAVA_SOURCES), (path) => path.endsWith(".java"));
|
|
96
|
+
return javaSources <= classes;
|
|
97
|
+
}
|