@shortlink-org/portolan 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/catalog/enum_test.go +46 -0
- package/catalog/evidence_test.go +35 -0
- package/catalog/model.go +1066 -0
- package/catalog/roundtrip_test.go +203 -0
- package/catalog/via_test.go +38 -0
- package/cli/init.test.mjs +6 -1
- package/cli/portolan.mjs +8 -0
- package/cli/portolan.test.mjs +49 -0
- package/go.mod +14 -0
- package/go.sum +20 -0
- package/internal/gocall/README.md +19 -0
- package/internal/gocall/analyze.go +189 -0
- package/internal/gocall/analyze_test.go +107 -0
- package/internal/gohttp/analyze.go +2562 -0
- package/internal/gohttp/destination.go +373 -0
- package/internal/gohttp/endpoints.go +1067 -0
- package/internal/gohttp/roots.go +320 -0
- package/internal/gohttp/typed.go +96 -0
- package/internal/goscan/constants.go +85 -0
- package/internal/goscan/goscan_test.go +227 -0
- package/internal/goscan/index.go +629 -0
- package/internal/goscan/index_test.go +66 -0
- package/internal/goscan/names.go +52 -0
- package/internal/goscan/parse_test.go +11 -0
- package/internal/goscan/source.go +37 -0
- package/internal/goscan/tree.go +284 -0
- package/internal/goscan/types.go +99 -0
- package/internal/wsdl/ids.go +127 -0
- package/internal/wsdl/ids_test.go +21 -0
- package/internal/wsdl/model.go +70 -0
- package/internal/wsdl/parse.go +949 -0
- package/internal/wsdl/parse_test.go +170 -0
- package/package.json +22 -10
- package/plugin/describe.go +118 -0
- package/plugin/describe_test.go +114 -0
- package/plugin/protocol.go +141 -0
- package/plugin/schematest/schematest.go +126 -0
- package/plugins/README.md +109 -46
- package/plugins/cmd/portolan-http-clients/main.go +19 -0
- package/plugins/extract-celery/extract.py +0 -2
- package/plugins/extract-celery/extract_test.py +1 -1
- package/plugins/extract-django/README.md +39 -17
- package/plugins/extract-django/domain.py +28 -17
- package/plugins/extract-django/extract.py +21 -7
- package/plugins/extract-django/extract_test.py +55 -2
- package/plugins/extract-django/lifecycle.py +2 -0
- package/plugins/extract-django/operations.py +1 -1
- package/plugins/extract-django/routing_test.py +109 -1
- package/plugins/extract-django/store.py +1 -1
- package/plugins/extract-django/transport.py +101 -55
- package/plugins/extract-django/verbs.py +241 -0
- package/plugins/extract-go/README.md +47 -0
- package/plugins/extract-http-clients/describe.go +19 -0
- package/plugins/extract-http-clients/describe_test.go +11 -0
- package/plugins/extract-http-clients/extract.go +740 -0
- package/plugins/extract-http-clients/extract_test.go +1561 -0
- package/plugins/extract-http-clients/main.go +41 -0
- package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
- package/plugins/extract-python-kafka/extract.py +0 -2
- package/plugins/extract-python-kafka/extract_test.py +1 -1
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/extract.ts +4 -5
- package/plugins/extract-ts/graphql.test.ts +1 -1
- package/plugins/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/protocol.py +1 -5
- package/portolan.json +3 -2
- package/schema/portolan.schema.json +34 -0
- package/scripts/README.md +18 -12
- package/scripts/catalog-sources.mjs +6 -0
- package/scripts/delivery-presets.mjs +21 -11
- package/scripts/diff.mjs +5 -1
- package/scripts/django-aggregates.test.mjs +58 -0
- package/scripts/gen-likec4.mjs +1 -1
- package/scripts/gen.mjs +118 -115
- package/scripts/go-discovery.test.mjs +30 -0
- package/scripts/history.mjs +186 -3
- package/scripts/history.test.mjs +1 -1
- package/scripts/host-plugins/fetch-git.mjs +77 -21
- package/scripts/host-plugins/fetch-git.test.mjs +62 -8
- package/scripts/local-api.mjs +71 -4
- package/scripts/local-api.test.mjs +63 -4
- package/scripts/local-discovery.mjs +82 -9
- package/scripts/manifest.mjs +5 -3
- package/scripts/manifest.test.mjs +24 -0
- package/scripts/output-diff.mjs +94 -0
- package/scripts/output-diff.test.mjs +36 -0
- package/scripts/package-smoke.mjs +62 -4
- package/scripts/plugin-host.mjs +22 -2
- package/scripts/plugin-host.test.mjs +9 -0
- package/scripts/plugin-wasm-worker.mjs +4 -1
- package/scripts/provenance.mjs +72 -0
- package/scripts/provenance.test.mjs +149 -0
- package/scripts/run-builtin.mjs +39 -5
- package/scripts/schema.mjs +29 -0
- package/scripts/warning-policy.mjs +161 -0
- package/scripts/warning-policy.test.mjs +56 -0
- package/src/app/Sidebar.tsx +3 -3
- package/src/catalog-docs.test.ts +64 -0
- package/src/catalog-docs.ts +35 -0
- package/src/catalog-error.test.ts +15 -0
- package/src/catalog-model.ts +48 -5
- package/src/catalog-validation.ts +9 -0
- package/src/chat/Starter.tsx +5 -11
- package/src/chat/tools.test.ts +27 -0
- package/src/chat/tools.ts +5 -9
- package/src/components/CatalogStamp.tsx +10 -8
- package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
- package/src/components/HTTPDestinationEvidence.tsx +31 -0
- package/src/components/Integrations.tsx +1 -1
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/data.ts +25 -7
- package/src/enrich.test.ts +332 -1
- package/src/enrich.ts +206 -3
- package/src/flow/StepDetail.tsx +6 -0
- package/src/flow/evidence.test.ts +16 -0
- package/src/flow/evidence.ts +34 -0
- package/src/index.css +44 -0
- package/src/landing/DraggableReveal.tsx +3 -2
- package/src/landing/EvidencePipeline.tsx +105 -0
- package/src/landing/LandingPage.tsx +2 -59
- package/src/lib/catalog-diff.ts +1 -1
- package/src/lib/django-aggregates.d.mts +9 -0
- package/src/lib/django-aggregates.mjs +36 -0
- package/src/lib/django-aggregates.test.ts +29 -0
- package/src/lib/django-aggregates.ts +5 -0
- package/src/lib/local-api.ts +20 -2
- package/src/lib/setup-info.test.ts +17 -0
- package/src/lib/setup-info.ts +58 -0
- package/src/lib/warnings.test.ts +54 -0
- package/src/lib/warnings.ts +260 -0
- package/src/map/ContextMapGraph.tsx +76 -32
- package/src/merge.ts +16 -9
- package/src/pages/AggregatePage.tsx +8 -7
- package/src/pages/ContextPage.tsx +6 -5
- package/src/pages/ServicePage.tsx +4 -3
- package/src/pages/Settings.tsx +189 -41
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- package/src/selection/DetailPanel.tsx +15 -0
- package/src/virtual-provenance.d.ts +11 -0
- package/vite.config.ts +5 -0
- package/scripts/vendor-lock.mjs +0 -58
- package/scripts/vendor-lock.test.mjs +0 -69
package/src/pages/Settings.tsx
CHANGED
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
import type { Discovery, ProjectDraft, ProjectPlan, RunEvent } from "../lib/local-api";
|
|
49
49
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
50
50
|
import { localKeys, localStatusQuery } from "../lib/queries";
|
|
51
|
-
import { sourceHref, treeHref } from "../lib/source-link";
|
|
51
|
+
import { bare, sourceHref, treeHref } from "../lib/source-link";
|
|
52
52
|
import { paths } from "../routes";
|
|
53
53
|
import { CapabilityEmpty, Empty, SectionTitle } from "../components/PageHeader";
|
|
54
54
|
import { Modal } from "../components/Overlay";
|
|
@@ -57,8 +57,13 @@ import { DeliverySettings } from "./settings/DeliverySettings";
|
|
|
57
57
|
import { PreferencesSettings } from "./settings/PreferencesSettings";
|
|
58
58
|
import { AboutSettings } from "./settings/AboutSettings";
|
|
59
59
|
import { IntegrationsSettings } from "./settings/IntegrationsSettings";
|
|
60
|
+
import { DjangoAggregateChoices } from "./settings/DjangoAggregateChoices";
|
|
61
|
+
import { djangoAggregateCandidates, djangoAggregateMessage } from "../lib/django-aggregates";
|
|
60
62
|
import { CatEmptyState, CatIllustration } from "../components/CatIllustration";
|
|
61
63
|
import { CommitLink } from "../components/CommitLink";
|
|
64
|
+
import { groupDiagnostics, warningDiagnostic } from "../lib/warnings";
|
|
65
|
+
import type { WarningDiagnostic, WarningSeverity } from "../lib/warnings";
|
|
66
|
+
import { AnimatePresence, m, rise } from "../lib/motion";
|
|
62
67
|
|
|
63
68
|
type Health = "healthy" | "changed" | "failed" | "unchecked";
|
|
64
69
|
type ProjectSource = ProjectDraft["source"];
|
|
@@ -261,44 +266,89 @@ function PipelineSteps({ steps }: { steps: SetupRunStep[] }) {
|
|
|
261
266
|
if (steps.length === 0) {
|
|
262
267
|
return <p className="mono text-muted">No result recorded for these steps.</p>;
|
|
263
268
|
}
|
|
269
|
+
const warnings = steps.flatMap((step) => step.warnings.map((message) => ({ plugin: step.plugin, message })));
|
|
270
|
+
const diagnostics = steps.flatMap((step) => step.diagnostics.length
|
|
271
|
+
? step.diagnostics
|
|
272
|
+
: step.warnings.map((message) => warningDiagnostic({ plugin: step.plugin, message })));
|
|
264
273
|
return (
|
|
265
|
-
<div className="
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
<div className="
|
|
269
|
-
<
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
<
|
|
274
|
-
|
|
274
|
+
<div className="space-y-2">
|
|
275
|
+
<div className="divide-y divide-line rounded-control border border-line">
|
|
276
|
+
{steps.map((step) => (
|
|
277
|
+
<div key={step.ordinal} className="px-3 py-2">
|
|
278
|
+
<div className="grid gap-2 sm:grid-cols-[5rem_minmax(7rem,1fr)_auto_auto] sm:items-center">
|
|
279
|
+
<span className="mono text-muted">{step.phase}</span>
|
|
280
|
+
<span className="mono truncate text-ink" title={step.plugin}>{step.plugin}</span>
|
|
281
|
+
<span className="mono text-muted">{step.fileCount} {plural(step.fileCount, "file")}</span>
|
|
282
|
+
<div className="flex items-center justify-between gap-2 sm:justify-end">
|
|
283
|
+
<span className="mono text-muted">{duration(step.durationMs)}</span>
|
|
284
|
+
<StepStatus status={step.status} />
|
|
285
|
+
</div>
|
|
275
286
|
</div>
|
|
276
287
|
</div>
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
288
|
+
))}
|
|
289
|
+
</div>
|
|
290
|
+
{warnings.length ? <details open={warnings.length <= 5}>
|
|
291
|
+
<summary className="mono cursor-pointer text-muted">{warnings.length} {plural(warnings.length, "warning")}</summary>
|
|
292
|
+
<div className="mt-2"><WarningPanel diagnostics={diagnostics} compact /></div>
|
|
293
|
+
</details> : null}
|
|
280
294
|
</div>
|
|
281
295
|
);
|
|
282
296
|
}
|
|
283
297
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
298
|
+
function WarningPanel({ diagnostics, compact = false }: { diagnostics: WarningDiagnostic[]; compact?: boolean }) {
|
|
299
|
+
const groups = groupDiagnostics(diagnostics);
|
|
300
|
+
const plugins = [...new Set(groups.map((group) => group.plugin))].sort();
|
|
301
|
+
const rules = [...new Set(groups.map((group) => group.rule))].sort();
|
|
302
|
+
const [plugin, setPlugin] = useState("all");
|
|
303
|
+
const [rule, setRule] = useState("all");
|
|
304
|
+
const [severity, setSeverity] = useState<WarningSeverity | "all">("all");
|
|
305
|
+
const [visibility, setVisibility] = useState<"active" | "suppressed" | "all">("active");
|
|
306
|
+
const shown = groups.filter((group) =>
|
|
307
|
+
(plugin === "all" || group.plugin === plugin)
|
|
308
|
+
&& (rule === "all" || group.rule === rule)
|
|
309
|
+
&& (severity === "all" || group.severity === severity)
|
|
310
|
+
&& (visibility === "all" || (visibility === "suppressed") === group.suppressed),
|
|
311
|
+
);
|
|
312
|
+
const activeCount = groups.filter((group) => !group.suppressed).reduce((sum, group) => sum + group.count, 0);
|
|
313
|
+
const suppressedCount = diagnostics.length - activeCount;
|
|
314
|
+
|
|
289
315
|
return (
|
|
290
|
-
<
|
|
291
|
-
<
|
|
292
|
-
{
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
<
|
|
297
|
-
|
|
298
|
-
</
|
|
299
|
-
|
|
300
|
-
</
|
|
301
|
-
|
|
316
|
+
<div className="space-y-2">
|
|
317
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
318
|
+
<span className="mono text-muted">{activeCount} active · {groups.length} {plural(groups.length, "rule")}</span>
|
|
319
|
+
{suppressedCount ? <span className="chip status-verified">{suppressedCount} suppressed</span> : null}
|
|
320
|
+
<div className="flex flex-wrap gap-2 sm:ml-auto">
|
|
321
|
+
{plugins.length > 1 ? <select aria-label="Filter warnings by plugin" className={`${FIELD} w-auto py-1`} value={plugin} onChange={(event) => setPlugin(event.target.value)}><option value="all">all plugins</option>{plugins.map((name) => <option key={name} value={name}>{name}</option>)}</select> : null}
|
|
322
|
+
{rules.length > 1 ? <select aria-label="Filter warnings by rule" className={`${FIELD} w-auto py-1`} value={rule} onChange={(event) => setRule(event.target.value)}><option value="all">all rules</option>{rules.map((name) => <option key={name} value={name}>{name}</option>)}</select> : null}
|
|
323
|
+
<select aria-label="Filter warnings by severity" className={`${FIELD} w-auto py-1`} value={severity} onChange={(event) => setSeverity(event.target.value as WarningSeverity | "all")}><option value="all">all severities</option><option value="error">error</option><option value="warning">warning</option><option value="info">info</option></select>
|
|
324
|
+
<select aria-label="Filter active or suppressed warnings" className={`${FIELD} w-auto py-1`} value={visibility} onChange={(event) => setVisibility(event.target.value as "active" | "suppressed" | "all")}><option value="active">active</option><option value="suppressed">suppressed</option><option value="all">all</option></select>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
{shown.length ? <div className="space-y-2">{shown.map((group) => {
|
|
328
|
+
const color = group.severity === "error" ? "text-unresolved" : group.severity === "warning" ? "text-declared" : "text-muted";
|
|
329
|
+
const candidates = group.messages.filter((warning) => warning.aggregateCandidates || djangoAggregateCandidates(warning.message));
|
|
330
|
+
const examples = group.messages.filter((warning) => !warning.aggregateCandidates && !djangoAggregateCandidates(warning.message)).slice(0, compact ? 3 : 12);
|
|
331
|
+
return <details key={`${group.plugin}:${group.rule}:${group.suppressed}`} className={`rounded-control border px-3 py-2 ${group.suppressed ? "border-line opacity-75" : group.severity === "error" ? "border-unresolved" : "border-line"}`} open={!compact && shown.length <= 4}>
|
|
332
|
+
<summary className="cursor-pointer list-none">
|
|
333
|
+
<span className="flex flex-wrap items-center gap-2">
|
|
334
|
+
<CircleAlert size={15} className={`shrink-0 ${color}`} />
|
|
335
|
+
<span className="mono text-ink">{group.plugin}</span>
|
|
336
|
+
<span className={`chip ${group.severity === "error" ? "status-unresolved" : group.severity === "warning" ? "status-declared" : ""}`}>{group.severity}</span>
|
|
337
|
+
<span className="mono text-muted">{group.rule}</span>
|
|
338
|
+
<span className="chip ml-auto">×{group.count}</span>
|
|
339
|
+
</span>
|
|
340
|
+
<span className="mt-1 block text-muted"><span className="font-medium text-ink">Next:</span> {group.action}</span>
|
|
341
|
+
{group.suppressionReason ? <span className="mt-1 block text-muted"><span className="font-medium text-ink">Suppressed:</span> {group.suppressionReason}</span> : null}
|
|
342
|
+
</summary>
|
|
343
|
+
<ul className="mono mt-2 space-y-1 text-muted">
|
|
344
|
+
{examples.map((warning, index) => <li key={`${index}:${warning.message}`} className="break-words border-l-2 border-line pl-2">{djangoAggregateMessage(warning.message)}</li>)}
|
|
345
|
+
</ul>
|
|
346
|
+
{candidates.length ? <DjangoAggregateChoices warnings={candidates} /> : null}
|
|
347
|
+
{group.count > examples.length + candidates.length ? <p className="mono mt-2 text-faint">{group.count - examples.length - candidates.length} more occurrences</p> : null}
|
|
348
|
+
</details>;
|
|
349
|
+
})}</div> : <p className="rounded-control border border-line px-3 py-2 text-muted">No warnings match these filters.</p>}
|
|
350
|
+
{!compact && groups.some((group) => !group.suppressed) ? <p className="text-faint">Suppress a reviewed limitation with a typed CEL entry in <span className="mono">portolan.json → warningPolicies</span>. The reason is required and remains visible here after regeneration.</p> : null}
|
|
351
|
+
</div>
|
|
302
352
|
);
|
|
303
353
|
}
|
|
304
354
|
|
|
@@ -307,11 +357,22 @@ function ProjectCard({ project, onRemove }: { project: SetupProject; onRemove?:
|
|
|
307
357
|
const declared = setupInfo.steps.filter((step) => step.projectId === project.id);
|
|
308
358
|
const runSteps = setupInfo.run?.steps.filter((step) => step.projectId === project.id) ?? [];
|
|
309
359
|
const pluginNames = [...new Set(declared.map((step) => step.plugin))];
|
|
360
|
+
// A project rooted at the repository itself owns every source in it.
|
|
361
|
+
const rootedAtRepository = project.root === "." || project.root === "";
|
|
310
362
|
const sources = catalogSources.filter(
|
|
311
|
-
(source) => source.path === project.root || source.path.startsWith(`${project.root}/`),
|
|
363
|
+
(source) => rootedAtRepository || source.path === project.root || source.path.startsWith(`${project.root}/`),
|
|
312
364
|
);
|
|
313
365
|
const outputs = [...new Set(runSteps.flatMap((step) => step.files))];
|
|
366
|
+
// A vendored project's provenance is its pin: the upstream commit the copy
|
|
367
|
+
// was fetched at, which is also what its source links open. The history of
|
|
368
|
+
// THIS checkout says when the copy landed here, which is a different fact.
|
|
369
|
+
const pin = project.repository
|
|
370
|
+
? catalog.repos?.find((held) => bare(held.repo) === bare(project.repository!))
|
|
371
|
+
: undefined;
|
|
314
372
|
const commits = [...new Set(sources.map((source) => source.commit).filter(Boolean))];
|
|
373
|
+
// "uncommitted" is what the history says of a file it does not hold yet;
|
|
374
|
+
// it is not a commit, and a link built from it would open nothing.
|
|
375
|
+
const committed = commits.filter((commit) => commit !== "uncommitted");
|
|
315
376
|
const href = projectHref(project);
|
|
316
377
|
const sourceLink = forge(project);
|
|
317
378
|
const health = healthFor(runSteps, declared.length, setupInfo);
|
|
@@ -342,7 +403,7 @@ function ProjectCard({ project, onRemove }: { project: SetupProject; onRemove?:
|
|
|
342
403
|
<dt>scope</dt><dd className="truncate text-ink">{project.components?.length ? `${project.group ?? project.context} · ${project.components.length} components` : [project.group ?? project.context, project.component ?? project.service].filter(Boolean).join(" · ") || "estate"}</dd>
|
|
343
404
|
<dt>pipeline</dt><dd className="text-ink">{declared.length} {plural(declared.length, "step")}</dd>
|
|
344
405
|
<dt>fragments</dt><dd className="text-ink">{sources.length}</dd>
|
|
345
|
-
<dt>commit</dt><dd className="truncate text-ink" title={commits.join(", ")}>{
|
|
406
|
+
<dt>commit</dt><dd className="truncate text-ink" title={pin ? pin.commit : commits.join(", ")}>{pin ? <CommitLink commit={pin.commit} repository={project.repository} length={12} /> : committed.length === 1 ? <CommitLink commit={committed[0]!} repository={project.repository} length={12} /> : committed.length > 1 ? `${committed.length} source commits` : commits.length > 0 ? "uncommitted" : "not stamped"}</dd>
|
|
346
407
|
</dl>
|
|
347
408
|
|
|
348
409
|
<div className="mt-4 flex flex-wrap gap-1.5" aria-label="Active plugins">
|
|
@@ -829,11 +890,15 @@ function Wizard({ open, initialSource, onClose, onAdded, onRunStarted }: { open:
|
|
|
829
890
|
const trialRunning = stage === "trial" && !finished;
|
|
830
891
|
const confirmedDeployables = discovery?.deployables.filter((candidate) => candidate.confidence === "high") ?? [];
|
|
831
892
|
const splitsDeployables = confirmedDeployables.length > 1 && draft?.component === discovery?.defaults.component;
|
|
893
|
+
const trialDiagnostics = trial?.diagnostics ?? [];
|
|
894
|
+
const trialActiveWarnings = trial
|
|
895
|
+
? groupDiagnostics(trialDiagnostics).filter((group) => !group.suppressed).reduce((sum, group) => sum + group.count, 0)
|
|
896
|
+
: 0;
|
|
832
897
|
const quality = trial && draft ? [
|
|
833
898
|
{ label: "Stable project identity", done: Boolean(draft.id.trim() && draft.name.trim()) },
|
|
834
899
|
{ label: "Architecture placement", done: Boolean(draft.group.trim() && (splitsDeployables || draft.component.trim())) },
|
|
835
900
|
{ label: "Catalog facts extracted", done: trial.facts.some((fact) => fact.count > 0) },
|
|
836
|
-
{ label: "Extraction without warnings", done:
|
|
901
|
+
{ label: "Extraction without active warnings", done: trialActiveWarnings === 0 },
|
|
837
902
|
] : [];
|
|
838
903
|
const totalSteps = pipeline?.stepCount ?? plan?.steps.length ?? 0;
|
|
839
904
|
const percent = totalSteps ? Math.round((completedSteps.length / totalSteps) * 100) : 0;
|
|
@@ -877,7 +942,7 @@ function Wizard({ open, initialSource, onClose, onAdded, onRunStarted }: { open:
|
|
|
877
942
|
{discovery.detections.length ? <div className="grid gap-2">{discovery.detections.map((item) => {
|
|
878
943
|
const checked = draft.plugins.includes(item.plugin);
|
|
879
944
|
const capability = CAPABILITIES[item.plugin] ?? { title: item.plugin, summary: "Catalog facts extracted from project files." };
|
|
880
|
-
return <label key={item.plugin} className={`flex cursor-pointer items-start gap-3 rounded-control border px-3 py-3 transition-colors ${checked ? "border-accent bg-surface" : "border-line hover:bg-surface"}`}><input className="mt-1" type="checkbox" checked={checked} onChange={() => setDraft({ ...draft, plugins: checked ? draft.plugins.filter((name) => name !== item.plugin) : [...draft.plugins, item.plugin] })} /><span className="min-w-0 flex-1"><span className="flex flex-wrap items-center gap-2"><span className="font-medium text-ink">{capability.title}</span><span className={`chip ${item.confidence === "high" ? "status-verified" : "status-declared"}`}>{item.confidence} confidence</span>{item.candidates.length > 1 ? <span className="chip status-declared">{item.candidates.length} candidates</span> : null}</span><span className="mt-0.5 block text-muted">{capability.summary}</span><span className="mono mt-1 block truncate text-faint" title={item.evidence}>evidence · {item.evidence}</span><span className="mono mt-0.5 block text-faint">extractor · {item.plugin}</span></span></label>;
|
|
945
|
+
return <label key={item.plugin} className={`flex cursor-pointer items-start gap-3 rounded-control border px-3 py-3 transition-colors ${checked ? "border-accent bg-surface" : "border-line hover:bg-surface"}`}><input aria-label={`Toggle ${capability.title}`} className="mt-1" type="checkbox" checked={checked} onChange={() => setDraft({ ...draft, plugins: checked ? draft.plugins.filter((name) => name !== item.plugin) : [...draft.plugins, item.plugin] })} /><span className="min-w-0 flex-1"><span className="flex flex-wrap items-center gap-2"><span className="font-medium text-ink">{capability.title}</span><span className={`chip ${item.confidence === "high" ? "status-verified" : "status-declared"}`}>{item.confidence} confidence</span>{item.candidates.length > 1 ? <span className="chip status-declared">{item.candidates.length} candidates</span> : null}</span><span className="mt-0.5 block text-muted">{capability.summary}</span><span className="mono mt-1 block truncate text-faint" title={item.evidence}>evidence · {item.evidence}</span>{item.preview?.length ? <span className="mt-2 grid gap-1.5">{item.preview.slice(0, 3).map((preview) => <span key={preview.file} className="block rounded-control border border-line bg-canvas px-2 py-1.5"><span className="mono block truncate text-faint" title={preview.file}>{preview.file}</span><span className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-muted">{Object.entries(preview.fields).map(([field, value]) => <span key={field}><span className="text-faint">{field}</span> · <span className="text-ink">{value}</span></span>)}</span></span>)}{item.preview.length > 3 ? <span className="text-faint">+ {item.preview.length - 3} more recognized records</span> : null}</span> : null}<span className="mono mt-0.5 block text-faint">extractor · {item.plugin}</span></span></label>;
|
|
881
946
|
})}</div> : <Empty>no supported project signals found</Empty>}
|
|
882
947
|
</div>
|
|
883
948
|
</div>
|
|
@@ -895,7 +960,7 @@ function Wizard({ open, initialSource, onClose, onAdded, onRunStarted }: { open:
|
|
|
895
960
|
{trial ? <section><div className="label mb-2">what Portolan found</div>{trial.facts.length ? <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">{trial.facts.map((fact) => <div key={fact.key} className="rounded-control border border-line bg-canvas px-3 py-2"><div className="tnum text-lg font-semibold text-ink">{fact.count}</div><div className="text-muted">{fact.label}</div></div>)}</div> : <div className="rounded-control border border-line bg-surface px-3 py-3 text-muted">Only component metadata was found. You can add APIs, domain models and stores later without re-adding the project.</div>}</section> : null}
|
|
896
961
|
{trial ? <section><div className="label mb-2">catalog readiness</div><div className="grid gap-2 sm:grid-cols-2">{quality.map((item) => <div key={item.label} className="flex items-center gap-2 rounded-control border border-line px-3 py-2"><span className={`flex size-5 items-center justify-center rounded-full ${item.done ? "bg-verified text-canvas" : "bg-surface text-declared"}`}>{item.done ? <Check size={12} aria-hidden /> : <CircleAlert size={12} aria-hidden />}</span><span className={item.done ? "text-ink" : "text-muted"}>{item.label}</span></div>)}</div></section> : null}
|
|
897
962
|
<section><div className="label mb-2">extractor results</div><div className="divide-y divide-line rounded-control border border-line">{(trial?.steps ?? completedSteps).map((step) => <div key={`${step.plugin}:${"ordinal" in step ? step.ordinal : "trial"}`} className="grid gap-1 px-3 py-2 sm:grid-cols-[1fr_auto_auto]"><span><span className="font-medium text-ink">{CAPABILITIES[step.plugin]?.title ?? step.plugin}</span><span className="mono ml-2 text-faint">{step.plugin}</span>{step.message ? <span className="mt-1 block text-unresolved">{step.message}</span> : null}</span><span className="mono text-muted">{step.fileCount} {plural(step.fileCount, "file")}</span><span className={`chip ${step.status === "failed" ? "status-unresolved" : "status-verified"}`}>{step.status === "failed" ? "failed" : "read"}</span></div>)}</div></section>
|
|
898
|
-
{
|
|
963
|
+
{trialDiagnostics.length ? <section><div className="label mb-2">diagnostics · {trialDiagnostics.length}</div><WarningPanel diagnostics={trialDiagnostics} /></section> : null}
|
|
899
964
|
{finished?.status === "failed" && logs.length ? <details><summary className="cursor-pointer text-muted">Generator log · {logs.length} lines</summary><pre className="mono mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-control bg-surface p-3 text-muted">{logs.map((event) => event.message).join("\n")}</pre></details> : null}
|
|
900
965
|
{trial ? <div><div className="label mb-2">changes after apply</div><dl className="mono grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-muted"><dt>project</dt><dd className="text-ink">{plan.project.id}</dd>{plan.fetch ? <><dt>pin</dt><dd className="truncate text-ink"><CommitLink commit={plan.fetch.commit} repository={plan.fetch.repo} length={12} /></dd></> : null}<dt>source</dt><dd className="truncate text-ink">{plan.source}</dd><dt>pipeline</dt><dd className="text-ink">{plan.steps.length + (plan.fetch ? 1 : 0)} {plural(plan.steps.length + (plan.fetch ? 1 : 0), "extract step")}</dd><dt>fragments</dt><dd className="text-ink">{trial.generatedFiles} generated</dd></dl></div> : null}
|
|
901
966
|
</div>
|
|
@@ -1148,6 +1213,88 @@ function SettingsContent({ local, onAdd, onRemove, onGenerate }: { local: boolea
|
|
|
1148
1213
|
);
|
|
1149
1214
|
}
|
|
1150
1215
|
|
|
1216
|
+
function RemoveProjectDialog({
|
|
1217
|
+
project,
|
|
1218
|
+
busy,
|
|
1219
|
+
onClose,
|
|
1220
|
+
onConfirm,
|
|
1221
|
+
}: {
|
|
1222
|
+
project: SetupProject | null;
|
|
1223
|
+
busy: boolean;
|
|
1224
|
+
onClose: () => void;
|
|
1225
|
+
onConfirm: () => void;
|
|
1226
|
+
}) {
|
|
1227
|
+
const name = project?.name ?? "project";
|
|
1228
|
+
return (
|
|
1229
|
+
<Modal
|
|
1230
|
+
open={project !== null}
|
|
1231
|
+
onClose={busy ? () => {} : onClose}
|
|
1232
|
+
label={`Remove ${name}`}
|
|
1233
|
+
width="min(520px,92vw)"
|
|
1234
|
+
>
|
|
1235
|
+
<div className="flex items-center gap-3 border-b border-line px-5 py-4">
|
|
1236
|
+
<m.span
|
|
1237
|
+
animate={busy ? { rotate: 360 } : { rotate: 0 }}
|
|
1238
|
+
transition={busy ? { duration: 0.9, ease: "linear", repeat: Infinity } : undefined}
|
|
1239
|
+
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-unresolved/10 text-unresolved"
|
|
1240
|
+
>
|
|
1241
|
+
{busy ? <LoaderCircle size={17} aria-hidden /> : <Trash2 size={16} aria-hidden />}
|
|
1242
|
+
</m.span>
|
|
1243
|
+
<div className="min-w-0">
|
|
1244
|
+
<div className="font-semibold text-ink">{busy ? `Removing ${name}` : `Remove ${name}?`}</div>
|
|
1245
|
+
<div className="mono mt-0.5 truncate text-faint">{project?.id}</div>
|
|
1246
|
+
</div>
|
|
1247
|
+
</div>
|
|
1248
|
+
|
|
1249
|
+
<div className="min-h-40 p-5">
|
|
1250
|
+
<AnimatePresence mode="wait" initial={false}>
|
|
1251
|
+
{busy ? (
|
|
1252
|
+
<m.div key="removing" {...rise} aria-live="polite">
|
|
1253
|
+
<p className="font-medium text-ink">Updating project configuration…</p>
|
|
1254
|
+
<p className="mt-2 text-muted">
|
|
1255
|
+
Portolan is removing the project and its extraction steps. The source tree remains untouched.
|
|
1256
|
+
</p>
|
|
1257
|
+
<div
|
|
1258
|
+
role="progressbar"
|
|
1259
|
+
aria-label={`Removing ${name}`}
|
|
1260
|
+
className="mt-5 h-1.5 overflow-hidden rounded-full bg-surface"
|
|
1261
|
+
>
|
|
1262
|
+
<m.div
|
|
1263
|
+
className="h-full w-2/5 rounded-full bg-unresolved"
|
|
1264
|
+
initial={{ x: "-100%" }}
|
|
1265
|
+
animate={{ x: ["-100%", "250%"] }}
|
|
1266
|
+
transition={{ duration: 1.1, ease: "easeInOut", repeat: Infinity }}
|
|
1267
|
+
/>
|
|
1268
|
+
</div>
|
|
1269
|
+
</m.div>
|
|
1270
|
+
) : (
|
|
1271
|
+
<m.div key="confirm" {...rise}>
|
|
1272
|
+
<p className="text-muted">
|
|
1273
|
+
This removes the project, its extraction steps and its dedicated catalog configuration from <span className="mono text-ink">portolan.json</span>. Your source code stays untouched.
|
|
1274
|
+
</p>
|
|
1275
|
+
<p className="mt-3 text-muted">
|
|
1276
|
+
You can undo immediately. Generated documentation changes remain pending until you preview and apply them.
|
|
1277
|
+
</p>
|
|
1278
|
+
</m.div>
|
|
1279
|
+
)}
|
|
1280
|
+
</AnimatePresence>
|
|
1281
|
+
</div>
|
|
1282
|
+
|
|
1283
|
+
<div className="flex justify-end gap-2 border-t border-line px-5 py-4">
|
|
1284
|
+
<button type="button" className="tbtn" onClick={onClose} disabled={busy}>Cancel</button>
|
|
1285
|
+
<button type="button" className="product-primary product-danger min-w-36" onClick={onConfirm} disabled={busy}>
|
|
1286
|
+
<AnimatePresence mode="wait" initial={false}>
|
|
1287
|
+
<m.span key={busy ? "busy" : "ready"} {...rise} className="flex items-center justify-center gap-2">
|
|
1288
|
+
{busy ? <LoaderCircle size={15} aria-hidden /> : <Trash2 size={15} aria-hidden />}
|
|
1289
|
+
{busy ? "Removing…" : "Remove project"}
|
|
1290
|
+
</m.span>
|
|
1291
|
+
</AnimatePresence>
|
|
1292
|
+
</button>
|
|
1293
|
+
</div>
|
|
1294
|
+
</Modal>
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1151
1298
|
export function Settings() {
|
|
1152
1299
|
useDocumentTitle("Settings");
|
|
1153
1300
|
const queryClient = useQueryClient();
|
|
@@ -1210,11 +1357,12 @@ export function Settings() {
|
|
|
1210
1357
|
<SetupContext.Provider value={setup}>
|
|
1211
1358
|
<SettingsContent local={local} onAdd={setWizardSource} onRemove={setRemoveTarget} onGenerate={() => void generate()} />
|
|
1212
1359
|
<Wizard open={wizardSource !== null} initialSource={wizardSource ?? "local"} onClose={() => setWizardSource(null)} onAdded={setSetup} onRunStarted={(id) => { setRunId(id); setRunOpen(true); }} />
|
|
1213
|
-
<
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1360
|
+
<RemoveProjectDialog
|
|
1361
|
+
project={removeTarget}
|
|
1362
|
+
busy={removeBusy}
|
|
1363
|
+
onClose={() => setRemoveTarget(null)}
|
|
1364
|
+
onConfirm={() => void confirmRemove()}
|
|
1365
|
+
/>
|
|
1218
1366
|
<RunDialog runId={runId} open={runOpen} onClose={() => setRunOpen(false)} onFinished={refresh} onApply={(preview) => void generate(preview)} />
|
|
1219
1367
|
</SetupContext.Provider>
|
|
1220
1368
|
);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { djangoAggregateCandidates, djangoAggregateMessage } from "../../lib/django-aggregates";
|
|
4
|
+
import { djangoAggregateProposals, saveDjangoAggregates } from "../../lib/local-api";
|
|
5
|
+
import { localKeys, localStatusQuery } from "../../lib/queries";
|
|
6
|
+
import type { WarningDiagnostic } from "../../lib/warnings";
|
|
7
|
+
import { useToastStore } from "../../app/toast";
|
|
8
|
+
|
|
9
|
+
const CANDIDATE_KEY = ["local", "django-aggregates"];
|
|
10
|
+
|
|
11
|
+
export function DjangoAggregateChoices({ warnings }: { warnings: WarningDiagnostic[] }) {
|
|
12
|
+
const status = useQuery(localStatusQuery());
|
|
13
|
+
const queryClient = useQueryClient();
|
|
14
|
+
const say = useToastStore((state) => state.say);
|
|
15
|
+
const proposals = useQuery({
|
|
16
|
+
queryKey: [...CANDIDATE_KEY, status.data?.setup.run?.finishedAt],
|
|
17
|
+
queryFn: djangoAggregateProposals,
|
|
18
|
+
enabled: status.isSuccess,
|
|
19
|
+
retry: false,
|
|
20
|
+
});
|
|
21
|
+
const [choices, setChoices] = useState<Record<string, string>>({});
|
|
22
|
+
const [busy, setBusy] = useState(false);
|
|
23
|
+
const [error, setError] = useState("");
|
|
24
|
+
const [saved, setSaved] = useState(false);
|
|
25
|
+
const rows = warnings.flatMap((warning) => {
|
|
26
|
+
const candidates = warning.aggregateCandidates ?? djangoAggregateCandidates(warning.message);
|
|
27
|
+
if (!candidates) return [];
|
|
28
|
+
const matches = proposals.data?.proposals.filter((proposal) => proposal.plugin === warning.plugin && proposal.app === candidates.app
|
|
29
|
+
&& djangoAggregateMessage(proposal.message).slice(0, 500) === djangoAggregateMessage(warning.message).slice(0, 500)) ?? [];
|
|
30
|
+
return [{ ...candidates, key: `${warning.plugin}:${warning.message}`, proposal: matches.length === 1 ? matches[0] : undefined }];
|
|
31
|
+
});
|
|
32
|
+
const selections = rows.flatMap((row) => row.proposal && choices[row.key] ? [{ id: row.proposal.id, model: choices[row.key]! }] : []);
|
|
33
|
+
const canSave = Boolean(proposals.data && !proposals.data.stale && !status.data?.activeRun && selections.length && !busy);
|
|
34
|
+
|
|
35
|
+
async function save() {
|
|
36
|
+
if (!canSave || !proposals.data) return;
|
|
37
|
+
setBusy(true); setError(""); setSaved(false);
|
|
38
|
+
try {
|
|
39
|
+
await saveDjangoAggregates(proposals.data.revision, selections);
|
|
40
|
+
setSaved(true);
|
|
41
|
+
// Refreshing the manifest hides the now-stale report and unmounts this
|
|
42
|
+
// picker; keep the next step visible outside that report.
|
|
43
|
+
say("Aggregate roots saved to portolan.json. Preview generated diff to update the catalog.");
|
|
44
|
+
await Promise.all([
|
|
45
|
+
queryClient.invalidateQueries({ queryKey: CANDIDATE_KEY }),
|
|
46
|
+
queryClient.invalidateQueries({ queryKey: localKeys.status }),
|
|
47
|
+
]);
|
|
48
|
+
} catch (cause) {
|
|
49
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
50
|
+
} finally { setBusy(false); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return <div className="mt-3 space-y-3">
|
|
54
|
+
<p className="text-muted">Choose the model that owns each application’s aggregate. Other concrete models become its entities.</p>
|
|
55
|
+
{rows.map((row) => <div key={row.key} className="rounded-control border border-line p-3">
|
|
56
|
+
<label className="block font-medium text-ink">
|
|
57
|
+
Aggregate root for <span className="mono">{row.app}</span>
|
|
58
|
+
<select aria-label={`Aggregate root for ${row.app}`} className="mt-2 block w-full rounded-control border border-line bg-canvas px-3 py-2 text-ink" value={choices[row.key] ?? ""} onChange={(event) => { setChoices({ ...choices, [row.key]: event.target.value }); setSaved(false); }} disabled={busy}>
|
|
59
|
+
<option value="">Choose a model…</option>
|
|
60
|
+
{row.models.map((model) => <option key={model.name} value={model.name}>{model.name}</option>)}
|
|
61
|
+
</select>
|
|
62
|
+
</label>
|
|
63
|
+
<details className="mt-2 text-muted">
|
|
64
|
+
<summary className="cursor-pointer">{row.models.length} candidates · source locations</summary>
|
|
65
|
+
<ul className="mono mt-2 space-y-1">{row.models.map((model) => <li key={model.name} className="break-words">{model.name} · {model.path}:{model.line}</li>)}</ul>
|
|
66
|
+
</details>
|
|
67
|
+
{choices[row.key] ? <pre className="mono mt-2 overflow-auto rounded-control bg-canvas p-2 text-ink">{JSON.stringify({ aggregates: { [row.app]: choices[row.key] } }, null, 2)}</pre> : null}
|
|
68
|
+
</div>)}
|
|
69
|
+
{status.isSuccess ? <>
|
|
70
|
+
<button className="rounded-control border border-line bg-canvas px-3 py-2 font-medium text-accent hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50" onClick={() => void save()} disabled={!canSave}>{busy ? "Saving…" : "Save selected roots"}</button>
|
|
71
|
+
<p className="text-muted">Saves to the matching extraction steps in portolan.json. Regenerate afterward to update the catalog and warnings.</p>
|
|
72
|
+
{proposals.data?.stale && !saved ? <p className="text-declared">Regenerate before saving: these candidates belong to an older manifest.</p> : null}
|
|
73
|
+
{proposals.isError ? <p role="alert" className="text-unresolved">Could not load the saved extraction report. Regenerate and reopen Settings.</p> : null}
|
|
74
|
+
{rows.some((row) => !row.proposal) && proposals.isSuccess ? <p className="text-muted">Candidates from a trial can be saved after applying the project and generating its catalog.</p> : null}
|
|
75
|
+
</> : <p className="text-muted">To save, open Portolan locally or copy the selected aggregates option into the matching extraction step in portolan.json.</p>}
|
|
76
|
+
{saved ? <p role="status" className="text-verified">Aggregate roots saved. Regenerate to apply the choices to the catalog.</p> : null}
|
|
77
|
+
{error ? <p role="alert" className="text-unresolved">{error}</p> : null}
|
|
78
|
+
</div>;
|
|
79
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RelationEvidencePanel } from "../components/RelationEvidence";
|
|
1
2
|
// The right rail. One panel, one selection, every page that draws a diagram.
|
|
2
3
|
//
|
|
3
4
|
// It renders by kind rather than by page, so an event opened from a flow reads
|
|
@@ -238,6 +239,10 @@ function ViewBody({
|
|
|
238
239
|
return (
|
|
239
240
|
<>
|
|
240
241
|
{view.doc ? <p className="mt-2 text-muted">{view.doc}</p> : null}
|
|
242
|
+
<RelationEvidencePanel items={[
|
|
243
|
+
...(view.source ? [{ kind: "contract" as const, rule: "sql-view-definition", source: view.source, symbol: view.name }] : []),
|
|
244
|
+
...(view.persists?.evidence ?? []),
|
|
245
|
+
]} renderSource={(source) => <SchemaEvidenceSource source={source} owner={store.owner} />} />
|
|
241
246
|
|
|
242
247
|
<Label>Store</Label>
|
|
243
248
|
<SelectLink id={store.id}>{store.id}</SelectLink>
|
|
@@ -344,6 +349,7 @@ function TableBody({
|
|
|
344
349
|
return (
|
|
345
350
|
<>
|
|
346
351
|
{table.doc ? <p className="mt-2 text-muted">{table.doc}</p> : null}
|
|
352
|
+
<RelationEvidencePanel items={[...(table.evidence ?? []), ...(table.persists?.evidence ?? [])]} renderSource={(source) => <SchemaEvidenceSource source={source} owner={store.owner} />} />
|
|
347
353
|
|
|
348
354
|
<Label>Store</Label>
|
|
349
355
|
<SelectLink id={store.id}>{store.id}</SelectLink>
|
|
@@ -534,6 +540,11 @@ function ColumnBody({
|
|
|
534
540
|
return (
|
|
535
541
|
<>
|
|
536
542
|
{column.doc ? <p className="mt-2 text-muted">{column.doc}</p> : null}
|
|
543
|
+
{(column.fk || column.from?.length || column.maps) ? <RelationEvidencePanel items={[
|
|
544
|
+
...(table?.evidence ?? []),
|
|
545
|
+
...(table?.persists?.evidence ?? []),
|
|
546
|
+
...(view?.source ? [{ kind: "contract" as const, rule: "sql-view-definition", source: view.source, symbol: view.name }] : []),
|
|
547
|
+
]} renderSource={(source) => <SchemaEvidenceSource source={source} owner={resolved.store.owner} />} /> : null}
|
|
537
548
|
|
|
538
549
|
<Label>{view ? "View" : "Table"}</Label>
|
|
539
550
|
{view ? (
|
|
@@ -1550,3 +1561,7 @@ export function WithDetail({
|
|
|
1550
1561
|
</SavedGroup>
|
|
1551
1562
|
);
|
|
1552
1563
|
}
|
|
1564
|
+
|
|
1565
|
+
function SchemaEvidenceSource({ source, owner }: { source: string; owner: string }) {
|
|
1566
|
+
return <SourcePreviewLink location={sourceLocation(source, index.serviceById.get(owner), allRepos(catalog))} className="mono break-all text-muted hover:text-accent">{source}</SourcePreviewLink>;
|
|
1567
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The stamp of every catalog source, as the checkout's history says: served
|
|
2
|
+
// by `scripts/provenance.mjs` at build time (portolan.0010). Keyed by the
|
|
3
|
+
// path `src/data.ts` gives a source. A staged site imports its sources under
|
|
4
|
+
// flattened names; `source` is then the path the file has in the workspace.
|
|
5
|
+
declare module "virtual:portolan-provenance" {
|
|
6
|
+
const provenance: Record<
|
|
7
|
+
string,
|
|
8
|
+
{ commit: string; generatedAt: string; source?: string }
|
|
9
|
+
>;
|
|
10
|
+
export default provenance;
|
|
11
|
+
}
|
package/vite.config.ts
CHANGED
|
@@ -17,6 +17,10 @@ import { localApiPlugin } from "./scripts/local-api.mjs";
|
|
|
17
17
|
// generator wrote them; the build copies the same files into dist.
|
|
18
18
|
// @ts-expect-error plain JavaScript module intentionally has no browser types
|
|
19
19
|
import { siteDocsPlugin } from "./scripts/site-docs.mjs";
|
|
20
|
+
// When each catalog source last changed, read from git at build time and
|
|
21
|
+
// served to the app as one virtual module (portolan.0010).
|
|
22
|
+
// @ts-expect-error plain JavaScript module intentionally has no browser types
|
|
23
|
+
import { provenancePlugin } from "./scripts/provenance.mjs";
|
|
20
24
|
|
|
21
25
|
/** A git answer, or "" when there is nothing to answer with (no repo, no git). */
|
|
22
26
|
function git(args: string): string {
|
|
@@ -160,6 +164,7 @@ export default defineConfig({
|
|
|
160
164
|
// TypeScript below node_modules.
|
|
161
165
|
localApiPlugin(workspace, publicSetupFrom),
|
|
162
166
|
siteDocsPlugin(workspace),
|
|
167
|
+
provenancePlugin(workspace),
|
|
163
168
|
react(),
|
|
164
169
|
tailwindcss(),
|
|
165
170
|
// The AsyncAPI reference brings a parser written for Node, and it calls
|
package/scripts/vendor-lock.mjs
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
// The commit a vendored copy is of.
|
|
2
|
-
//
|
|
3
|
-
// `stampFor` dates a fragment from the last commit that touched the directory
|
|
4
|
-
// it was read from, which is the right answer for code that lives here and the
|
|
5
|
-
// wrong one for code that does not. A service fetched out of another
|
|
6
|
-
// repository is stamped, without this, with the commit that VENDORED it: the
|
|
7
|
-
// estate then reports the service as fresh because somebody re-ran the fetch,
|
|
8
|
-
// and as unchanged when its own repository moved.
|
|
9
|
-
//
|
|
10
|
-
// The right answer is already on disk. `fetch-git` writes `git.lock.json`
|
|
11
|
-
// beside each copy, naming the commit it fetched, and a copy re-fetched at the
|
|
12
|
-
// same commit does not move it. Reading it here keeps the rule the host has
|
|
13
|
-
// always kept - the host works out the stamp, never the plugin - while making
|
|
14
|
-
// the subject of the stamp the code rather than the vendoring.
|
|
15
|
-
|
|
16
|
-
import { readFileSync } from "node:fs";
|
|
17
|
-
import { dirname, join } from "node:path";
|
|
18
|
-
|
|
19
|
-
/** The file `fetch-git` writes beside every copy. Its name is that plugin's. */
|
|
20
|
-
export const LOCK = "git.lock.json";
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* The commit `root` is a copy of, or null when it is not a copy of anything.
|
|
24
|
-
*
|
|
25
|
-
* Walks up from the directory the extractor was pointed at, because the lock
|
|
26
|
-
* sits at the root of the repository's copy and the step reads a service some
|
|
27
|
-
* way inside it: `vendor/repos/acme/shop/services/oms` is stamped by the lock
|
|
28
|
-
* three levels above it. It stops at the repository root, so a run in a tree
|
|
29
|
-
* with no vendored copies reads nothing and answers null.
|
|
30
|
-
*
|
|
31
|
-
* A lock naming other than exactly one repository is not one of these - the
|
|
32
|
-
* fetcher writes one per directory - and is left to the fetcher to complain
|
|
33
|
-
* about, which it does with the path and the count. Nothing here fails a run:
|
|
34
|
-
* the caller has a git history to fall back on, and a stamp is not worth
|
|
35
|
-
* refusing to describe an estate over.
|
|
36
|
-
*/
|
|
37
|
-
export function vendoredCommit(root, read = readFileSync) {
|
|
38
|
-
for (let at = root; at && at !== "." && at !== "/"; at = dirname(at)) {
|
|
39
|
-
let raw;
|
|
40
|
-
try {
|
|
41
|
-
raw = read(join(at, LOCK), "utf8");
|
|
42
|
-
} catch {
|
|
43
|
-
continue; // No lock here; the copy's root may still be above.
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
try {
|
|
47
|
-
const lock = JSON.parse(raw);
|
|
48
|
-
const [only, ...rest] = lock.repos ?? [];
|
|
49
|
-
if (only?.commit && rest.length === 0) return only.commit;
|
|
50
|
-
} catch {
|
|
51
|
-
// Unreadable, which the fetcher reports against the copy itself.
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return null;
|
|
58
|
-
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
-
|
|
6
|
-
import { vendoredCommit } from "./vendor-lock.mjs";
|
|
7
|
-
|
|
8
|
-
const COMMIT = "c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0";
|
|
9
|
-
|
|
10
|
-
describe("vendoredCommit", () => {
|
|
11
|
-
let root;
|
|
12
|
-
|
|
13
|
-
beforeEach(() => {
|
|
14
|
-
root = mkdtempSync(join(tmpdir(), "portolan-vendor-"));
|
|
15
|
-
});
|
|
16
|
-
afterEach(() => {
|
|
17
|
-
rmSync(root, { recursive: true, force: true });
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
const copy = (at, lock) => {
|
|
21
|
-
mkdirSync(join(root, at), { recursive: true });
|
|
22
|
-
writeFileSync(join(root, at, "git.lock.json"), JSON.stringify(lock));
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
// The case this exists for: the extractor is pointed at a service some way
|
|
26
|
-
// inside the copy, and the lock is at the copy's root.
|
|
27
|
-
it("reads the lock above the directory the extractor was given", () => {
|
|
28
|
-
copy("vendor/repos/acme/shop", { repos: [{ repo: "github.com/acme/shop", commit: COMMIT }] });
|
|
29
|
-
mkdirSync(join(root, "vendor/repos/acme/shop/services/oms"), { recursive: true });
|
|
30
|
-
|
|
31
|
-
expect(vendoredCommit(join(root, "vendor/repos/acme/shop/services/oms"))).toBe(COMMIT);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("answers null for a directory that is nobody's copy", () => {
|
|
35
|
-
mkdirSync(join(root, "examples/shop/oms"), { recursive: true });
|
|
36
|
-
|
|
37
|
-
expect(vendoredCommit(join(root, "examples/shop/oms"))).toBeNull();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
// The fetcher writes one repository per directory. Anything else is its
|
|
41
|
-
// problem to report, with the path and the count; a stamp is not worth
|
|
42
|
-
// failing a run over, so this stops rather than guessing which one won.
|
|
43
|
-
it("answers null for a lock naming more than one repository", () => {
|
|
44
|
-
copy("vendor/repos/acme/shop", {
|
|
45
|
-
repos: [
|
|
46
|
-
{ repo: "github.com/acme/shop", commit: COMMIT },
|
|
47
|
-
{ repo: "github.com/acme/pay", commit: "0000000" },
|
|
48
|
-
],
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
expect(vendoredCommit(join(root, "vendor/repos/acme/shop"))).toBeNull();
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("answers null for a lock that cannot be read", () => {
|
|
55
|
-
mkdirSync(join(root, "vendor/repos/acme/shop"), { recursive: true });
|
|
56
|
-
writeFileSync(join(root, "vendor/repos/acme/shop/git.lock.json"), "{ not json");
|
|
57
|
-
|
|
58
|
-
expect(vendoredCommit(join(root, "vendor/repos/acme/shop"))).toBeNull();
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
// A copy nested inside another copy takes the innermost lock: it is the one
|
|
62
|
-
// that names the files the extractor is about to read.
|
|
63
|
-
it("takes the nearest lock", () => {
|
|
64
|
-
copy("vendor/repos/acme", { repos: [{ repo: "github.com/acme/outer", commit: "9999999" }] });
|
|
65
|
-
copy("vendor/repos/acme/shop", { repos: [{ repo: "github.com/acme/shop", commit: COMMIT }] });
|
|
66
|
-
|
|
67
|
-
expect(vendoredCommit(join(root, "vendor/repos/acme/shop"))).toBe(COMMIT);
|
|
68
|
-
});
|
|
69
|
-
});
|