@shortlink-org/portolan 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +23 -0
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1066 -0
  5. package/catalog/roundtrip_test.go +203 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +8 -0
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +14 -0
  11. package/go.sum +20 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +22 -10
  35. package/plugin/describe.go +118 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +109 -46
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-celery/extract.py +0 -2
  42. package/plugins/extract-celery/extract_test.py +1 -1
  43. package/plugins/extract-django/README.md +39 -17
  44. package/plugins/extract-django/domain.py +28 -17
  45. package/plugins/extract-django/extract.py +21 -7
  46. package/plugins/extract-django/extract_test.py +55 -2
  47. package/plugins/extract-django/lifecycle.py +2 -0
  48. package/plugins/extract-django/operations.py +1 -1
  49. package/plugins/extract-django/routing_test.py +109 -1
  50. package/plugins/extract-django/store.py +1 -1
  51. package/plugins/extract-django/transport.py +101 -55
  52. package/plugins/extract-django/verbs.py +241 -0
  53. package/plugins/extract-go/README.md +47 -0
  54. package/plugins/extract-http-clients/describe.go +19 -0
  55. package/plugins/extract-http-clients/describe_test.go +11 -0
  56. package/plugins/extract-http-clients/extract.go +740 -0
  57. package/plugins/extract-http-clients/extract_test.go +1561 -0
  58. package/plugins/extract-http-clients/main.go +41 -0
  59. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  60. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  61. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  62. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  63. package/plugins/extract-python-kafka/extract.py +0 -2
  64. package/plugins/extract-python-kafka/extract_test.py +1 -1
  65. package/plugins/extract-ts/extract.test.ts +2 -2
  66. package/plugins/extract-ts/extract.ts +4 -5
  67. package/plugins/extract-ts/graphql.test.ts +1 -1
  68. package/plugins/openapi/ids.go +261 -0
  69. package/plugins/openapi/ids_test.go +98 -0
  70. package/plugins/portolan-go.wasm +0 -0
  71. package/plugins/pyplugin/protocol.py +1 -5
  72. package/portolan.json +3 -2
  73. package/schema/portolan.schema.json +34 -0
  74. package/scripts/README.md +18 -12
  75. package/scripts/catalog-sources.mjs +6 -0
  76. package/scripts/delivery-presets.mjs +21 -11
  77. package/scripts/diff.mjs +5 -1
  78. package/scripts/django-aggregates.test.mjs +58 -0
  79. package/scripts/gen-likec4.mjs +1 -1
  80. package/scripts/gen.mjs +118 -115
  81. package/scripts/go-discovery.test.mjs +30 -0
  82. package/scripts/history.mjs +186 -3
  83. package/scripts/history.test.mjs +1 -1
  84. package/scripts/host-plugins/fetch-git.mjs +77 -21
  85. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  86. package/scripts/local-api.mjs +71 -4
  87. package/scripts/local-api.test.mjs +63 -4
  88. package/scripts/local-discovery.mjs +82 -9
  89. package/scripts/manifest.mjs +5 -3
  90. package/scripts/manifest.test.mjs +24 -0
  91. package/scripts/output-diff.mjs +94 -0
  92. package/scripts/output-diff.test.mjs +36 -0
  93. package/scripts/package-smoke.mjs +62 -4
  94. package/scripts/plugin-host.mjs +22 -2
  95. package/scripts/plugin-host.test.mjs +9 -0
  96. package/scripts/plugin-wasm-worker.mjs +4 -1
  97. package/scripts/provenance.mjs +72 -0
  98. package/scripts/provenance.test.mjs +149 -0
  99. package/scripts/run-builtin.mjs +39 -5
  100. package/scripts/schema.mjs +29 -0
  101. package/scripts/warning-policy.mjs +161 -0
  102. package/scripts/warning-policy.test.mjs +56 -0
  103. package/src/app/Sidebar.tsx +3 -3
  104. package/src/catalog-docs.test.ts +64 -0
  105. package/src/catalog-docs.ts +35 -0
  106. package/src/catalog-error.test.ts +15 -0
  107. package/src/catalog-model.ts +48 -5
  108. package/src/catalog-validation.ts +9 -0
  109. package/src/chat/Starter.tsx +5 -11
  110. package/src/chat/tools.test.ts +27 -0
  111. package/src/chat/tools.ts +5 -9
  112. package/src/components/CatalogStamp.tsx +10 -8
  113. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  114. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  115. package/src/components/Integrations.tsx +1 -1
  116. package/src/components/MachineDocs.tsx +6 -5
  117. package/src/components/MethodRows.tsx +9 -2
  118. package/src/components/RelationEvidence.test.tsx +14 -0
  119. package/src/components/RelationEvidence.tsx +53 -0
  120. package/src/data.ts +25 -7
  121. package/src/enrich.test.ts +332 -1
  122. package/src/enrich.ts +206 -3
  123. package/src/flow/StepDetail.tsx +6 -0
  124. package/src/flow/evidence.test.ts +16 -0
  125. package/src/flow/evidence.ts +34 -0
  126. package/src/index.css +44 -0
  127. package/src/landing/DraggableReveal.tsx +3 -2
  128. package/src/landing/EvidencePipeline.tsx +105 -0
  129. package/src/landing/LandingPage.tsx +2 -59
  130. package/src/lib/catalog-diff.ts +1 -1
  131. package/src/lib/django-aggregates.d.mts +9 -0
  132. package/src/lib/django-aggregates.mjs +36 -0
  133. package/src/lib/django-aggregates.test.ts +29 -0
  134. package/src/lib/django-aggregates.ts +5 -0
  135. package/src/lib/local-api.ts +20 -2
  136. package/src/lib/setup-info.test.ts +17 -0
  137. package/src/lib/setup-info.ts +58 -0
  138. package/src/lib/warnings.test.ts +54 -0
  139. package/src/lib/warnings.ts +260 -0
  140. package/src/map/ContextMapGraph.tsx +76 -32
  141. package/src/merge.ts +16 -9
  142. package/src/pages/AggregatePage.tsx +8 -7
  143. package/src/pages/ContextPage.tsx +6 -5
  144. package/src/pages/ServicePage.tsx +4 -3
  145. package/src/pages/Settings.tsx +189 -41
  146. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  147. package/src/selection/DetailPanel.tsx +15 -0
  148. package/src/virtual-provenance.d.ts +11 -0
  149. package/vite.config.ts +5 -0
  150. package/scripts/vendor-lock.mjs +0 -58
  151. package/scripts/vendor-lock.test.mjs +0 -69
@@ -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="divide-y divide-line rounded-control border border-line">
266
- {steps.map((step) => (
267
- <div key={step.ordinal} className="px-3 py-2">
268
- <div className="grid gap-2 sm:grid-cols-[5rem_minmax(7rem,1fr)_auto_auto] sm:items-center">
269
- <span className="mono text-muted">{step.phase}</span>
270
- <span className="mono truncate text-ink" title={step.plugin}>{step.plugin}</span>
271
- <span className="mono text-muted">{step.fileCount} {plural(step.fileCount, "file")}</span>
272
- <div className="flex items-center justify-between gap-2 sm:justify-end">
273
- <span className="mono text-muted">{duration(step.durationMs)}</span>
274
- <StepStatus status={step.status} />
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
- <StepWarnings warnings={step.warnings} />
278
- </div>
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
- // What a plugin could not read, in its own words. For a tree the extractor
285
- // only half understood this is the list of what to fix, so it is shown where
286
- // the step is rather than left on the terminal it scrolled off.
287
- function StepWarnings({ warnings }: { warnings: string[] }) {
288
- if (warnings.length === 0) return null;
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
- <details className="mt-2" open={warnings.length <= 5}>
291
- <summary className="mono cursor-pointer text-muted">
292
- {warnings.length} {plural(warnings.length, "warning")}
293
- </summary>
294
- <ul className="mono mt-1 space-y-1 text-muted">
295
- {warnings.map((warning, index) => (
296
- <li key={`${index}:${warning}`} className="break-words border-l-2 border-line pl-2">
297
- {warning}
298
- </li>
299
- ))}
300
- </ul>
301
- </details>
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(", ")}>{commits.length === 0 ? "not stamped" : commits.length === 1 ? <CommitLink commit={commits[0]!} repository={project.repository} length={12} /> : `${commits.length} source commits`}</dd>
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: trial.warnings.length === 0 },
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
- {trial?.warnings.length ? <section><div className="label mb-2">warnings · {trial.warnings.length}</div><div className="space-y-2">{trial.warnings.map((warning, index) => <div key={`${warning.plugin}:${index}`} className="flex gap-2 rounded-control border border-declared px-3 py-2 text-muted"><CircleAlert size={15} className="mt-0.5 shrink-0 text-declared" /><span><span className="mono text-ink">{warning.plugin}</span> · {warning.message}</span></div>)}</div></section> : null}
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
- <Modal open={removeTarget !== null} onClose={removeBusy ? () => {} : () => setRemoveTarget(null)} label={`Remove ${removeTarget?.name ?? "project"}`} width="min(520px,92vw)">
1214
- <div className="border-b border-line px-5 py-4"><div className="font-semibold text-ink">Remove {removeTarget?.name}?</div></div>
1215
- <div className="p-5"><p className="text-muted">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.</p><p className="mt-3 text-muted">You can undo immediately. Generated documentation changes remain pending until you preview and apply them.</p></div>
1216
- <div className="flex justify-end gap-2 border-t border-line px-5 py-4"><button type="button" className="tbtn" onClick={() => setRemoveTarget(null)} disabled={removeBusy}>Cancel</button><button type="button" className="product-primary" onClick={() => void confirmRemove()} disabled={removeBusy}>{removeBusy ? <LoaderCircle size={15} className="animate-spin" /> : <Trash2 size={15} />} Remove project</button></div>
1217
- </Modal>
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
@@ -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
- });