paseo-plugin-genui 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/OVERVIEW.md +21 -0
- package/README.md +77 -2
- package/assets/fonts/Excalifont-OFL.txt +109 -0
- package/assets/fonts/Excalifont-Regular-latin.woff2 +0 -0
- package/assets/icon.png +0 -0
- package/assets/icon.svg +1 -0
- package/assets/screenshot-chat.png +0 -0
- package/assets/screenshot-gallery.png +0 -0
- package/assets/screenshot-playground.png +0 -0
- package/client/components/content.tsx +468 -0
- package/client/components/data.tsx +967 -0
- package/client/components/dev.tsx +998 -0
- package/client/components/diagram.tsx +513 -0
- package/client/components/inputs.tsx +994 -0
- package/client/components/layout.tsx +239 -0
- package/client/components/media.tsx +428 -0
- package/client/components/project.tsx +641 -0
- package/client/components/widgets.tsx +836 -0
- package/client/gallery.tsx +449 -0
- package/client/live-ui.tsx +525 -0
- package/client/loaders.ts +342 -0
- package/client/markdown.tsx +172 -0
- package/client/node-renderer.tsx +149 -0
- package/client/render.tsx +186 -0
- package/client/runtime.ts +103 -0
- package/client/settings.tsx +57 -0
- package/client/web.ts +186 -0
- package/gallery/Acceptance-baseline.ui +1 -0
- package/gallery/Acceptance-checklist.ui +14 -0
- package/gallery/Acceptance-evidence.ui +9 -0
- package/gallery/Acceptance.ui +1 -0
- package/gallery/Badge.ui +3 -0
- package/gallery/Benchmark-context.ui +8 -0
- package/gallery/Benchmark-hyperfine.ui +1 -0
- package/gallery/Benchmark-runs-list.ui +9 -0
- package/gallery/Benchmark-runs.ui +1 -0
- package/gallery/Benchmark-vitest.ui +1 -0
- package/gallery/Benchmark.ui +9 -0
- package/gallery/Board-bb-tasks.ui +1 -0
- package/gallery/Board-gh-issues.ui +1 -0
- package/gallery/Board-gh-prs.ui +1 -0
- package/gallery/Board.ui +12 -0
- package/gallery/Button-destructive.ui +4 -0
- package/gallery/Button.ui +5 -0
- package/gallery/Calendar.ui +15 -0
- package/gallery/CalendarHeatmap-short.ui +11 -0
- package/gallery/CalendarHeatmap.ui +4 -0
- package/gallery/Callout.ui +4 -0
- package/gallery/Card.ui +4 -0
- package/gallery/Carousel.ui +8 -0
- package/gallery/Changes-paths.ui +1 -0
- package/gallery/Changes-uncommitted.ui +1 -0
- package/gallery/Changes.ui +1 -0
- package/gallery/Chart-area.ui +3 -0
- package/gallery/Chart-funnel.ui +3 -0
- package/gallery/Chart-heatmap.ui +11 -0
- package/gallery/Chart-pie.ui +3 -0
- package/gallery/Chart-radar.ui +3 -0
- package/gallery/Chart-scatter.ui +4 -0
- package/gallery/Chart-stacked.ui +5 -0
- package/gallery/Chart.ui +3 -0
- package/gallery/Checklist.ui +6 -0
- package/gallery/Choice.ui +8 -0
- package/gallery/Code.ui +3 -0
- package/gallery/Commits-limit.ui +1 -0
- package/gallery/Commits.ui +1 -0
- package/gallery/Compare-conditions.ui +4 -0
- package/gallery/Compare-three.ui +7 -0
- package/gallery/Compare.ui +4 -0
- package/gallery/Coverage-go.ui +1 -0
- package/gallery/Coverage-lcov.ui +1 -0
- package/gallery/Coverage-python.ui +1 -0
- package/gallery/Coverage.ui +1 -0
- package/gallery/DateInput.ui +5 -0
- package/gallery/Decision-percent.ui +10 -0
- package/gallery/Decision.ui +12 -0
- package/gallery/Dependencies-audit.ui +1 -0
- package/gallery/Dependencies-both.ui +1 -0
- package/gallery/Dependencies-list.ui +7 -0
- package/gallery/Dependencies-pip.ui +1 -0
- package/gallery/Dependencies-pnpm.ui +1 -0
- package/gallery/Dependencies.ui +1 -0
- package/gallery/Details.ui +4 -0
- package/gallery/Diagram-flow.ui +16 -0
- package/gallery/Diagram-sequence.ui +15 -0
- package/gallery/Diagram.ui +16 -0
- package/gallery/Diff.ui +3 -0
- package/gallery/Divider.ui +5 -0
- package/gallery/EditableTable.ui +9 -0
- package/gallery/Entity-list.ui +6 -0
- package/gallery/Entity.ui +8 -0
- package/gallery/Environments.ui +1 -0
- package/gallery/Estimate-points.ui +6 -0
- package/gallery/Estimate.ui +8 -0
- package/gallery/File.ui +4 -0
- package/gallery/Findings-biome.ui +1 -0
- package/gallery/Findings-eslint.ui +1 -0
- package/gallery/Findings-oxlint.ui +1 -0
- package/gallery/Findings-review.ui +1 -0
- package/gallery/Findings-ruff.ui +1 -0
- package/gallery/Findings-sarif.ui +1 -0
- package/gallery/Findings-ship.ui +11 -0
- package/gallery/Findings-tsc.ui +1 -0
- package/gallery/Findings.ui +7 -0
- package/gallery/Flashcards.ui +3 -0
- package/gallery/FollowUps.ui +4 -0
- package/gallery/Form.ui +8 -0
- package/gallery/Gallery-links.ui +8 -0
- package/gallery/Gallery-strip.ui +14 -0
- package/gallery/Gallery.ui +10 -0
- package/gallery/Gauge.ui +7 -0
- package/gallery/Grid.ui +8 -0
- package/gallery/Hotspots-photo.ui +8 -0
- package/gallery/Hotspots.ui +11 -0
- package/gallery/Icon.ui +5 -0
- package/gallery/Image.ui +4 -0
- package/gallery/ImageSearch-aspects.ui +6 -0
- package/gallery/ImageSearch.ui +4 -0
- package/gallery/Input.ui +5 -0
- package/gallery/Json-api.ui +1 -0
- package/gallery/Json.ui +8 -0
- package/gallery/KeyValue.ui +3 -0
- package/gallery/List-dots.ui +8 -0
- package/gallery/List.ui +8 -0
- package/gallery/ListEditor.ui +5 -0
- package/gallery/Logs-jsonl.ui +1 -0
- package/gallery/Logs-lines.ui +11 -0
- package/gallery/Logs-python.ui +1 -0
- package/gallery/Logs.ui +1 -0
- package/gallery/Map-city.ui +8 -0
- package/gallery/Map.ui +8 -0
- package/gallery/Metric.ui +3 -0
- package/gallery/OptionCards-multi.ui +10 -0
- package/gallery/OptionCards.ui +10 -0
- package/gallery/Pipeline-checks.ui +1 -0
- package/gallery/Pipeline-passed.ui +1 -0
- package/gallery/Pipeline-running.ui +1 -0
- package/gallery/Pipeline-stages.ui +13 -0
- package/gallery/Pipeline.ui +1 -0
- package/gallery/Plan.ui +9 -0
- package/gallery/Progress.ui +4 -0
- package/gallery/Provenance-deploy.ui +6 -0
- package/gallery/Provenance-no-receipt.ui +1 -0
- package/gallery/Provenance.ui +1 -0
- package/gallery/PullRequest.ui +1 -0
- package/gallery/Quiz.ui +6 -0
- package/gallery/Radio.ui +5 -0
- package/gallery/Rating.ui +5 -0
- package/gallery/Roadmap-sprint.ui +8 -0
- package/gallery/Roadmap-year.ui +9 -0
- package/gallery/Roadmap.ui +12 -0
- package/gallery/Row.ui +3 -0
- package/gallery/SegmentedBar-status.ui +8 -0
- package/gallery/SegmentedBar.ui +8 -0
- package/gallery/Select.ui +5 -0
- package/gallery/Slider.ui +7 -0
- package/gallery/Snippet.ui +3 -0
- package/gallery/Sources-chips.ui +4 -0
- package/gallery/Sources.ui +8 -0
- package/gallery/Sparkline.ui +6 -0
- package/gallery/Stack.ui +7 -0
- package/gallery/StackTrace-aggregate.ui +1 -0
- package/gallery/StackTrace-browser.ui +1 -0
- package/gallery/StackTrace-go.ui +1 -0
- package/gallery/StackTrace-java.ui +1 -0
- package/gallery/StackTrace-python.ui +1 -0
- package/gallery/StackTrace-rust.ui +1 -0
- package/gallery/StackTrace.ui +1 -0
- package/gallery/Status.ui +9 -0
- package/gallery/Stepper.ui +5 -0
- package/gallery/Steps.ui +8 -0
- package/gallery/Swatches-long-names.ui +4 -0
- package/gallery/Swatches-picker.ui +5 -0
- package/gallery/Swatches.ui +10 -0
- package/gallery/Table.ui +7 -0
- package/gallery/Tabs.ui +8 -0
- package/gallery/Tests-junit.ui +1 -0
- package/gallery/Tests-vitest-junit.ui +1 -0
- package/gallery/Tests-vitest.ui +1 -0
- package/gallery/Tests.ui +9 -0
- package/gallery/Text.ui +4 -0
- package/gallery/TextArea.ui +5 -0
- package/gallery/Threads-ids.ui +1 -0
- package/gallery/Threads.ui +1 -0
- package/gallery/Timeline.ui +8 -0
- package/gallery/Timer.ui +4 -0
- package/gallery/Toggle.ui +5 -0
- package/gallery/Transcript-text.ui +1 -0
- package/gallery/Transcript-untimed.ui +1 -0
- package/gallery/Transcript-whisper.ui +1 -0
- package/gallery/Transcript.ui +1 -0
- package/gallery/Tree.ui +3 -0
- package/gallery/Treemap-du.ui +1 -0
- package/gallery/Treemap-esbuild.ui +1 -0
- package/gallery/Treemap-tree.ui +7 -0
- package/gallery/Treemap.ui +18 -0
- package/gallery/Walkthrough.ui +9 -0
- package/gallery/Waterfall-har.ui +1 -0
- package/gallery/Waterfall-trace.ui +1 -0
- package/gallery/Waterfall.ui +11 -0
- package/gallery/data/acceptance/coil/viewport-lifecycle.json +25 -0
- package/gallery/data/acceptance/coil-gate.json +153 -0
- package/gallery/data/acceptance/orbit-baseline.json +163 -0
- package/gallery/data/acceptance/shots/gate-cell-opened.png +0 -0
- package/gallery/data/acceptance/shots/gate-light.png +0 -0
- package/gallery/data/acceptance/shots/gate-phone-dark.png +0 -0
- package/gallery/data/activity.json +1 -0
- package/gallery/data/bb-tasks-orb.json +420 -0
- package/gallery/data/biome.json +229 -0
- package/gallery/data/compare/status-after.png +0 -0
- package/gallery/data/compare/status-before.png +0 -0
- package/gallery/data/compare/status-dark-2x.png +0 -0
- package/gallery/data/compare/status-rerender.png +0 -0
- package/gallery/data/coverage-py.json +1 -0
- package/gallery/data/coverage-summary.json +1 -0
- package/gallery/data/coverage.out +23 -0
- package/gallery/data/deploys.json +1 -0
- package/gallery/data/device-runs.json +285 -0
- package/gallery/data/du-node-modules.txt +359 -0
- package/gallery/data/esbuild-meta.json +292 -0
- package/gallery/data/eslint.json +266 -0
- package/gallery/data/eslint.sarif +343 -0
- package/gallery/data/findings-review.json +76 -0
- package/gallery/data/gh-issues-nextjs.json +293 -0
- package/gallery/data/gh-pr-checks.json +272 -0
- package/gallery/data/gh-prs-nextjs.json +419 -0
- package/gallery/data/gh-run-failed.json +1 -0
- package/gallery/data/gh-run-passed.json +1 -0
- package/gallery/data/gh-run-running.json +1 -0
- package/gallery/data/github-release.json +57 -0
- package/gallery/data/hyperfine-startup.json +187 -0
- package/gallery/data/lcov.info +234 -0
- package/gallery/data/logs/media-sync.log +127 -0
- package/gallery/data/logs/thumbnailer.jsonl +17 -0
- package/gallery/data/logs/vitest-run.log +89 -0
- package/gallery/data/npm-audit.json +672 -0
- package/gallery/data/npm-outdated.json +179 -0
- package/gallery/data/oxlint.json +156 -0
- package/gallery/data/pip-outdated.json +68 -0
- package/gallery/data/pnpm-outdated.json +58 -0
- package/gallery/data/provenance/devicectl-install.json +35 -0
- package/gallery/data/provenance/install-timeout.log +4 -0
- package/gallery/data/provenance/iphone-install.json +72 -0
- package/gallery/data/provenance/iphone-no-receipt.json +57 -0
- package/gallery/data/provenance/live-home.jpg +0 -0
- package/gallery/data/provenance/live-probe.txt +10 -0
- package/gallery/data/provenance/xcodebuild-2811.log +19 -0
- package/gallery/data/provenance/xcodebuild-4712.log +19 -0
- package/gallery/data/pytest-junit.xml +64 -0
- package/gallery/data/python-org-trace.json +35 -0
- package/gallery/data/python-org.har +31 -0
- package/gallery/data/retry.ts +13 -0
- package/gallery/data/ruff.json +261 -0
- package/gallery/data/src/durations.py +63 -0
- package/gallery/data/src/json-logic.ts +156 -0
- package/gallery/data/src/mapMath.ts +135 -0
- package/gallery/data/src/semver/bump.go +73 -0
- package/gallery/data/traces/browser-error.json +5 -0
- package/gallery/data/traces/go/main.go +27 -0
- package/gallery/data/traces/go-panic.txt +16 -0
- package/gallery/data/traces/java-caused-by.txt +15 -0
- package/gallery/data/traces/node-aggregate.txt +30 -0
- package/gallery/data/traces/node-cause.txt +21 -0
- package/gallery/data/traces/py/deploy.json +5 -0
- package/gallery/data/traces/py/deploy.py +17 -0
- package/gallery/data/traces/py/settings.py +19 -0
- package/gallery/data/traces/python-chain.txt +30 -0
- package/gallery/data/traces/rust/main.rs +11 -0
- package/gallery/data/traces/rust-panic.txt +16 -0
- package/gallery/data/traces/shop/cart.mjs +19 -0
- package/gallery/data/traces/shop/pricing.mjs +12 -0
- package/gallery/data/traces/shop/status.mjs +9 -0
- package/gallery/data/traces/web/app.js +11 -0
- package/gallery/data/traces/web/index.html +1 -0
- package/gallery/data/traces/web/search.js +10 -0
- package/gallery/data/transcript/call-noisy.wav +0 -0
- package/gallery/data/transcript/call.json +362 -0
- package/gallery/data/transcript/call.wav +0 -0
- package/gallery/data/transcript/mlx-whisper-tiny.en-noisy.json +1 -0
- package/gallery/data/transcript/whisper-cpp-base.en-noisy.json +658 -0
- package/gallery/data/transcript/whisper-cpp-base.en.json +658 -0
- package/gallery/data/transcript/whisper-cpp-base.en.srt +16 -0
- package/gallery/data/transcript/whisper-cpp-tiny.en-noisy.json +619 -0
- package/gallery/data/tsc.txt +4 -0
- package/gallery/data/vitest-bench.json +119 -0
- package/gallery/data/vitest-junit.xml +133 -0
- package/gallery/data/vitest-report.json +612 -0
- package/gallery/images/bell.svg +10 -0
- package/gallery/images/bike.svg +47 -0
- package/gallery/images/footer-planet.webp +0 -0
- package/gallery/images/front-light.svg +13 -0
- package/gallery/images/golden-hour.webp +0 -0
- package/gallery/images/option-flowers.webp +0 -0
- package/gallery/images/option-hilltop.webp +0 -0
- package/gallery/images/option-meadow.webp +0 -0
- package/gallery/images/option-sky.webp +0 -0
- package/gallery/images/u-lock.svg +10 -0
- package/index.client.tsx +164 -0
- package/index.server.ts +216 -0
- package/package.json +50 -4
- package/paseo-plugin.json +14 -0
- package/server/data-source.ts +407 -0
- package/server/project.ts +981 -0
- package/server/web/address.ts +57 -0
- package/server/web/cache.ts +85 -0
- package/server/web/commons.ts +126 -0
- package/server/web/geocode.ts +40 -0
- package/server/web/image.ts +35 -0
- package/server/web/lookups.ts +248 -0
- package/server/web/net.ts +210 -0
- package/shared/core/acceptance.ts +744 -0
- package/shared/core/ast.ts +76 -0
- package/shared/core/benchmark.ts +743 -0
- package/shared/core/catalog.ts +733 -0
- package/shared/core/compare.ts +225 -0
- package/shared/core/coverage.ts +456 -0
- package/shared/core/data.ts +334 -0
- package/shared/core/decisions.ts +527 -0
- package/shared/core/dependencies.ts +604 -0
- package/shared/core/diagnostics.ts +24 -0
- package/shared/core/environments.ts +212 -0
- package/shared/core/evaluate.ts +737 -0
- package/shared/core/fields.ts +66 -0
- package/shared/core/findings.ts +1172 -0
- package/shared/core/form.ts +69 -0
- package/shared/core/index.ts +44 -0
- package/shared/core/junit.ts +252 -0
- package/shared/core/lexer.ts +275 -0
- package/shared/core/logs.ts +496 -0
- package/shared/core/markdown.ts +2374 -0
- package/shared/core/parser.ts +355 -0
- package/shared/core/perf-units.ts +228 -0
- package/shared/core/pipeline.ts +679 -0
- package/shared/core/project.ts +472 -0
- package/shared/core/provenance.ts +725 -0
- package/shared/core/stack-trace.ts +718 -0
- package/shared/core/streaming.ts +136 -0
- package/shared/core/test-results.ts +562 -0
- package/shared/core/thread-activity.ts +203 -0
- package/shared/core/tracking.ts +887 -0
- package/shared/core/transcript.ts +1364 -0
- package/shared/core/treemap.ts +446 -0
- package/shared/core/types.ts +70 -0
- package/shared/core/validate.ts +574 -0
- package/shared/core/waterfall.ts +536 -0
- package/shared/diagram/flowchart.ts +205 -0
- package/shared/diagram/layout.ts +440 -0
- package/shared/diagram/markdown.ts +135 -0
- package/shared/diagram/sequence.ts +122 -0
- package/shared/diagram/zoom.ts +114 -0
- package/shared/fallback.ts +120 -0
- package/shared/genui.test.ts +122 -0
- package/shared/instructions.ts +144 -0
- package/shared/logic/calendarMath.ts +130 -0
- package/shared/logic/chartMath.ts +289 -0
- package/shared/logic/choose-logic.ts +196 -0
- package/shared/logic/compare-logic.ts +270 -0
- package/shared/logic/content-model.ts +227 -0
- package/shared/logic/environments-logic.ts +53 -0
- package/shared/logic/explain-logic.ts +315 -0
- package/shared/logic/fieldLogic.ts +138 -0
- package/shared/logic/gallery-search.ts +71 -0
- package/shared/logic/icons.ts +322 -0
- package/shared/logic/json-logic.ts +153 -0
- package/shared/logic/mapMath.ts +133 -0
- package/shared/logic/project-logic.ts +39 -0
- package/shared/logic/references-model.ts +201 -0
- package/shared/logic/sketch.ts +165 -0
- package/shared/logic/supersession.ts +77 -0
- package/shared/logic/values.ts +133 -0
- package/shared/logic/widget-logic.ts +163 -0
- package/shared/rpc.ts +252 -0
- package/shared/segment.ts +81 -0
- package/shared/settings.ts +12 -0
|
@@ -0,0 +1,2374 @@
|
|
|
1
|
+
// A static markdown rendering of an evaluated UI for clients that cannot run
|
|
2
|
+
// it (mobile app, notifications, CLI): readable on a phone, no HTML.
|
|
3
|
+
import type { LoadedData } from "./data";
|
|
4
|
+
import { acceptanceVerdict, cellsLine, checkLabel, isCellGap, isGap, needsPlaces, ownerContradiction, readAcceptance, type Check, type Evidence as AcceptanceEvidence } from "./acceptance";
|
|
5
|
+
import { conditionDifferences, conditionsWarning, readCompare, type CompareImage } from "./compare";
|
|
6
|
+
import { analyse, countsText, describeRegion, formatClock, formatWer, readTracks } from "./transcript";
|
|
7
|
+
import {
|
|
8
|
+
formatConditions,
|
|
9
|
+
formatRatio,
|
|
10
|
+
groupRuns,
|
|
11
|
+
readBenchmark,
|
|
12
|
+
runStatusWord,
|
|
13
|
+
unmeasuredCounts,
|
|
14
|
+
type AllRunsReport,
|
|
15
|
+
type Comparison,
|
|
16
|
+
type Context,
|
|
17
|
+
type Run,
|
|
18
|
+
} from "./benchmark";
|
|
19
|
+
import { formatAmount as perfAmount, formatBytes, formatDelta, formatMs, formatPercent as formatChange, formatShare } from "./perf-units";
|
|
20
|
+
import { readTreemap, type TreeNode } from "./treemap";
|
|
21
|
+
import { readWaterfall, type Span } from "./waterfall";
|
|
22
|
+
import { coverageTone, formatPercent, headline, mostUncovered, percent, rangeLabel, readCoverage } from "./coverage";
|
|
23
|
+
import {
|
|
24
|
+
countSeverities,
|
|
25
|
+
readFindings,
|
|
26
|
+
SEVERITIES,
|
|
27
|
+
severityCount,
|
|
28
|
+
severityLabel,
|
|
29
|
+
sortFindings,
|
|
30
|
+
statusCounts,
|
|
31
|
+
statusSentence,
|
|
32
|
+
verdictHeadline,
|
|
33
|
+
type Severity,
|
|
34
|
+
} from "./findings";
|
|
35
|
+
import {
|
|
36
|
+
GROUP_LABEL,
|
|
37
|
+
groupOutdated,
|
|
38
|
+
readDependencies,
|
|
39
|
+
SEVERITIES as DEPENDENCY_SEVERITIES,
|
|
40
|
+
splitChange,
|
|
41
|
+
type OutdatedPackage,
|
|
42
|
+
type Severity as DependencySeverity,
|
|
43
|
+
type VulnerablePackage,
|
|
44
|
+
} from "./dependencies";
|
|
45
|
+
import { formatElapsed, jobCountsText, matrixPrefix, readPipeline, STATUS_LABEL, type PipelineJob, type PipelineStatus } from "./pipeline";
|
|
46
|
+
import { evaluate, initialState, isPlainObject } from "./evaluate";
|
|
47
|
+
import { compile } from "./index";
|
|
48
|
+
import {
|
|
49
|
+
estimateTask,
|
|
50
|
+
estimateTotals,
|
|
51
|
+
estimateUnit,
|
|
52
|
+
formatAmount,
|
|
53
|
+
formatQuantity,
|
|
54
|
+
formatWithUnit,
|
|
55
|
+
initialChecks,
|
|
56
|
+
joinAnd,
|
|
57
|
+
leaderMargin,
|
|
58
|
+
planTotal,
|
|
59
|
+
rankOptions,
|
|
60
|
+
readDecision,
|
|
61
|
+
readEstimate,
|
|
62
|
+
readPlan,
|
|
63
|
+
round,
|
|
64
|
+
} from "./decisions";
|
|
65
|
+
import {
|
|
66
|
+
ATTENTION_LABEL,
|
|
67
|
+
CHANGE_LABEL,
|
|
68
|
+
changeTotals,
|
|
69
|
+
CHECKS_LABEL,
|
|
70
|
+
checkCounts,
|
|
71
|
+
MERGEABILITY_LABEL,
|
|
72
|
+
parseChangesTarget,
|
|
73
|
+
parseCommitLimit,
|
|
74
|
+
parsePathFilter,
|
|
75
|
+
parseThreadIds,
|
|
76
|
+
PR_STATE_LABEL,
|
|
77
|
+
projectRequestKey,
|
|
78
|
+
relativeTime,
|
|
79
|
+
REVIEW_LABEL,
|
|
80
|
+
scopeLabel,
|
|
81
|
+
threadStatusLabel,
|
|
82
|
+
type ChangesView,
|
|
83
|
+
type CommitsView,
|
|
84
|
+
type Parsed,
|
|
85
|
+
type ProjectRequest,
|
|
86
|
+
type ProjectResult,
|
|
87
|
+
type PullRequestView,
|
|
88
|
+
type ThreadsView,
|
|
89
|
+
} from "./project";
|
|
90
|
+
import { previewText, threadActivityLabel } from "./thread-activity";
|
|
91
|
+
import { commonDirectory, displayPath, formatDuration, readTestRun, stripAnsi } from "./test-results";
|
|
92
|
+
import { firstError, foldRepeats, readLogs, LOG_LEVELS } from "./logs";
|
|
93
|
+
import { frameRows, memberPlaces, readTrace, relationLabel, type TraceFrame } from "./stack-trace";
|
|
94
|
+
import {
|
|
95
|
+
aheadBehindLabel,
|
|
96
|
+
defaultEnvironments,
|
|
97
|
+
environmentTitle,
|
|
98
|
+
kindLabel,
|
|
99
|
+
LIFE_LABEL,
|
|
100
|
+
liveThreads,
|
|
101
|
+
threadSummary,
|
|
102
|
+
uncommittedLabel,
|
|
103
|
+
type EnvironmentInfo,
|
|
104
|
+
} from "./environments";
|
|
105
|
+
import {
|
|
106
|
+
cardLate,
|
|
107
|
+
formatDay,
|
|
108
|
+
formatDays,
|
|
109
|
+
formatRange,
|
|
110
|
+
HEALTH_LABEL,
|
|
111
|
+
healthSummary,
|
|
112
|
+
itemDays,
|
|
113
|
+
localDay,
|
|
114
|
+
readBoard,
|
|
115
|
+
readRoadmap,
|
|
116
|
+
readStatus,
|
|
117
|
+
relativeDue,
|
|
118
|
+
ROADMAP_STATUS_LABEL,
|
|
119
|
+
type BoardCard,
|
|
120
|
+
type Health,
|
|
121
|
+
type RoadmapStatus,
|
|
122
|
+
} from "./tracking";
|
|
123
|
+
import {
|
|
124
|
+
chainSummary,
|
|
125
|
+
readProvenance,
|
|
126
|
+
RECEIPT_LABEL,
|
|
127
|
+
shortValue,
|
|
128
|
+
STAGE_STATUS_LABEL,
|
|
129
|
+
type Evidence as ProvenanceEvidence,
|
|
130
|
+
type Fact as ProvenanceFact,
|
|
131
|
+
} from "./provenance";
|
|
132
|
+
import type { UiNode, Value } from "./types";
|
|
133
|
+
|
|
134
|
+
const TONE_DOTS: Record<string, string> = { good: "🟢", warn: "🟠", bad: "🔴", info: "🔵", neutral: "⚪" };
|
|
135
|
+
const STEP_MARKERS: Record<string, string> = { done: "✅ ", current: "▶️ ", blocked: "⛔ " };
|
|
136
|
+
|
|
137
|
+
/** A readable, non-interactive markdown rendering of an evaluated UI, for clients that can't run it (mobile app, notifications, CLI). Uses the current state values. */
|
|
138
|
+
export function toMarkdown(roots: readonly UiNode[]): string {
|
|
139
|
+
return paragraphs(blocks(roots, false));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** compileStreaming/compile + initialState + evaluate + toMarkdown in one call; on compile failure returns the source in a ```ui fence so nothing is lost. */
|
|
143
|
+
export function specToMarkdown(source: string, data?: ReadonlyMap<string, LoadedData>): string {
|
|
144
|
+
const { program } = compile(source);
|
|
145
|
+
if (program === null) return fence(source, "ui");
|
|
146
|
+
return toMarkdown(evaluate(program, initialState(program, data), data).roots);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function hasKind<K extends "node" | "action" | "binding">(
|
|
150
|
+
value: Value | undefined,
|
|
151
|
+
kind: K,
|
|
152
|
+
): value is Extract<Value, { readonly kind: K }> {
|
|
153
|
+
return typeof value === "object" && value !== null && "kind" in value && value.kind === kind;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function list(value: Value | undefined): readonly Value[] {
|
|
157
|
+
return Array.isArray(value) ? value : [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function children(value: Value | undefined): UiNode[] {
|
|
161
|
+
return list(value).filter((child) => hasKind(child, "node"));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A list item's fields by key; a non-object item stands for its `key` field (e.g. a bare string is a title). */
|
|
165
|
+
function fields(item: Value, key: string): { readonly [key: string]: Value } {
|
|
166
|
+
// Narrowing keeps structurally compatible Action shapes; widen to read by key.
|
|
167
|
+
return isPlainObject(item) ? item : { [key]: item };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A table lookup that ignores inherited keys, for free-form model text such as an item's `tone`. */
|
|
171
|
+
function lookup(table: Record<string, string>, key: string): string {
|
|
172
|
+
return Object.hasOwn(table, key) ? table[key] : "";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function paragraphs(parts: readonly string[]): string {
|
|
176
|
+
return parts.filter((part) => part !== "").join("\n\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Lines under an optional `label:` line; just `label: —` when there are none. */
|
|
180
|
+
function labelledLines(label: string, lines: readonly string[]): string {
|
|
181
|
+
if (lines.length === 0) return labelled(label, "—");
|
|
182
|
+
return [...(label === "" ? [] : [`${label}:`]), ...lines].join("\n");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* One block per node. Consecutive Buttons share one `Options:` line; in a Row, so do consecutive Metrics.
|
|
187
|
+
* A toned Icon is a dot prefixed to the next one-line block, or a block of its own when nothing follows.
|
|
188
|
+
*/
|
|
189
|
+
function blocks(nodes: readonly UiNode[], row: boolean): string[] {
|
|
190
|
+
const out: string[] = [];
|
|
191
|
+
let previous: UiNode["type"] | undefined;
|
|
192
|
+
let icon = "";
|
|
193
|
+
for (const node of nodes) {
|
|
194
|
+
if (node.type === "Icon") {
|
|
195
|
+
if (icon !== "") out.push(icon);
|
|
196
|
+
icon = block(node);
|
|
197
|
+
} else if (node.type === previous && (node.type === "Button" || (row && node.type === "Metric"))) {
|
|
198
|
+
out[out.length - 1] += ` · ${node.type === "Button" ? buttonLabel(node) : block(node)}`;
|
|
199
|
+
} else {
|
|
200
|
+
const text = block(node);
|
|
201
|
+
if (icon !== "" && text !== "" && !text.includes("\n") && !/^[|>#-]/.test(text)) {
|
|
202
|
+
out.push(`${icon} ${text}`);
|
|
203
|
+
} else {
|
|
204
|
+
if (icon !== "") out.push(icon);
|
|
205
|
+
out.push(text);
|
|
206
|
+
}
|
|
207
|
+
icon = "";
|
|
208
|
+
}
|
|
209
|
+
previous = node.type;
|
|
210
|
+
}
|
|
211
|
+
if (icon !== "") out.push(icon);
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A value as text on one logical line: components in block form, lists comma-separated. */
|
|
216
|
+
function inline(value: Value | undefined): string {
|
|
217
|
+
if (value === undefined || value === null) return "";
|
|
218
|
+
if (typeof value !== "object") return String(value);
|
|
219
|
+
if (Array.isArray(value)) return value.map(inline).join(", ");
|
|
220
|
+
if (hasKind(value, "node")) return value.type === "Button" ? buttonLabel(value) : block(value);
|
|
221
|
+
if (hasKind(value, "binding")) return inline(value.value);
|
|
222
|
+
if (hasKind(value, "action")) return "";
|
|
223
|
+
return Object.entries(value)
|
|
224
|
+
.map(([key, item]) => `${key}: ${inline(item)}`)
|
|
225
|
+
.join(", ");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A Button as an option: its label, `⚠ `-marked when destructive; icons have no text form. */
|
|
229
|
+
function buttonLabel(node: UiNode): string {
|
|
230
|
+
return `${node.props.variant === "destructive" ? "⚠ " : ""}${inline(node.props.label)}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A GFM table cell: one line, `|` escaped. */
|
|
234
|
+
function cell(value: Value): string {
|
|
235
|
+
return inline(value).replace(/\s*\r?\n\s*/g, " ").replace(/\|/g, "\\|");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function table(columns: readonly Value[], rows: readonly Value[]): string {
|
|
239
|
+
const header = columns.map(cell);
|
|
240
|
+
const body = rows.map((row) => (Array.isArray(row) ? row : [row]).map(cell));
|
|
241
|
+
const width = body.reduce((widest, cells) => Math.max(widest, cells.length), header.length);
|
|
242
|
+
if (width === 0) return "";
|
|
243
|
+
const line = (cells: readonly string[]) => `| ${Array.from({ length: width }, (_, i) => cells[i] ?? "").join(" | ")} |`;
|
|
244
|
+
return [line(header), line(Array(width).fill("---")), ...body.map(line)].join("\n");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A fenced block whose fence is longer than any backtick run inside. */
|
|
248
|
+
function fence(code: string, language: string): string {
|
|
249
|
+
const longest = (code.match(/`+/g) ?? []).reduce((max, run) => Math.max(max, run.length), 2);
|
|
250
|
+
const ticks = "`".repeat(longest + 1);
|
|
251
|
+
return `${ticks}${language}\n${code.replace(/\n$/, "")}\n${ticks}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** An input's label (its bound state's name when unlabeled) and current value. */
|
|
255
|
+
function field(node: UiNode): { readonly label: string; readonly value: Value } {
|
|
256
|
+
const binding = node.props.value;
|
|
257
|
+
const name = hasKind(binding, "binding") ? binding.name : "";
|
|
258
|
+
const label = inline(node.props.label) || name.charAt(0).toUpperCase() + name.slice(1);
|
|
259
|
+
return { label, value: hasKind(binding, "binding") ? binding.value : null };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function labelled(label: string, text: string): string {
|
|
263
|
+
return label === "" ? text : `${label}: ${text}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function finite(value: Value | undefined): value is number {
|
|
267
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** A sum without float noise (0.1 + 0.2 → 0.3); null when nothing is a number. */
|
|
271
|
+
function total(values: readonly Value[]): number | null {
|
|
272
|
+
const numbers = values.filter(finite);
|
|
273
|
+
return numbers.length === 0 ? null : Number(numbers.reduce((sum, n) => sum + n, 0).toPrecision(12));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** A Snippet's location: `path`, `path:12` or `path:12-20`. */
|
|
277
|
+
function location(path: Value | undefined, from: Value | undefined, to: Value | undefined): string {
|
|
278
|
+
const start = finite(from) ? from : finite(to) ? 1 : null;
|
|
279
|
+
const range = start === null ? "" : finite(to) && to !== start ? `:${start}-${to}` : `:${start}`;
|
|
280
|
+
return `\`${inline(path)}${range}\``;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** KeyValue pairs from an object, or from `[label, value]` / `{label, value}` list items. */
|
|
284
|
+
function pairs(value: Value | undefined): [Value, Value][] {
|
|
285
|
+
if (value !== undefined && isPlainObject(value)) return Object.entries(value);
|
|
286
|
+
return list(value).flatMap((item): [Value, Value][] => {
|
|
287
|
+
if (Array.isArray(item)) return item.length >= 2 ? [[item[0], item[1]]] : [];
|
|
288
|
+
const pair = fields(item, "");
|
|
289
|
+
if (isPlainObject(item) && Object.hasOwn(pair, "label")) return [[pair.label, pair.value ?? null]];
|
|
290
|
+
return [];
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
interface TreeEntry {
|
|
295
|
+
readonly label: string;
|
|
296
|
+
note: string;
|
|
297
|
+
folder: boolean;
|
|
298
|
+
readonly children: TreeEntry[];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function treeEntry(level: TreeEntry[], label: string): TreeEntry {
|
|
302
|
+
const existing = level.find((entry) => entry.label === label);
|
|
303
|
+
if (existing !== undefined) return existing;
|
|
304
|
+
const entry: TreeEntry = { label, note: "", folder: false, children: [] };
|
|
305
|
+
level.push(entry);
|
|
306
|
+
return entry;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Merges path strings ("src/a.ts", "docs/") and `{label, children?, note?}` objects into one tree, in first-seen order. */
|
|
310
|
+
function treeEntries(items: readonly Value[], into: TreeEntry[]): TreeEntry[] {
|
|
311
|
+
for (const item of items) {
|
|
312
|
+
if (typeof item === "string" || typeof item === "number") {
|
|
313
|
+
const path = String(item).trim();
|
|
314
|
+
const segments = path.split("/").filter((segment) => segment !== "" && segment !== ".");
|
|
315
|
+
let level = into;
|
|
316
|
+
segments.forEach((segment, index) => {
|
|
317
|
+
const entry = treeEntry(level, segment);
|
|
318
|
+
if (index < segments.length - 1 || path.endsWith("/")) entry.folder = true;
|
|
319
|
+
level = entry.children;
|
|
320
|
+
});
|
|
321
|
+
} else if (isPlainObject(item)) {
|
|
322
|
+
const node = fields(item, "");
|
|
323
|
+
const raw = inline(node.label).trim();
|
|
324
|
+
const label = raw.replace(/\/+$/, "");
|
|
325
|
+
if (label === "") continue;
|
|
326
|
+
const entry = treeEntry(into, label);
|
|
327
|
+
const note = inline(node.note);
|
|
328
|
+
if (note !== "") entry.note = note;
|
|
329
|
+
if (raw !== label || Array.isArray(node.children)) entry.folder = true;
|
|
330
|
+
treeEntries(list(node.children), entry.children);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return into;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function treeLines(entries: readonly TreeEntry[], depth: number): string[] {
|
|
337
|
+
return entries.flatMap((entry) => [
|
|
338
|
+
`${" ".repeat(depth)}- ${entry.label}${entry.folder || entry.children.length > 0 ? "/" : ""}${entry.note === "" ? "" : ` — ${entry.note}`}`,
|
|
339
|
+
...treeLines(entry.children, depth + 1),
|
|
340
|
+
]);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** `m:ss`, or `h:mm:ss` from an hour. */
|
|
344
|
+
function clock(seconds: number): string {
|
|
345
|
+
const whole = Math.round(seconds);
|
|
346
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
347
|
+
const hours = Math.floor(whole / 3600);
|
|
348
|
+
const minutes = Math.floor((whole % 3600) / 60);
|
|
349
|
+
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(whole % 60)}` : `${minutes}:${pad(whole % 60)}`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** A part's whole-percent share of `sum`; a sliver reads `<1%`, never 0%. Empty without a total. */
|
|
353
|
+
function wholeShare(part: number, sum: number): string {
|
|
354
|
+
if (!(sum > 0)) return "";
|
|
355
|
+
const share = part / sum;
|
|
356
|
+
return share > 0 && share < 0.005 ? "<1%" : `${Math.round(share * 100)}%`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** A stage-to-stage rate as on screen: whole from 10%, one decimal below, `<0.1%` for slivers. */
|
|
360
|
+
function conversion(ratio: number): string {
|
|
361
|
+
const percent = ratio * 100;
|
|
362
|
+
if (percent === 0) return "0%";
|
|
363
|
+
if (Math.abs(percent) < 0.1) return "<0.1%";
|
|
364
|
+
return `${Number(percent.toFixed(Math.abs(percent) < 10 ? 1 : 0))}%`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* A CalendarHeatmap as text: its total and busiest day, then each day when
|
|
371
|
+
* there are up to two weeks of them, else a row per month. Dates stay
|
|
372
|
+
* strings throughout, so no time zone can move one.
|
|
373
|
+
*/
|
|
374
|
+
function calendarMarkdown(label: string, days: Value | undefined): string {
|
|
375
|
+
const entries: [Value | undefined, Value | undefined][] =
|
|
376
|
+
days !== undefined && isPlainObject(days)
|
|
377
|
+
? Object.entries(days)
|
|
378
|
+
: list(days).map((item) => {
|
|
379
|
+
if (Array.isArray(item)) return [item[0], item[1] ?? 1];
|
|
380
|
+
const day = fields(item, "date");
|
|
381
|
+
return [day.date, day.value === undefined ? 1 : day.value];
|
|
382
|
+
});
|
|
383
|
+
const sums = new Map<string, number>();
|
|
384
|
+
for (const [date, value] of entries) {
|
|
385
|
+
if (typeof date !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(date) || !finite(value)) continue;
|
|
386
|
+
sums.set(date, (sums.get(date) ?? 0) + value);
|
|
387
|
+
}
|
|
388
|
+
const dates = [...sums.keys()].sort();
|
|
389
|
+
if (dates.length === 0) return labelled(label, "—");
|
|
390
|
+
const busiest = (within: readonly string[]) =>
|
|
391
|
+
within.reduce((best, date) => (sums.get(date)! > sums.get(best)! ? date : best));
|
|
392
|
+
const peak = busiest(dates);
|
|
393
|
+
const summary = labelled(
|
|
394
|
+
label,
|
|
395
|
+
`**${total(dates.map((date) => sums.get(date)!))}** total, ${dates[0]} to ${dates[dates.length - 1]}; busiest ${peak} (${sums.get(peak)})`,
|
|
396
|
+
);
|
|
397
|
+
if (dates.length <= 14) {
|
|
398
|
+
return paragraphs([summary, table(["Date", label || "Value"], dates.map((date) => [date, sums.get(date)!]))]);
|
|
399
|
+
}
|
|
400
|
+
const months = new Map<string, string[]>();
|
|
401
|
+
for (const date of dates) months.set(date.slice(0, 7), [...(months.get(date.slice(0, 7)) ?? []), date]);
|
|
402
|
+
return paragraphs([
|
|
403
|
+
summary,
|
|
404
|
+
table(
|
|
405
|
+
["Month", "Total", "Active days", "Busiest"],
|
|
406
|
+
[...months].map(([month, within]) => {
|
|
407
|
+
const top = busiest(within);
|
|
408
|
+
return [
|
|
409
|
+
`${MONTH_NAMES[Number(month.slice(5)) - 1] ?? month} ${month.slice(0, 4)}`,
|
|
410
|
+
total(within.map((date) => sums.get(date)!)),
|
|
411
|
+
within.filter((date) => sums.get(date)! > 0).length,
|
|
412
|
+
`${top} (${sums.get(top)})`,
|
|
413
|
+
];
|
|
414
|
+
}),
|
|
415
|
+
),
|
|
416
|
+
]);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function block(node: UiNode): string {
|
|
420
|
+
const props = node.props;
|
|
421
|
+
switch (node.type) {
|
|
422
|
+
case "Card": {
|
|
423
|
+
const title = inline(props.title);
|
|
424
|
+
return paragraphs([title === "" ? "" : `**${title}**`, ...blocks(children(props.children), false)]);
|
|
425
|
+
}
|
|
426
|
+
case "Details":
|
|
427
|
+
return paragraphs([`**${inline(props.title)}**`, ...blocks(children(props.children), false)]);
|
|
428
|
+
case "Row":
|
|
429
|
+
return paragraphs(blocks(children(props.children), true));
|
|
430
|
+
case "Stack":
|
|
431
|
+
case "Grid":
|
|
432
|
+
return paragraphs(blocks(children(props.children), false));
|
|
433
|
+
case "Divider":
|
|
434
|
+
return "---";
|
|
435
|
+
case "Text":
|
|
436
|
+
return inline(props.text);
|
|
437
|
+
case "Callout": {
|
|
438
|
+
const title = inline(props.title);
|
|
439
|
+
const text = inline(props.text);
|
|
440
|
+
const dot = lookup(TONE_DOTS, inline(props.tone) || "info");
|
|
441
|
+
const content = title === "" ? `${dot} ${text}` : paragraphs([`${dot} **${title}**`, text]);
|
|
442
|
+
// Every line quoted; blank lines stay inside the quote.
|
|
443
|
+
return content
|
|
444
|
+
.split("\n")
|
|
445
|
+
.map((line) => (line === "" ? ">" : `> ${line}`))
|
|
446
|
+
.join("\n");
|
|
447
|
+
}
|
|
448
|
+
case "Icon":
|
|
449
|
+
return lookup(TONE_DOTS, inline(props.tone));
|
|
450
|
+
case "Metric": {
|
|
451
|
+
const note = inline(props.note);
|
|
452
|
+
return `${inline(props.label)} **${inline(props.value)}**${note === "" ? "" : ` (${note})`}`;
|
|
453
|
+
}
|
|
454
|
+
case "Badge":
|
|
455
|
+
return `${TONE_DOTS[inline(props.tone) || "neutral"]} ${inline(props.text)}`;
|
|
456
|
+
case "KeyValue": {
|
|
457
|
+
const entries = pairs(props.pairs);
|
|
458
|
+
if (entries.length > 3) return table(["", ""], entries);
|
|
459
|
+
// Two trailing spaces: a hard line break, so the pairs stay on separate lines.
|
|
460
|
+
return entries.map(([key, value]) => `**${inline(key)}:** ${inline(value)}`).join(" \n");
|
|
461
|
+
}
|
|
462
|
+
case "Image": {
|
|
463
|
+
const src = inline(props.src).trim();
|
|
464
|
+
const alt = inline(props.alt).trim();
|
|
465
|
+
if (/^https:\/\//i.test(src)) {
|
|
466
|
+
const escapes: Record<string, string> = { " ": "%20", "(": "%28", ")": "%29" };
|
|
467
|
+
return `![${alt.replace(/[[\]\\]/g, "\\$&")}](${src.replace(/[ ()]/g, (c) => escapes[c])})`;
|
|
468
|
+
}
|
|
469
|
+
// Text clients can't load workspace files: name the image and where it is.
|
|
470
|
+
return `🖼 ${alt === "" ? src : `${alt} (${src})`}`;
|
|
471
|
+
}
|
|
472
|
+
case "Table":
|
|
473
|
+
return table(list(props.columns), list(props.rows));
|
|
474
|
+
case "Chart": {
|
|
475
|
+
const unit = inline(props.unit).trim();
|
|
476
|
+
const withUnit = (name: string) => (unit === "" ? name : `${name} (${unit})`);
|
|
477
|
+
const series = props.series;
|
|
478
|
+
const named: [string, readonly Value[]][] = Array.isArray(series)
|
|
479
|
+
? [["Value", series]]
|
|
480
|
+
: isPlainObject(series)
|
|
481
|
+
? Object.entries(series).map(([name, values]) => [name, list(values)])
|
|
482
|
+
: [];
|
|
483
|
+
const labels = list(props.labels);
|
|
484
|
+
if (props.type === "pie") {
|
|
485
|
+
// A pie has one series; its slices are shares of the positive total.
|
|
486
|
+
const [name, values] = named[0] ?? ["Value", []];
|
|
487
|
+
const slices = labels.map((_, index) => {
|
|
488
|
+
const value = values[index];
|
|
489
|
+
return finite(value) && value > 0 ? value : 0;
|
|
490
|
+
});
|
|
491
|
+
const sum = slices.reduce((all, slice) => all + slice, 0);
|
|
492
|
+
return table(
|
|
493
|
+
["", withUnit(name), "%"],
|
|
494
|
+
labels.map((label, index) => [label, values[index] ?? null, wholeShare(slices[index], sum)]),
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
if (props.type === "funnel") {
|
|
498
|
+
// A funnel has one series too: each stage against the first and the one before.
|
|
499
|
+
const [name, values] = named[0] ?? ["Value", []];
|
|
500
|
+
const first = values[0];
|
|
501
|
+
return table(
|
|
502
|
+
["", withUnit(name), "% of first", "% of previous"],
|
|
503
|
+
labels.map((label, index) => {
|
|
504
|
+
const value = values[index];
|
|
505
|
+
const previous = values[index - 1];
|
|
506
|
+
return [
|
|
507
|
+
label,
|
|
508
|
+
value ?? null,
|
|
509
|
+
index > 0 && finite(value) && finite(first) && first > 0 ? conversion(value / first) : "",
|
|
510
|
+
finite(value) && finite(previous) && previous > 0 ? conversion(value / previous) : "",
|
|
511
|
+
];
|
|
512
|
+
}),
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
if (props.type === "heatmap") {
|
|
516
|
+
// Laid out as on screen: labels across, one row per series.
|
|
517
|
+
return table([unit, ...labels], named.map(([name, values]) => [name, ...labels.map((_, index) => values[index] ?? null)]));
|
|
518
|
+
}
|
|
519
|
+
const stacked = props.stacked === true && named.length > 1 && (props.type === "bar" || props.type === "area");
|
|
520
|
+
return table(
|
|
521
|
+
[props.type === "scatter" ? "x" : "", ...named.map(([name]) => withUnit(name)), ...(stacked ? [withUnit("Total")] : [])],
|
|
522
|
+
labels.map((label, index) => {
|
|
523
|
+
const values = named.map(([, values]) => values[index] ?? null);
|
|
524
|
+
return [label, ...values, ...(stacked ? [total(values)] : [])];
|
|
525
|
+
}),
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
case "Sparkline": {
|
|
529
|
+
const values = list(props.values).filter(finite);
|
|
530
|
+
if (values.length < 2) return values.map(String).join("");
|
|
531
|
+
return `${values[0]} → ${values[values.length - 1]} (min ${Math.min(...values)}, max ${Math.max(...values)})`;
|
|
532
|
+
}
|
|
533
|
+
case "Progress": {
|
|
534
|
+
const value = Number(props.value);
|
|
535
|
+
const max = props.max;
|
|
536
|
+
const amount =
|
|
537
|
+
typeof max !== "number" ? `${value}%` : `${value}/${max}${max === 0 ? "" : ` (${Math.round((value / max) * 100)}%)`}`;
|
|
538
|
+
return labelled(inline(props.label), amount);
|
|
539
|
+
}
|
|
540
|
+
case "Steps":
|
|
541
|
+
return list(props.items)
|
|
542
|
+
.map((item, index) => {
|
|
543
|
+
const step = fields(item, "title");
|
|
544
|
+
const detail = inline(step.detail);
|
|
545
|
+
const marker = lookup(STEP_MARKERS, inline(step.status));
|
|
546
|
+
return `${index + 1}. ${marker}${inline(step.title)}${detail === "" ? "" : ` — ${detail}`}`;
|
|
547
|
+
})
|
|
548
|
+
.join("\n");
|
|
549
|
+
case "Timeline":
|
|
550
|
+
return list(props.items)
|
|
551
|
+
.map((item) => {
|
|
552
|
+
const event = fields(item, "title");
|
|
553
|
+
const dot = lookup(TONE_DOTS, inline(event.tone));
|
|
554
|
+
const head = [inline(event.time), inline(event.title)].filter((part) => part !== "").join(" · ");
|
|
555
|
+
const detail = inline(event.detail);
|
|
556
|
+
return `- ${dot === "" ? "" : `${dot} `}${head}${detail === "" ? "" : ` — ${detail}`}`;
|
|
557
|
+
})
|
|
558
|
+
.join("\n");
|
|
559
|
+
case "Tree":
|
|
560
|
+
return treeLines(treeEntries(list(props.items), []), 0).join("\n");
|
|
561
|
+
case "Tabs": {
|
|
562
|
+
const labels = list(props.labels);
|
|
563
|
+
return paragraphs(
|
|
564
|
+
children(props.children).map((child, index) => {
|
|
565
|
+
const label = inline(labels[index]);
|
|
566
|
+
return paragraphs([label === "" ? "" : `**${label}**`, block(child)]);
|
|
567
|
+
}),
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
case "Code":
|
|
571
|
+
return fence(inline(props.code), inline(props.language));
|
|
572
|
+
case "Snippet": {
|
|
573
|
+
const where = location(props.path, props.from, props.to);
|
|
574
|
+
const error = inline(props.error);
|
|
575
|
+
if (error !== "") return `${where} _(error: ${error})_`;
|
|
576
|
+
return typeof props.code === "string" ? `${where}\n${fence(props.code, inline(props.language))}` : where;
|
|
577
|
+
}
|
|
578
|
+
case "Diff":
|
|
579
|
+
return fence(inline(props.patch), "diff");
|
|
580
|
+
case "Diagram":
|
|
581
|
+
return diagramMarkdown(inline(props.mermaid), props.details);
|
|
582
|
+
case "File": {
|
|
583
|
+
const line = inline(props.line);
|
|
584
|
+
return `\`${inline(props.path)}${line === "" ? "" : `:${line}`}\``;
|
|
585
|
+
}
|
|
586
|
+
case "Slider": {
|
|
587
|
+
const { label, value } = field(node);
|
|
588
|
+
return labelled(label, `${inline(value) || "—"} (${inline(props.min)}–${inline(props.max)})`);
|
|
589
|
+
}
|
|
590
|
+
case "Toggle": {
|
|
591
|
+
const { label, value } = field(node);
|
|
592
|
+
return labelled(label, value === true ? "on" : "off");
|
|
593
|
+
}
|
|
594
|
+
case "Choice":
|
|
595
|
+
case "Select": {
|
|
596
|
+
const { label, value } = field(node);
|
|
597
|
+
return labelled(label, `${inline(value) || "—"} (${list(props.options).map(inline).join(", ")})`);
|
|
598
|
+
}
|
|
599
|
+
case "Radio": {
|
|
600
|
+
const { label, value } = field(node);
|
|
601
|
+
const options = list(props.options).map((item) => {
|
|
602
|
+
const option = fields(item, "value");
|
|
603
|
+
const picked = value !== null && option.value === value;
|
|
604
|
+
const detail = inline(option.detail);
|
|
605
|
+
return `- ${picked ? "◉" : "○"} ${inline(option.label ?? option.value)}${detail === "" ? "" : ` — ${detail}`}`;
|
|
606
|
+
});
|
|
607
|
+
return labelledLines(label, options);
|
|
608
|
+
}
|
|
609
|
+
case "Checklist": {
|
|
610
|
+
const { label, value } = field(node);
|
|
611
|
+
const picked = list(value);
|
|
612
|
+
const items = list(props.options).map((option) => `- [${picked.includes(option) ? "x" : " "}] ${inline(option)}`);
|
|
613
|
+
return [label === "" ? "" : `${label}:`, ...items].filter((line) => line !== "").join("\n");
|
|
614
|
+
}
|
|
615
|
+
case "ListEditor": {
|
|
616
|
+
const { label, value } = field(node);
|
|
617
|
+
return labelledLines(
|
|
618
|
+
label,
|
|
619
|
+
list(value).map((item) => `- ${inline(item)}`),
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
case "Stepper":
|
|
623
|
+
case "Input":
|
|
624
|
+
case "TextArea":
|
|
625
|
+
case "DateInput": {
|
|
626
|
+
const { label, value } = field(node);
|
|
627
|
+
return labelled(label, inline(value) || "—");
|
|
628
|
+
}
|
|
629
|
+
case "Rating": {
|
|
630
|
+
const { label, value } = field(node);
|
|
631
|
+
const max = finite(props.max) ? Math.min(10, Math.max(1, Math.round(props.max))) : 5;
|
|
632
|
+
const stars = finite(value) ? Math.min(max, Math.max(0, Math.round(value))) : 0;
|
|
633
|
+
return labelled(label, `${"★".repeat(stars)}${"☆".repeat(max - stars)} (${stars}/${max})`);
|
|
634
|
+
}
|
|
635
|
+
case "Button":
|
|
636
|
+
return `Options: ${buttonLabel(node)}`;
|
|
637
|
+
case "Form":
|
|
638
|
+
return paragraphs([...blocks(children(props.children), false), `Submit: ${inline(props.submit).trim() || "Send"}`]);
|
|
639
|
+
case "Timer": {
|
|
640
|
+
const seconds = props.seconds;
|
|
641
|
+
return `⏱ ${labelled(inline(props.label), finite(seconds) && seconds > 0 ? `${clock(seconds)} countdown` : "stopwatch")}`;
|
|
642
|
+
}
|
|
643
|
+
case "Quiz":
|
|
644
|
+
return paragraphs(
|
|
645
|
+
list(props.questions).map((item, index) => {
|
|
646
|
+
const question = fields(item, "q");
|
|
647
|
+
const marker = `${index + 1}. `;
|
|
648
|
+
const indent = " ".repeat(marker.length);
|
|
649
|
+
const options = list(question.options);
|
|
650
|
+
const answer = question.answer;
|
|
651
|
+
const correct =
|
|
652
|
+
typeof answer === "number" && Number.isInteger(answer) && answer >= 0 && answer < options.length
|
|
653
|
+
? `${String.fromCharCode(65 + answer)}. ${inline(options[answer])}`
|
|
654
|
+
: "";
|
|
655
|
+
const why = inline(question.why);
|
|
656
|
+
const verdict = [correct, why].filter((part) => part !== "").join(" — ");
|
|
657
|
+
return [
|
|
658
|
+
`${marker}${inline(question.q ?? question.question)}`,
|
|
659
|
+
...options.map((option, at) => `${indent}- ${String.fromCharCode(65 + at)}. ${inline(option)}`),
|
|
660
|
+
// A paragraph of its own after the options, so it reads as the reveal rather than another option.
|
|
661
|
+
...(verdict === "" ? [] : ["", `${indent}**Answer:** ${verdict}`]),
|
|
662
|
+
].join("\n");
|
|
663
|
+
}),
|
|
664
|
+
);
|
|
665
|
+
case "Flashcards":
|
|
666
|
+
return list(props.cards)
|
|
667
|
+
.map((item) => {
|
|
668
|
+
const card = fields(item, "front");
|
|
669
|
+
const back = inline(card.back);
|
|
670
|
+
return `- ${inline(card.front)}${back === "" ? "" : ` → ${back}`}`;
|
|
671
|
+
})
|
|
672
|
+
.join("\n");
|
|
673
|
+
case "Carousel":
|
|
674
|
+
return paragraphs(blocks(children(props.children), false));
|
|
675
|
+
case "OptionCards": {
|
|
676
|
+
const { label, value } = field(node);
|
|
677
|
+
const multiple = Array.isArray(value);
|
|
678
|
+
const options = list(props.options).flatMap((item) => {
|
|
679
|
+
const option = fields(item, "value");
|
|
680
|
+
const chosen = option.value ?? option.title;
|
|
681
|
+
if (chosen === undefined || chosen === null) return [];
|
|
682
|
+
const picked = multiple ? list(value).includes(chosen) : chosen === value;
|
|
683
|
+
const marker = multiple ? `[${picked ? "x" : " "}]` : picked ? "◉" : "○";
|
|
684
|
+
const detail = inline(option.detail);
|
|
685
|
+
return [`- ${marker} ${inline(option.title ?? chosen)}${detail === "" ? "" : ` — ${detail}`}`];
|
|
686
|
+
});
|
|
687
|
+
return labelledLines(label, options);
|
|
688
|
+
}
|
|
689
|
+
case "Swatches": {
|
|
690
|
+
// Unbound it is a palette: no value, no picked marker.
|
|
691
|
+
const bound = hasKind(props.value, "binding");
|
|
692
|
+
const { label, value } = field(node);
|
|
693
|
+
const colors = list(props.colors).flatMap((item) => {
|
|
694
|
+
const color = fields(item, "value");
|
|
695
|
+
const css = inline(color.value).trim();
|
|
696
|
+
if (css === "") return [];
|
|
697
|
+
const name = inline(color.name).trim();
|
|
698
|
+
// Colours compare without case or surrounding space.
|
|
699
|
+
const picked = typeof value === "string" && value.trim().toLowerCase() === css.toLowerCase();
|
|
700
|
+
const marker = !bound ? "" : picked ? "◉ " : "○ ";
|
|
701
|
+
return [`- ${marker}${name === "" ? "" : `${name} `}\`${css}\``];
|
|
702
|
+
});
|
|
703
|
+
return labelledLines(label, colors);
|
|
704
|
+
}
|
|
705
|
+
case "EditableTable": {
|
|
706
|
+
const { label, value } = field(node);
|
|
707
|
+
const columns = editableColumns(props.columns);
|
|
708
|
+
const rows = list(value).map((item) => {
|
|
709
|
+
const row = fields(item, "");
|
|
710
|
+
return columns.map(({ key }) => {
|
|
711
|
+
const cell = Object.hasOwn(row, key) ? row[key] : null;
|
|
712
|
+
return typeof cell === "boolean" ? (cell ? "✓" : "✗") : cell;
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
return paragraphs([
|
|
716
|
+
label === "" ? "" : `${label}:`,
|
|
717
|
+
rows.length === 0 ? "—" : table(columns.map((column) => column.label), rows),
|
|
718
|
+
]);
|
|
719
|
+
}
|
|
720
|
+
case "FollowUps": {
|
|
721
|
+
const prompts = list(props.prompts)
|
|
722
|
+
.map((prompt) => inline(prompt).trim())
|
|
723
|
+
.filter((prompt) => prompt !== "");
|
|
724
|
+
return prompts.length === 0 ? "" : ["Follow-ups:", ...prompts.map((prompt) => `- ${prompt}`)].join("\n");
|
|
725
|
+
}
|
|
726
|
+
case "Entity": {
|
|
727
|
+
const name = inline(props.name).trim();
|
|
728
|
+
const kind = inline(props.kind);
|
|
729
|
+
const url = webUrl(inline(props.url), ["http:", "https:"]);
|
|
730
|
+
// A file image is decoration text clients can't load; an https one shows.
|
|
731
|
+
const image = remoteImage(inline(props.image), name);
|
|
732
|
+
return paragraphs([
|
|
733
|
+
image ?? "",
|
|
734
|
+
`**${name}**${Object.hasOwn(ENTITY_KINDS, kind) ? ` · ${ENTITY_KINDS[kind]}` : ""}`,
|
|
735
|
+
inline(props.note).trim(),
|
|
736
|
+
pairs(props.facts)
|
|
737
|
+
.map(([key, value]) => `**${inline(key)}:** ${inline(value)}`)
|
|
738
|
+
.join(" \n"),
|
|
739
|
+
url === null ? "" : `🔗 ${link(url.host.replace(/^www\./i, ""), url)}`,
|
|
740
|
+
]);
|
|
741
|
+
}
|
|
742
|
+
case "Sources": {
|
|
743
|
+
const lines = list(props.links).flatMap((item, index) => {
|
|
744
|
+
const source = fields(item, "url");
|
|
745
|
+
const raw = inline(source.url).trim();
|
|
746
|
+
// A streaming `{title: …` has not reached its url yet.
|
|
747
|
+
if (raw === "" && index === list(props.links).length - 1) return [];
|
|
748
|
+
const marker = `${index + 1}. `;
|
|
749
|
+
const url = webUrl(raw, ["https:"]);
|
|
750
|
+
if (url === null) return [`${marker}${raw || "—"} _(not an https link)_`];
|
|
751
|
+
const domain = url.host.replace(/^www\./i, "");
|
|
752
|
+
const title = inline(source.title).trim();
|
|
753
|
+
const note = inline(source.note).trim();
|
|
754
|
+
const about = [title === "" ? "" : domain, note].filter((part) => part !== "").join(" · ");
|
|
755
|
+
return [`${marker}${link(title || domain, url)}${about === "" ? "" : ` — ${about}`}`];
|
|
756
|
+
});
|
|
757
|
+
return lines.length === 0 ? "" : ["Sources:", ...lines].join("\n");
|
|
758
|
+
}
|
|
759
|
+
case "Hotspots": {
|
|
760
|
+
const src = inline(props.image).trim();
|
|
761
|
+
const points = list(props.points).flatMap((item, index) => {
|
|
762
|
+
const point = fields(item, "title");
|
|
763
|
+
const title = inline(point.title).trim() || `Point ${index + 1}`;
|
|
764
|
+
const body = inline(point.text).trim();
|
|
765
|
+
const marker = `${index + 1}. `;
|
|
766
|
+
// Continuation lines indented under the item, so a multi-paragraph text stays in it.
|
|
767
|
+
const text = body.replace(/\n(?=[^\n])/g, `\n${" ".repeat(marker.length)}`);
|
|
768
|
+
return [`${marker}**${title}**${body === "" ? "" : ` — ${text}`}`];
|
|
769
|
+
});
|
|
770
|
+
return paragraphs([remoteImage(src, "") ?? (src === "" ? "" : `🖼 ${src}`), points.join("\n")]);
|
|
771
|
+
}
|
|
772
|
+
case "ImageSearch": {
|
|
773
|
+
const query = inline(props.query).trim();
|
|
774
|
+
return searchedImage(query, inline(props.alt).trim() || query);
|
|
775
|
+
}
|
|
776
|
+
case "Gallery":
|
|
777
|
+
return paragraphs(list(props.images).map(galleryImage));
|
|
778
|
+
case "Map":
|
|
779
|
+
return placeList(list(props.places), props.route === true);
|
|
780
|
+
case "Gauge": {
|
|
781
|
+
const min = finite(props.min) ? props.min : 0;
|
|
782
|
+
const max = finite(props.max) ? props.max : 100;
|
|
783
|
+
return labelled(inline(props.label), `**${inline(props.value)}** (${min}–${max})`);
|
|
784
|
+
}
|
|
785
|
+
case "CalendarHeatmap":
|
|
786
|
+
return calendarMarkdown(inline(props.label), props.days);
|
|
787
|
+
case "SegmentedBar": {
|
|
788
|
+
const unit = inline(props.unit).trim();
|
|
789
|
+
const parts = list(props.parts)
|
|
790
|
+
.map((part) => fields(part, "label"))
|
|
791
|
+
.filter((part) => finite(part.value) && part.value >= 0);
|
|
792
|
+
const sum = parts.reduce((all, part) => all + (part.value as number), 0);
|
|
793
|
+
return table(
|
|
794
|
+
["", unit === "" ? "Value" : `Value (${unit})`, "%"],
|
|
795
|
+
parts.map((part) => {
|
|
796
|
+
const dot = lookup(TONE_DOTS, inline(part.tone));
|
|
797
|
+
return [`${dot === "" ? "" : `${dot} `}${inline(part.label)}`, part.value, wholeShare(part.value as number, sum)];
|
|
798
|
+
}),
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
case "Walkthrough":
|
|
802
|
+
return paragraphs(
|
|
803
|
+
list(props.steps).map((item, index) => {
|
|
804
|
+
const step = fields(item, "body");
|
|
805
|
+
const title = inline(step.title).trim();
|
|
806
|
+
const code = inline(step.code);
|
|
807
|
+
return paragraphs([
|
|
808
|
+
`**${index + 1}.${title === "" ? "" : ` ${title}`}**`,
|
|
809
|
+
inline(step.body).trim(),
|
|
810
|
+
code.trim() === "" ? "" : fence(code, inline(step.language).trim()),
|
|
811
|
+
]);
|
|
812
|
+
}),
|
|
813
|
+
);
|
|
814
|
+
case "List":
|
|
815
|
+
return list(props.items)
|
|
816
|
+
.flatMap((item) => {
|
|
817
|
+
const row = fields(item, "title");
|
|
818
|
+
const title = inline(row.title).trim();
|
|
819
|
+
if (title === "") return [];
|
|
820
|
+
const dot = lookup(TONE_DOTS, inline(row.tone));
|
|
821
|
+
// A row that opens a link reads as that link; other actions have no text form.
|
|
822
|
+
const actions: readonly (Value | undefined)[] = Array.isArray(row.action) ? row.action : [row.action];
|
|
823
|
+
const url =
|
|
824
|
+
actions.flatMap((action) =>
|
|
825
|
+
hasKind(action, "action") && action.type === "openUrl" ? [webUrl(action.url, ["http:", "https:"])] : [],
|
|
826
|
+
)[0] ?? null;
|
|
827
|
+
const tail = [inline(row.detail).trim(), inline(row.value).trim()].filter((part) => part !== "").join(" · ");
|
|
828
|
+
return [`- ${dot === "" ? "" : `${dot} `}${url === null ? `**${title}**` : link(title, url)}${tail === "" ? "" : ` — ${tail}`}`];
|
|
829
|
+
})
|
|
830
|
+
.join("\n");
|
|
831
|
+
case "Calendar":
|
|
832
|
+
return eventCalendarMarkdown(list(props.events), inline(props.month));
|
|
833
|
+
case "Changes":
|
|
834
|
+
case "Commits":
|
|
835
|
+
case "PullRequest":
|
|
836
|
+
case "Threads":
|
|
837
|
+
return projectMarkdown(node);
|
|
838
|
+
case "Tests":
|
|
839
|
+
return testsMarkdown(props.results, inline(props.title));
|
|
840
|
+
case "Json":
|
|
841
|
+
return jsonMarkdown(props.value ?? null, inline(props.label));
|
|
842
|
+
case "Plan":
|
|
843
|
+
return planMarkdown(props.steps, inline(props.submit));
|
|
844
|
+
case "Decision":
|
|
845
|
+
return decisionMarkdown(props.options, props.criteria);
|
|
846
|
+
case "Estimate":
|
|
847
|
+
return estimateMarkdown(props.tasks, props.unit);
|
|
848
|
+
case "Environments":
|
|
849
|
+
return environmentsMarkdown();
|
|
850
|
+
case "Findings":
|
|
851
|
+
return findingsMarkdown(props.items, inline(props.title));
|
|
852
|
+
case "Coverage":
|
|
853
|
+
return coverageMarkdown(props.report);
|
|
854
|
+
case "Board":
|
|
855
|
+
return boardMarkdown(props.items, props.columns, localDay(new Date()));
|
|
856
|
+
case "Roadmap":
|
|
857
|
+
return roadmapMarkdown(props.items, localDay(new Date()));
|
|
858
|
+
case "Status":
|
|
859
|
+
return statusMarkdown(props.items, localDay(new Date()));
|
|
860
|
+
case "Benchmark":
|
|
861
|
+
return benchmarkMarkdown(props.results, inline(props.unit).trim() || null);
|
|
862
|
+
case "Waterfall":
|
|
863
|
+
return waterfallMarkdown(props.spans, inline(props.unit).trim() || null);
|
|
864
|
+
case "Treemap":
|
|
865
|
+
return treemapMarkdown(props.items, inline(props.unit).trim() || null);
|
|
866
|
+
case "Logs":
|
|
867
|
+
return logsMarkdown(props.output, inline(props.command).trim(), props.exit);
|
|
868
|
+
case "StackTrace":
|
|
869
|
+
return stackTraceMarkdown(props.trace, inline(props.title).trim());
|
|
870
|
+
case "Pipeline":
|
|
871
|
+
return pipelineMarkdown(props.run);
|
|
872
|
+
case "Dependencies":
|
|
873
|
+
return dependenciesMarkdown(props.report);
|
|
874
|
+
case "Compare":
|
|
875
|
+
return compareMarkdown(props.images);
|
|
876
|
+
case "Provenance":
|
|
877
|
+
return provenanceMarkdown(props.stages, props.expected);
|
|
878
|
+
case "Acceptance":
|
|
879
|
+
return acceptanceMarkdown(props.criteria, props.columns);
|
|
880
|
+
case "Transcript":
|
|
881
|
+
return transcriptMarkdown(props.tracks, inline(props.audio).trim());
|
|
882
|
+
case "Error":
|
|
883
|
+
return `_(error: ${inline(props.message)})_`;
|
|
884
|
+
case "Loading":
|
|
885
|
+
return `_(loading ${inline(props.path)})_`;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** EditableTable columns: keys or `{key, label?}`; a bare key is its own label, capitalized. */
|
|
890
|
+
function editableColumns(value: Value | undefined): { readonly key: string; readonly label: string }[] {
|
|
891
|
+
return list(value).flatMap((item) => {
|
|
892
|
+
const column = fields(item, "key");
|
|
893
|
+
const key = inline(column.key).trim();
|
|
894
|
+
if (key === "") return [];
|
|
895
|
+
return [{ key, label: inline(column.label).trim() || key.charAt(0).toUpperCase() + key.slice(1) }];
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const ENTITY_KINDS: Record<string, string> = {
|
|
900
|
+
product: "Product",
|
|
901
|
+
place: "Place",
|
|
902
|
+
person: "Person",
|
|
903
|
+
org: "Organisation",
|
|
904
|
+
link: "Link",
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
/** An absolute URL with one of `protocols` (`"https:"`) and a host, or null. */
|
|
908
|
+
function webUrl(raw: string, protocols: readonly string[]): URL | null {
|
|
909
|
+
try {
|
|
910
|
+
const url = new URL(raw.trim());
|
|
911
|
+
return protocols.includes(url.protocol) && url.hostname !== "" ? url : null;
|
|
912
|
+
} catch {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** `[label](url)` with the label's brackets escaped and the URL safe inside the parentheses. */
|
|
918
|
+
function link(label: string, url: URL): string {
|
|
919
|
+
return `[${label.replace(/[[\]\\]/g, "\\$&")}](${url.href.replace(/[()]/g, (c) => (c === "(" ? "%28" : "%29"))})`;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** An https image as markdown (text clients can fetch it); null for files and anything else. */
|
|
923
|
+
function remoteImage(src: string, alt: string): string | null {
|
|
924
|
+
const url = webUrl(src, ["https:"]);
|
|
925
|
+
return url === null ? null : `!${link(alt, url)}`;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** A photo an image search found, with its credit (web/commons.ts `FoundImage`). */
|
|
929
|
+
export interface SearchedImage {
|
|
930
|
+
readonly url: string;
|
|
931
|
+
readonly creator: string;
|
|
932
|
+
readonly license: string;
|
|
933
|
+
readonly sourceUrl: string;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* The image searches of the rendering in progress, by query. block() is
|
|
938
|
+
* synchronous, so toMarkdownWithImages can hold them here for one call.
|
|
939
|
+
*/
|
|
940
|
+
let searched: ReadonlyMap<string, SearchedImage | null> = new Map();
|
|
941
|
+
|
|
942
|
+
/** toMarkdown with ImageSearch and Gallery `{query}` items shown as the photos `images` found for them (see imageSearches). */
|
|
943
|
+
export function toMarkdownWithImages(roots: readonly UiNode[], images: ReadonlyMap<string, SearchedImage | null>): string {
|
|
944
|
+
searched = images;
|
|
945
|
+
try {
|
|
946
|
+
return toMarkdown(roots);
|
|
947
|
+
} finally {
|
|
948
|
+
searched = new Map();
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/** The image searches in evaluated nodes (ImageSearch queries, Gallery `{query}` items), in order, without repeats. */
|
|
953
|
+
export function imageSearches(roots: readonly UiNode[]): string[] {
|
|
954
|
+
const queries = new Set<string>();
|
|
955
|
+
const visit = (value: Value | undefined): void => {
|
|
956
|
+
if (value === undefined || value === null || typeof value !== "object") return;
|
|
957
|
+
if (Array.isArray(value)) {
|
|
958
|
+
value.forEach(visit);
|
|
959
|
+
} else if (hasKind(value, "node")) {
|
|
960
|
+
if (value.type === "ImageSearch") queries.add(inline(value.props.query).trim());
|
|
961
|
+
if (value.type === "Gallery") {
|
|
962
|
+
for (const item of list(value.props.images)) {
|
|
963
|
+
const image = fields(item, "src");
|
|
964
|
+
if (inline(image.src).trim() === "") queries.add(inline(image.query).trim());
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
Object.values(value.props).forEach(visit);
|
|
968
|
+
} else if (isPlainObject(value)) {
|
|
969
|
+
Object.values(value).forEach(visit);
|
|
970
|
+
}
|
|
971
|
+
};
|
|
972
|
+
roots.forEach(visit);
|
|
973
|
+
queries.delete("");
|
|
974
|
+
return [...queries];
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* A searched photo, then its caption and credit on lines of their own (two
|
|
979
|
+
* trailing spaces break the line). Without a result (none found, lookups off,
|
|
980
|
+
* plain toMarkdown) it is named instead.
|
|
981
|
+
*/
|
|
982
|
+
function searchedImage(query: string, alt: string, caption = ""): string {
|
|
983
|
+
const found = searched.get(query);
|
|
984
|
+
const image = found ? remoteImage(found.url, alt) : null;
|
|
985
|
+
const source = found ? webUrl(found.sourceUrl, ["https:"]) : null;
|
|
986
|
+
if (!found || image === null || source === null) return `🖼 ${caption || alt}`;
|
|
987
|
+
const credit = `${found.creator}, ${found.license}`.replace(/[*_`[\]\\]/g, "\\$&");
|
|
988
|
+
return [image, caption, `_Photo: ${credit}, ${link("Wikimedia Commons", source)}_`].filter((line) => line !== "").join(" \n");
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/** One Gallery item: a path, an https URL, or `{src|query, caption?}`. */
|
|
992
|
+
function galleryImage(item: Value): string {
|
|
993
|
+
const image = fields(item, "src");
|
|
994
|
+
const caption = inline(image.caption).trim();
|
|
995
|
+
const src = inline(image.src).trim();
|
|
996
|
+
const query = inline(image.query).trim();
|
|
997
|
+
const alt = inline(image.alt).trim() || caption || query;
|
|
998
|
+
if (src === "") return query === "" ? "" : searchedImage(query, alt, caption);
|
|
999
|
+
const remote = remoteImage(src, alt);
|
|
1000
|
+
if (remote === null) return `🖼 ${caption === "" ? src : `${caption} (${src})`}`;
|
|
1001
|
+
return caption === "" ? remote : `${remote} \n${caption}`;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** A Map as a numbered list, each place linked on OpenStreetMap: its pin, or a search for its name. */
|
|
1005
|
+
function placeList(places: readonly Value[], route: boolean): string {
|
|
1006
|
+
const named = places.flatMap((item) => {
|
|
1007
|
+
const place = fields(item, "name");
|
|
1008
|
+
const name = inline(place.name).trim();
|
|
1009
|
+
return name === "" ? [] : [{ name, note: inline(place.note).trim(), lat: place.lat, lng: place.lng }];
|
|
1010
|
+
});
|
|
1011
|
+
const lines = named.map(({ name, note, lat, lng }, index) => {
|
|
1012
|
+
const located = finite(lat) && finite(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180;
|
|
1013
|
+
const url = located
|
|
1014
|
+
? new URL(`https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=15/${lat}/${lng}`)
|
|
1015
|
+
: new URL(`https://www.openstreetmap.org/search?query=${encodeURIComponent(name)}`);
|
|
1016
|
+
return `${index + 1}. **${name}**${note === "" ? "" : ` — ${note}`} · ${link("OpenStreetMap", url)}`;
|
|
1017
|
+
});
|
|
1018
|
+
const order = route && named.length > 1 ? `Route: ${named.map((place) => place.name).join(" → ")}` : "";
|
|
1019
|
+
return paragraphs([order, lines.join("\n")]);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* The label a mermaid source gives node `id` (`crank[Crankset]`, `a((Hub))`,
|
|
1024
|
+
* `b{"Ok?"}`), or the id itself.
|
|
1025
|
+
*/
|
|
1026
|
+
function mermaidLabel(source: string, id: string): string {
|
|
1027
|
+
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1028
|
+
const match = new RegExp(`(?:^|[\\s;&>-])${escaped}\\s*[[({>]+\\s*"?([^"\\])}\\n]+?)"?\\s*[\\])}]`, "m").exec(source);
|
|
1029
|
+
return match?.[1]?.trim() || id;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/** The mermaid fence, then each node's detail under the node's label. */
|
|
1033
|
+
function diagramMarkdown(source: string, details: Value | undefined): string {
|
|
1034
|
+
const entries = details !== undefined && isPlainObject(details) ? Object.entries(details) : [];
|
|
1035
|
+
return paragraphs([
|
|
1036
|
+
fence(source, "mermaid"),
|
|
1037
|
+
...entries.map(([id, detail]) => {
|
|
1038
|
+
const parts = (Array.isArray(detail) ? detail : [detail]).map((part) =>
|
|
1039
|
+
hasKind(part, "node") ? block(part) : inline(part).trim(),
|
|
1040
|
+
);
|
|
1041
|
+
const [first = "", ...rest] = parts.filter((part) => part !== "");
|
|
1042
|
+
if (first === "") return "";
|
|
1043
|
+
const label = `**${mermaidLabel(source, id)}**`;
|
|
1044
|
+
// Text joins the label on one line; a component goes under it.
|
|
1045
|
+
const firstIsText = !hasKind(Array.isArray(detail) ? detail[0] : detail, "node");
|
|
1046
|
+
return paragraphs(firstIsText ? [`${label} — ${first}`, ...rest] : [label, first, ...rest]);
|
|
1047
|
+
}),
|
|
1048
|
+
]);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const WEEKDAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
1052
|
+
const MONTH_LONG = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" });
|
|
1053
|
+
|
|
1054
|
+
/** `yyyy-mm-dd` as a UTC midnight, so no timezone moves the day; null for anything else. */
|
|
1055
|
+
function calendarDate(value: Value | undefined): Date | null {
|
|
1056
|
+
const match = typeof value === "string" ? /^\s*(\d{4})-(\d{1,2})-(\d{1,2})(?:$|[T\s])/.exec(value) : null;
|
|
1057
|
+
if (match === null) return null;
|
|
1058
|
+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
1059
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
1060
|
+
return date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? date : null;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** A Calendar as its events by month: `**October 2026**` then `- Fri 9 — RC 1` lines. */
|
|
1064
|
+
function eventCalendarMarkdown(items: readonly Value[], month: string): string {
|
|
1065
|
+
const events = items
|
|
1066
|
+
.flatMap((item) => {
|
|
1067
|
+
if (!isPlainObject(item)) return [];
|
|
1068
|
+
const event = fields(item, "date");
|
|
1069
|
+
const date = calendarDate(event.date);
|
|
1070
|
+
return date === null ? [] : [{ date, title: inline(event.title).trim(), dot: lookup(TONE_DOTS, inline(event.tone)) }];
|
|
1071
|
+
})
|
|
1072
|
+
.sort((a, b) => a.date.getTime() - b.date.getTime());
|
|
1073
|
+
if (events.length === 0) {
|
|
1074
|
+
const shown = calendarDate(`${month.trim()}-01`);
|
|
1075
|
+
return shown === null ? "" : `**${MONTH_LONG.format(shown)}**: no events`;
|
|
1076
|
+
}
|
|
1077
|
+
const months: string[] = [];
|
|
1078
|
+
let current = "";
|
|
1079
|
+
for (const { date, title, dot } of events) {
|
|
1080
|
+
const heading = MONTH_LONG.format(date);
|
|
1081
|
+
if (heading !== current) {
|
|
1082
|
+
months.push(`**${heading}**`);
|
|
1083
|
+
current = heading;
|
|
1084
|
+
}
|
|
1085
|
+
const line = `- ${WEEKDAY_SHORT[date.getUTCDay()]} ${date.getUTCDate()} — ${dot === "" ? "" : `${dot} `}${title || "Event"}`;
|
|
1086
|
+
months[months.length - 1] += `\n${line}`;
|
|
1087
|
+
}
|
|
1088
|
+
return paragraphs(months);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// ── Project components that read BB (Changes, Commits, PullRequest, Threads) ──
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* What BB answered for the rendering in progress, by request key. block() is
|
|
1095
|
+
* synchronous, so toMarkdownWithProject holds them here for one call.
|
|
1096
|
+
*/
|
|
1097
|
+
let projectAnswers: ReadonlyMap<string, ProjectResult> = new Map();
|
|
1098
|
+
|
|
1099
|
+
/** The BB read a Project node needs, or why its props can't be read; null for other nodes. */
|
|
1100
|
+
function projectRequest(node: UiNode): Parsed<ProjectRequest> | null {
|
|
1101
|
+
const props = node.props;
|
|
1102
|
+
switch (node.type) {
|
|
1103
|
+
case "Changes": {
|
|
1104
|
+
const target = parseChangesTarget(props.target);
|
|
1105
|
+
if (!target.ok) return target;
|
|
1106
|
+
const paths = parsePathFilter(props.paths);
|
|
1107
|
+
if (!paths.ok) return paths;
|
|
1108
|
+
return { ok: true, value: { kind: "changes", target: target.value, paths: paths.value } };
|
|
1109
|
+
}
|
|
1110
|
+
case "Commits":
|
|
1111
|
+
return { ok: true, value: { kind: "commits" } };
|
|
1112
|
+
case "PullRequest":
|
|
1113
|
+
return { ok: true, value: { kind: "pullRequest" } };
|
|
1114
|
+
case "Threads": {
|
|
1115
|
+
const ids = parseThreadIds(props.ids);
|
|
1116
|
+
return ids.ok ? { ok: true, value: { kind: "threads", ids: ids.value } } : ids;
|
|
1117
|
+
}
|
|
1118
|
+
case "Environments":
|
|
1119
|
+
return { ok: true, value: { kind: "environments" } };
|
|
1120
|
+
default:
|
|
1121
|
+
return null;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/** The BB reads the evaluated nodes need (Changes, Commits, PullRequest, Threads), in order, without repeats. */
|
|
1126
|
+
export function projectRequests(roots: readonly UiNode[]): ProjectRequest[] {
|
|
1127
|
+
const requests = new Map<string, ProjectRequest>();
|
|
1128
|
+
const visit = (value: Value | undefined): void => {
|
|
1129
|
+
if (value === undefined || value === null || typeof value !== "object") return;
|
|
1130
|
+
if (Array.isArray(value)) {
|
|
1131
|
+
value.forEach(visit);
|
|
1132
|
+
} else if (hasKind(value, "node")) {
|
|
1133
|
+
const request = projectRequest(value);
|
|
1134
|
+
if (request?.ok) requests.set(projectRequestKey(request.value), request.value);
|
|
1135
|
+
Object.values(value.props).forEach(visit);
|
|
1136
|
+
} else if (isPlainObject(value)) {
|
|
1137
|
+
Object.values(value).forEach(visit);
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
1140
|
+
roots.forEach(visit);
|
|
1141
|
+
return [...requests.values()];
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** toMarkdownWithImages with Changes, Commits, PullRequest and Threads shown as what BB answered (`answers`, by `projectRequestKey`). */
|
|
1145
|
+
export function toMarkdownWithProject(
|
|
1146
|
+
roots: readonly UiNode[],
|
|
1147
|
+
images: ReadonlyMap<string, SearchedImage | null>,
|
|
1148
|
+
answers: ReadonlyMap<string, ProjectResult>,
|
|
1149
|
+
): string {
|
|
1150
|
+
projectAnswers = answers;
|
|
1151
|
+
try {
|
|
1152
|
+
return toMarkdownWithImages(roots, images);
|
|
1153
|
+
} finally {
|
|
1154
|
+
projectAnswers = new Map();
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** Each Project component's name, and what it says without an answer from BB (plain toMarkdown, no thread). */
|
|
1159
|
+
const PROJECT_TEXT: Record<string, { readonly title: string; readonly unanswered: string }> = {
|
|
1160
|
+
Changes: { title: "Changes", unanswered: "_The changes in this thread's checkout: open the reply in BB to see them._" },
|
|
1161
|
+
Commits: { title: "Commits", unanswered: "_This branch's commits: open the reply in BB to see them._" },
|
|
1162
|
+
PullRequest: { title: "Pull request", unanswered: "_This branch's pull request: open the reply in BB to see it._" },
|
|
1163
|
+
Threads: { title: "Threads", unanswered: "_Thread status: open the reply in BB to see it._" },
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
/** Rows a markdown list or table shows before saying how many more there are. */
|
|
1167
|
+
const MARKDOWN_ROWS = 50;
|
|
1168
|
+
|
|
1169
|
+
/** "_… and 3 more files_" when rows were left out; `noun` is singular. */
|
|
1170
|
+
function moreLine(shown: number, total: number, noun: string): string {
|
|
1171
|
+
const more = total - shown;
|
|
1172
|
+
return more > 0 ? `_… and ${more} more ${noun}${more === 1 ? "" : "s"}_` : "";
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function projectMarkdown(node: UiNode): string {
|
|
1176
|
+
const text = PROJECT_TEXT[node.type];
|
|
1177
|
+
if (text === undefined) return "";
|
|
1178
|
+
const request = projectRequest(node);
|
|
1179
|
+
if (request === null) return "";
|
|
1180
|
+
if (!request.ok) return `_(error: ${request.error})_`;
|
|
1181
|
+
const answer = projectAnswers.get(projectRequestKey(request.value));
|
|
1182
|
+
if (answer === undefined) return text.unanswered;
|
|
1183
|
+
if (!answer.ok) return `**${text.title}** · _${answer.message}_`;
|
|
1184
|
+
if ("scope" in answer) return changesMarkdown(answer);
|
|
1185
|
+
if ("commits" in answer) {
|
|
1186
|
+
const limit = parseCommitLimit(node.props.limit);
|
|
1187
|
+
return commitsMarkdown(answer, limit.ok ? limit.value : null);
|
|
1188
|
+
}
|
|
1189
|
+
if ("pullRequest" in answer) return pullRequestMarkdown(answer);
|
|
1190
|
+
// Environments renders through its own case in block().
|
|
1191
|
+
if ("environments" in answer) return "";
|
|
1192
|
+
return threadsMarkdown(answer);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function changesMarkdown(view: ChangesView): string {
|
|
1196
|
+
const totals = changeTotals(view.files);
|
|
1197
|
+
const filter = view.filter === null ? "" : ` · paths: ${view.filter.join(", ")} (${totals.files} of ${view.totalFiles} files)`;
|
|
1198
|
+
const summary =
|
|
1199
|
+
totals.files === 0 ? "no changes" : `${totals.files} file${totals.files === 1 ? "" : "s"}, +${totals.additions} −${totals.deletions}`;
|
|
1200
|
+
const header = `**Changes** · ${scopeLabel(view.scope)} · ${summary}${filter}`;
|
|
1201
|
+
if (totals.files === 0) return header;
|
|
1202
|
+
const rows = view.files.slice(0, MARKDOWN_ROWS).map((file) => {
|
|
1203
|
+
const counted = file.diff !== "binary";
|
|
1204
|
+
const kind = `${CHANGE_LABEL[file.kind]}${file.untracked ? ", untracked" : ""}${counted ? "" : ", binary"}`;
|
|
1205
|
+
const from = file.previousPath === null ? "" : ` (from \`${file.previousPath}\`)`;
|
|
1206
|
+
return [`\`${file.path}\`${from}`, kind, counted ? `+${file.additions}` : "—", counted ? `−${file.deletions}` : "—"];
|
|
1207
|
+
});
|
|
1208
|
+
return paragraphs([
|
|
1209
|
+
header,
|
|
1210
|
+
table(["File", "Change", "+", "−"], rows),
|
|
1211
|
+
moreLine(rows.length, view.files.length, "file"),
|
|
1212
|
+
view.listTruncated ? "_BB listed only part of the changed files._" : "",
|
|
1213
|
+
]);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function commitsMarkdown(view: CommitsView, limit: number | null): string {
|
|
1217
|
+
const where =
|
|
1218
|
+
view.onBase || view.ahead === null
|
|
1219
|
+
? (view.branch ?? "detached HEAD")
|
|
1220
|
+
: `${view.branch ?? "detached HEAD"} · ${view.ahead} ahead, ${view.behind} behind ${view.base}`;
|
|
1221
|
+
const header = `**Commits** · ${where}`;
|
|
1222
|
+
if (view.onBase) return `${header} · _this is the default branch: no branch commits_`;
|
|
1223
|
+
if (view.commits.length === 0) return `${header} · no commits since ${view.base}`;
|
|
1224
|
+
const shown = view.commits.slice(0, limit ?? MARKDOWN_ROWS);
|
|
1225
|
+
const lines = shown.map(
|
|
1226
|
+
(commit) => `- \`${commit.shortSha}\` ${commit.subject} — ${commit.author}, ${relativeTime(commit.authoredAt, view.takenAt)}`,
|
|
1227
|
+
);
|
|
1228
|
+
return paragraphs([header, lines.join("\n"), moreLine(shown.length, view.commits.length, "commit")]);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function pullRequestMarkdown(view: PullRequestView): string {
|
|
1232
|
+
const pr = view.pullRequest;
|
|
1233
|
+
if (pr === null) return `**Pull request** · ${view.branch === null ? "none" : `none for ${view.branch}`}`;
|
|
1234
|
+
const url = webUrl(pr.url, ["https:"]);
|
|
1235
|
+
const label = `#${pr.number} ${pr.title}`;
|
|
1236
|
+
const counts = checkCounts(pr.checks);
|
|
1237
|
+
const attention = ATTENTION_LABEL[pr.attention];
|
|
1238
|
+
return [
|
|
1239
|
+
`**Pull request** ${url === null ? `**${label}**` : link(label, url)}`,
|
|
1240
|
+
PR_STATE_LABEL[pr.state],
|
|
1241
|
+
`${pr.baseRef} ← ${pr.headRef}`,
|
|
1242
|
+
`${CHECKS_LABEL[pr.checks.state]}${counts === "" ? "" : ` (${counts})`}`,
|
|
1243
|
+
REVIEW_LABEL[pr.review.state],
|
|
1244
|
+
MERGEABILITY_LABEL[pr.mergeability],
|
|
1245
|
+
attention === null ? "" : `needs: ${attention}`,
|
|
1246
|
+
]
|
|
1247
|
+
.filter((part) => part !== "")
|
|
1248
|
+
.join(" · ");
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function threadsMarkdown(view: ThreadsView): string {
|
|
1252
|
+
const header = view.mode === "subthreads" ? `**Subthreads** (${view.threads.length}${view.truncated ? "+" : ""})` : "**Threads**";
|
|
1253
|
+
if (view.threads.length === 0) return `${header} · none`;
|
|
1254
|
+
const shown = view.threads.slice(0, MARKDOWN_ROWS);
|
|
1255
|
+
const lines = shown.map((thread) => {
|
|
1256
|
+
if (!thread.found) return `- \`${thread.id}\` — not found in this project`;
|
|
1257
|
+
const agent = [thread.provider.name ?? thread.provider.id, thread.model, thread.reasoning === null ? null : `${thread.reasoning} reasoning`]
|
|
1258
|
+
.filter((part) => part !== null)
|
|
1259
|
+
.join(", ");
|
|
1260
|
+
const row = [`- **${thread.title}** — ${threadActivityLabel(thread)} · ${agent} · ${relativeTime(thread.updatedAt, view.fetchedAt)}`];
|
|
1261
|
+
const pending = thread.pending;
|
|
1262
|
+
if (pending !== null) {
|
|
1263
|
+
const asked = pending.text === null ? "" : pending.code && !pending.text.includes("`") ? `: \`${pending.text}\`` : `: ${plainMarkdown(pending.text)}`;
|
|
1264
|
+
const options = pending.options.length === 0 ? "" : ` (${pending.options.map(plainMarkdown).join(" / ")})`;
|
|
1265
|
+
row.push(` - Needs you: ${pending.action}${asked}${options}${pending.more > 0 ? ` · ${pending.more} more waiting` : ""}`);
|
|
1266
|
+
}
|
|
1267
|
+
if (thread.lastMessage !== null) row.push(` - Latest message: ${plainMarkdown(previewText(thread.lastMessage.text))}`);
|
|
1268
|
+
return row.join("\n");
|
|
1269
|
+
});
|
|
1270
|
+
return paragraphs([header, lines.join("\n"), moreLine(shown.length, view.threads.length, "thread")]);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/** Failures a Tests rendering lists, and the lines and characters of each message it keeps. */
|
|
1274
|
+
const TEST_FAILURES = 20;
|
|
1275
|
+
const TEST_MESSAGE_LINES = 12;
|
|
1276
|
+
const TEST_MESSAGE_CHARS = 1_500;
|
|
1277
|
+
|
|
1278
|
+
/** A test run: a summary line, then each failure with where it is and its message (ANSI codes stripped), first lines only. */
|
|
1279
|
+
function testsMarkdown(results: Value | undefined, title: string): string {
|
|
1280
|
+
const read = readTestRun(results);
|
|
1281
|
+
if (!read.ok) return `_(${read.error})_`;
|
|
1282
|
+
const { tests, counts, ms } = read.run;
|
|
1283
|
+
const dots = [
|
|
1284
|
+
counts.failed > 0 ? `🔴 ${counts.failed} failed` : "",
|
|
1285
|
+
counts.passed > 0 ? `🟢 ${counts.passed} passed` : "",
|
|
1286
|
+
counts.skipped > 0 ? `⚪ ${counts.skipped} skipped` : "",
|
|
1287
|
+
].filter((part) => part !== "");
|
|
1288
|
+
const summary = tests.length === 0 ? "No tests ran." : [...dots, ms === null ? "" : formatDuration(ms)].filter((part) => part !== "").join(" · ");
|
|
1289
|
+
const failures = tests.filter((test) => test.status === "failed");
|
|
1290
|
+
const common = commonDirectory(tests.flatMap((test) => (test.file === null ? [] : [test.file])));
|
|
1291
|
+
const items = failures.slice(0, TEST_FAILURES).map((test, index) => {
|
|
1292
|
+
const where = test.file === null ? "" : ` — \`${displayPath(test.file, null, common)}${test.line === null ? "" : `:${test.line}`}\``;
|
|
1293
|
+
const head = `${index + 1}. **${test.name.replace(/[*_`[\]\\]/g, "\\$&")}**${test.detail === null ? "" : ` (${test.detail})`}${where}`;
|
|
1294
|
+
if (test.message === null) return head;
|
|
1295
|
+
const lines = stripAnsi(test.message).replace(/\s+$/, "").split("\n");
|
|
1296
|
+
let kept = lines.slice(0, TEST_MESSAGE_LINES).join("\n");
|
|
1297
|
+
if (kept.length > TEST_MESSAGE_CHARS) kept = `${kept.slice(0, TEST_MESSAGE_CHARS)}…`;
|
|
1298
|
+
else if (lines.length > TEST_MESSAGE_LINES) kept += "\n…";
|
|
1299
|
+
return `${head}\n${fence(kept, "").replace(/^/gm, " ")}`;
|
|
1300
|
+
});
|
|
1301
|
+
const rest = failures.length - items.length;
|
|
1302
|
+
return paragraphs([
|
|
1303
|
+
title === "" ? summary : `**${title}** — ${summary}`,
|
|
1304
|
+
items.length === 0 ? "" : `Failures:\n${items.join("\n")}`,
|
|
1305
|
+
rest > 0 ? `…and ${rest} more ${rest === 1 ? "failure" : "failures"}` : "",
|
|
1306
|
+
]);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/** Lines of pretty-printed JSON a Json rendering keeps. */
|
|
1310
|
+
const JSON_LINES = 60;
|
|
1311
|
+
|
|
1312
|
+
/** A value as a pretty-printed json fence, cut at JSON_LINES with a note of how much was left out. */
|
|
1313
|
+
function jsonMarkdown(value: Value, label: string): string {
|
|
1314
|
+
const lines = (JSON.stringify(value, null, 2) ?? "null").split("\n");
|
|
1315
|
+
const kept = lines.length > JSON_LINES ? [...lines.slice(0, JSON_LINES), "…"] : lines;
|
|
1316
|
+
return paragraphs([
|
|
1317
|
+
label === "" ? "" : `**${label}**`,
|
|
1318
|
+
fence(kept.join("\n"), "json"),
|
|
1319
|
+
lines.length > JSON_LINES ? `_(first ${JSON_LINES} of ${lines.length} lines)_` : "",
|
|
1320
|
+
]);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
const RISK_TEXT: Record<string, string> = { low: "low risk", medium: "medium risk", high: "**high risk**" };
|
|
1324
|
+
|
|
1325
|
+
/** A Plan as a numbered list: each step with its risk and estimate, detail and files below; the total and the approve label. */
|
|
1326
|
+
function planMarkdown(value: Value | undefined, submit: string): string {
|
|
1327
|
+
const { steps, error } = readPlan(value);
|
|
1328
|
+
if (error !== null) return `_(${error})_`;
|
|
1329
|
+
if (steps.length === 0) return "**Plan** · no steps";
|
|
1330
|
+
const { total, unparsed, mixed } = planTotal(steps, initialChecks(steps));
|
|
1331
|
+
const size = total === null ? "" : ` · ${formatQuantity(total)}${unparsed > 0 ? ` + ${unparsed} unsized` : ""}${steps.some((step) => step.optional) ? " without optional steps" : ""}`;
|
|
1332
|
+
const lines = steps.slice(0, MARKDOWN_ROWS).flatMap((step, index) => {
|
|
1333
|
+
const meta = [step.risk === null ? "" : RISK_TEXT[step.risk], step.estimate === null ? "" : step.amount === null ? step.estimate : formatQuantity(step.amount)]
|
|
1334
|
+
.filter((part) => part !== "")
|
|
1335
|
+
.join(" · ");
|
|
1336
|
+
return [
|
|
1337
|
+
`${index + 1}. ${step.optional ? "_(optional)_ " : ""}${step.title}${meta === "" ? "" : ` · ${meta}`}`,
|
|
1338
|
+
...(step.detail === null ? [] : [` ${step.detail.replace(/\s*\r?\n\s*/g, " ")}`]),
|
|
1339
|
+
...(step.files.length === 0 ? [] : [` ${step.files.map((file) => `\`${file.path}${file.line === null ? "" : `:${file.line}`}\``).join(" ")}`]),
|
|
1340
|
+
];
|
|
1341
|
+
});
|
|
1342
|
+
return paragraphs([
|
|
1343
|
+
`**Plan** · ${steps.length} ${steps.length === 1 ? "step" : "steps"}${size}${mixed ? " · estimates mix points and time" : ""}`,
|
|
1344
|
+
lines.join("\n"),
|
|
1345
|
+
moreLine(Math.min(steps.length, MARKDOWN_ROWS), steps.length, "step"),
|
|
1346
|
+
`Submit: ${submit.trim() || "Approve plan"}`,
|
|
1347
|
+
]);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/** A Decision as its verdict, the score matrix with weights and weighted scores (spec weights), pros and cons, and the options to send. */
|
|
1351
|
+
function decisionMarkdown(options: Value | undefined, criteria: Value | undefined): string {
|
|
1352
|
+
const read = readDecision(options, criteria);
|
|
1353
|
+
if (!read.ok) return `_(${read.error})_`;
|
|
1354
|
+
const model = read.model;
|
|
1355
|
+
if (model.options.length === 0) return "**Decision** · no options";
|
|
1356
|
+
const weights = model.criteria.map((criterion) => criterion.weight);
|
|
1357
|
+
const ranked = rankOptions(model, weights);
|
|
1358
|
+
const lead = leaderMargin(ranked);
|
|
1359
|
+
const score = (total: number) => String(round(total, 2));
|
|
1360
|
+
const top = ranked[0];
|
|
1361
|
+
const leader = model.options[top.index].name;
|
|
1362
|
+
const verdict =
|
|
1363
|
+
lead !== null && lead.tied.length > 0
|
|
1364
|
+
? `${joinAnd([leader, ...lead.tied.map((index) => model.options[index].name)])} are tied at ${score(top.total)} of ${model.scale}`
|
|
1365
|
+
: `${leader} leads with ${score(top.total)} of ${model.scale}${ranked.length > 1 ? `, ${score(lead?.margin ?? 0)} ahead of ${model.options[ranked[1].index].name}` : ""}`;
|
|
1366
|
+
const rows: Value[] = [
|
|
1367
|
+
...model.criteria.map((criterion, index): Value => [
|
|
1368
|
+
criterion.name,
|
|
1369
|
+
formatAmount(criterion.weight),
|
|
1370
|
+
...model.options.map((option) => (option.scores[index] === null ? "—" : formatAmount(option.scores[index] ?? 0))),
|
|
1371
|
+
]),
|
|
1372
|
+
["**Weighted score**", "", ...model.options.map((_, index) => {
|
|
1373
|
+
const entry = ranked.find((candidate) => candidate.index === index);
|
|
1374
|
+
return entry === undefined ? "" : index === top.index ? `**${score(entry.total)}**` : score(entry.total);
|
|
1375
|
+
})],
|
|
1376
|
+
];
|
|
1377
|
+
const notes = model.options.flatMap((option) => {
|
|
1378
|
+
const parts = [...option.pros.map((pro) => `+ ${pro}`), ...option.cons.map((con) => `− ${con}`), ...(option.note === null ? [] : [option.note])];
|
|
1379
|
+
return parts.length === 0 ? [] : [`- **${option.name}**: ${parts.join("; ")}`];
|
|
1380
|
+
});
|
|
1381
|
+
return paragraphs([
|
|
1382
|
+
`**Decision** · ${verdict}`,
|
|
1383
|
+
table(["Criterion", "Weight", ...model.options.map((option) => option.name)], rows),
|
|
1384
|
+
notes.join("\n"),
|
|
1385
|
+
model.issues.map((issue) => `_${issue}_`).join("\n"),
|
|
1386
|
+
`Options: ${ranked.map((entry) => `Go with ${model.options[entry.index].name}`).join(" · ")}`,
|
|
1387
|
+
]);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/** An Estimate as its totals line (PERT mean, P50/P80/P90, range) and a table of the tasks; invalid rows say why. */
|
|
1391
|
+
function estimateMarkdown(value: Value | undefined, unitValue: Value | undefined): string {
|
|
1392
|
+
const read = readEstimate(value);
|
|
1393
|
+
if (read.error !== null) return `_(${read.error})_`;
|
|
1394
|
+
if (read.tasks.length === 0) return "**Estimate** · no tasks";
|
|
1395
|
+
const unit = estimateUnit(unitValue);
|
|
1396
|
+
const estimates = read.tasks.map(estimateTask);
|
|
1397
|
+
const totals = estimateTotals(estimates);
|
|
1398
|
+
const amount = (number: number | null) => (number === null ? "—" : formatAmount(number));
|
|
1399
|
+
const summary =
|
|
1400
|
+
totals.count === 0
|
|
1401
|
+
? "no task has a usable low and high"
|
|
1402
|
+
: [
|
|
1403
|
+
`expected **${formatWithUnit(totals.mean, unit)}**`,
|
|
1404
|
+
`P50 ${formatWithUnit(totals.p50, unit)}`,
|
|
1405
|
+
`P80 ${formatWithUnit(totals.p80, unit)}`,
|
|
1406
|
+
`P90 ${formatWithUnit(totals.p90, unit)}`,
|
|
1407
|
+
`range ${formatAmount(totals.low)}–${formatWithUnit(totals.high, unit)}`,
|
|
1408
|
+
].join(" · ");
|
|
1409
|
+
const shown = estimates.slice(0, MARKDOWN_ROWS);
|
|
1410
|
+
return paragraphs([
|
|
1411
|
+
`**Estimate** · ${read.tasks.length} ${read.tasks.length === 1 ? "task" : "tasks"} · ${summary}`,
|
|
1412
|
+
table(
|
|
1413
|
+
["Task", `Low (${unit})`, "Likely", "High", "PERT mean"],
|
|
1414
|
+
shown.map((estimate): Value => [
|
|
1415
|
+
estimate.task.title,
|
|
1416
|
+
amount(estimate.task.low),
|
|
1417
|
+
estimate.task.likely === null && estimate.error === null ? `(${amount(estimate.likely)})` : amount(estimate.task.likely),
|
|
1418
|
+
amount(estimate.task.high),
|
|
1419
|
+
estimate.error === null ? formatAmount(estimate.mean) : `⚠ not counted: ${estimate.error}`,
|
|
1420
|
+
]),
|
|
1421
|
+
),
|
|
1422
|
+
moreLine(shown.length, estimates.length, "task"),
|
|
1423
|
+
]);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// ── Environments ────────────────────────────────────────────────────────────
|
|
1427
|
+
|
|
1428
|
+
/** Thread lines an environment lists under it in markdown. */
|
|
1429
|
+
const ENVIRONMENT_THREAD_LINES = 3;
|
|
1430
|
+
|
|
1431
|
+
/** The project's environments as BB answered (the default view: those with live threads), each with its git line and threads. */
|
|
1432
|
+
function environmentsMarkdown(): string {
|
|
1433
|
+
const answer = projectAnswers.get(projectRequestKey({ kind: "environments" }));
|
|
1434
|
+
if (answer === undefined) return "_This project's environments, their threads and changes: open the reply in BB to see them._";
|
|
1435
|
+
if (!answer.ok) return `**Environments** · _${answer.message}_`;
|
|
1436
|
+
if (!("environments" in answer)) return "";
|
|
1437
|
+
const all = answer.environments;
|
|
1438
|
+
if (all.length === 0) return "**Environments** · none in this project";
|
|
1439
|
+
const { shown, filtered } = defaultEnvironments(all);
|
|
1440
|
+
const running = all.reduce((sum, environment) => sum + environment.threadCounts.running, 0);
|
|
1441
|
+
const header = [
|
|
1442
|
+
"**Environments**",
|
|
1443
|
+
filtered ? `${shown.length} with live threads of ${all.length}` : `${all.length}`,
|
|
1444
|
+
running > 0 ? `${running} running` : "",
|
|
1445
|
+
]
|
|
1446
|
+
.filter((part) => part !== "")
|
|
1447
|
+
.join(" · ");
|
|
1448
|
+
const rows = shown.slice(0, MARKDOWN_ROWS);
|
|
1449
|
+
const items = rows.map((environment) => environmentItem(environment, answer.fetchedAt));
|
|
1450
|
+
const left = all.length - rows.length;
|
|
1451
|
+
return paragraphs([
|
|
1452
|
+
header,
|
|
1453
|
+
items.join("\n"),
|
|
1454
|
+
left > 0 ? `_… and ${left} more environment${left === 1 ? "" : "s"}${filtered ? " without live threads" : ""}_` : "",
|
|
1455
|
+
]);
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function environmentItem(environment: EnvironmentInfo, now: number): string {
|
|
1459
|
+
const title = environmentTitle(environment);
|
|
1460
|
+
const life = LIFE_LABEL[environment.life];
|
|
1461
|
+
const kind = [kindLabel(environment), environment.current ? "this thread" : "", life ?? ""].filter((part) => part !== "").join(", ");
|
|
1462
|
+
const parts = [
|
|
1463
|
+
`**${title.replace(/[*_`[\]\\]/g, "\\$&")}** (${kind})`,
|
|
1464
|
+
...environmentGit(environment),
|
|
1465
|
+
threadSummary(environment),
|
|
1466
|
+
environment.activeAt === null ? "" : relativeTime(environment.activeAt, now),
|
|
1467
|
+
];
|
|
1468
|
+
const live = liveThreads(environment).slice(0, ENVIRONMENT_THREAD_LINES);
|
|
1469
|
+
const threads = live.map((thread) => ` - ${thread.subthread ? "↳ " : ""}${thread.title.replace(/[*_`[\]\\]/g, "\\$&")} — ${threadStatusLabel(thread)}`);
|
|
1470
|
+
return [`- ${parts.filter((part) => part !== "").join(" · ")}`, ...threads].join("\n");
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** The git facts of an environment's detail: changes, position, pull request; or why they couldn't be read. */
|
|
1474
|
+
function environmentGit(environment: EnvironmentInfo): string[] {
|
|
1475
|
+
const detail = environment.detail;
|
|
1476
|
+
if (detail === null) return [];
|
|
1477
|
+
const checkout = detail.checkout;
|
|
1478
|
+
if (!checkout.ok) return [`_${checkout.message}_${checkout.path === null ? "" : ` (\`${checkout.path}\`)`}`];
|
|
1479
|
+
const position =
|
|
1480
|
+
checkout.checkout === "detached"
|
|
1481
|
+
? `detached ${checkout.head ?? ""}`.trim()
|
|
1482
|
+
: checkout.onBase
|
|
1483
|
+
? checkout.base === checkout.defaultBranch
|
|
1484
|
+
? "default branch"
|
|
1485
|
+
: `on base ${checkout.base}`
|
|
1486
|
+
: aheadBehindLabel(checkout);
|
|
1487
|
+
const pull = detail.pull;
|
|
1488
|
+
let pr = "";
|
|
1489
|
+
if (pull !== null && !pull.ok) pr = "pull request unknown";
|
|
1490
|
+
else if (pull !== null && pull.pullRequest === null) pr = "no pull request";
|
|
1491
|
+
else if (pull !== null && pull.pullRequest !== null) {
|
|
1492
|
+
const request = pull.pullRequest;
|
|
1493
|
+
const url = webUrl(request.url, ["https:"]);
|
|
1494
|
+
const label = `#${request.number}`;
|
|
1495
|
+
const checks = request.state === "open" || request.state === "draft" ? `, ${CHECKS_LABEL[request.checks.state].toLowerCase()}` : "";
|
|
1496
|
+
pr = `PR ${url === null ? label : link(label, url)} ${PR_STATE_LABEL[request.state]}${checks}`;
|
|
1497
|
+
}
|
|
1498
|
+
return [uncommittedLabel(checkout), position, pr];
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// ---------------------------------------------------------------------------
|
|
1502
|
+
// Findings and Coverage
|
|
1503
|
+
|
|
1504
|
+
const SEVERITY_DOTS: Record<Severity, string> = { critical: "🔴", high: "🔴", medium: "🟠", low: "🔵", info: "⚪" };
|
|
1505
|
+
|
|
1506
|
+
/** Markdown emphasis characters escaped outside `code spans`, so tool messages read as written. */
|
|
1507
|
+
function escapeProse(text: string): string {
|
|
1508
|
+
return text
|
|
1509
|
+
.split(/(`[^`\n]*`)/)
|
|
1510
|
+
.map((part, index) => (index % 2 === 1 ? part : part.replace(/[*_[\]\\<>]/g, "\\$&")))
|
|
1511
|
+
.join("");
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/**
|
|
1515
|
+
* Findings as `file:line severity title (rule)` lines, most severe first, under a count per severity; a review leads
|
|
1516
|
+
* with its verdict and what is open, counts per status, and says where each finding stands.
|
|
1517
|
+
*/
|
|
1518
|
+
function findingsMarkdown(items: Value | undefined, title: string): string {
|
|
1519
|
+
const read = readFindings(items);
|
|
1520
|
+
if (!read.ok) return `_(${read.error})_`;
|
|
1521
|
+
const { findings, scale, tool, review } = read.report;
|
|
1522
|
+
const counts = countSeverities(findings);
|
|
1523
|
+
const summary =
|
|
1524
|
+
findings.length === 0
|
|
1525
|
+
? "No findings."
|
|
1526
|
+
: SEVERITIES.filter((severity) => counts[severity] > 0)
|
|
1527
|
+
.map((severity) => `${SEVERITY_DOTS[severity]} ${severityCount(severity, scale, counts[severity])}`)
|
|
1528
|
+
.join(" · ");
|
|
1529
|
+
const subject = review?.subject == null ? null : `${review.subject} review`;
|
|
1530
|
+
const heading = title !== "" ? title : (subject ?? (findings.length === 1 ? "1 finding" : `${findings.length} findings`));
|
|
1531
|
+
const header = [`**${heading}**`, summary, tool ?? ""].filter((part) => part !== "").join(" — ");
|
|
1532
|
+
const verdict = verdictHeadline(read.report);
|
|
1533
|
+
const verdictLine = verdict === null ? "" : `${{ good: "🟢", warn: "🟠", bad: "🔴" }[verdict.tone]} **${verdict.text}**`;
|
|
1534
|
+
const statuses = statusCounts(findings)
|
|
1535
|
+
.map((entry) => entry.text)
|
|
1536
|
+
.join(" · ");
|
|
1537
|
+
const common = commonDirectory(findings.flatMap((item) => (item.file === null ? [] : [item.file])));
|
|
1538
|
+
const sorted = sortFindings(findings);
|
|
1539
|
+
const lines = sorted.slice(0, MARKDOWN_ROWS).map((item) => {
|
|
1540
|
+
const where = item.file === null ? "" : `\`${displayPath(item.file, null, common)}${item.line === null ? "" : `:${item.line}${item.column === null ? "" : `:${item.column}`}`}\` `;
|
|
1541
|
+
const url = item.url === null ? null : webUrl(item.url, ["https:", "http:"]);
|
|
1542
|
+
const rule = item.rule === null ? "" : ` (${url === null ? `\`${item.rule}\`` : link(item.rule, url)})`;
|
|
1543
|
+
const status = item.status === null ? "" : ` — _${escapeProse(statusSentence(item))}_`;
|
|
1544
|
+
return `- ${where}${severityLabel(item.severity, scale)}: ${escapeProse(item.title)}${rule}${status}`;
|
|
1545
|
+
});
|
|
1546
|
+
return paragraphs([header, verdictLine, statuses, lines.join("\n"), moreLine(lines.length, sorted.length, "finding")]);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/** Uncovered ranges a Coverage row lists before "…". */
|
|
1550
|
+
const COVERAGE_RANGES = 8;
|
|
1551
|
+
|
|
1552
|
+
/** The headline percentage with its tone, the other metrics, then the files with uncovered lines, most first. */
|
|
1553
|
+
function coverageMarkdown(report: Value | undefined): string {
|
|
1554
|
+
const read = readCoverage(report);
|
|
1555
|
+
if (!read.ok) return `_(${read.error})_`;
|
|
1556
|
+
const { total, unit, files } = read.report;
|
|
1557
|
+
const main = headline(total, unit);
|
|
1558
|
+
const value = percent(main);
|
|
1559
|
+
const tones: Record<string, string> = { good: "🟢", warn: "🟠", bad: "🔴" };
|
|
1560
|
+
const lead =
|
|
1561
|
+
value === null || main === null
|
|
1562
|
+
? `**Coverage** — nothing to cover`
|
|
1563
|
+
: `**Coverage** — ${tones[coverageTone(value)]} **${formatPercent(value)}** of ${unit} (${main.covered.toLocaleString("en-US")} of ${main.total.toLocaleString("en-US")})`;
|
|
1564
|
+
const others = (["statements", "branches", "functions"] as const)
|
|
1565
|
+
.filter((metric) => !(metric === "statements" && unit === "statements"))
|
|
1566
|
+
.flatMap((metric) => {
|
|
1567
|
+
const share = percent(total[metric]);
|
|
1568
|
+
return share === null ? [] : [`${metric} ${formatPercent(share)}`];
|
|
1569
|
+
});
|
|
1570
|
+
const common = commonDirectory(files.map((file) => file.path));
|
|
1571
|
+
const missing = mostUncovered(files, unit).filter((file) => file.missed > 0);
|
|
1572
|
+
const shown = missing.slice(0, MARKDOWN_ROWS);
|
|
1573
|
+
const rows = shown.map((file) => {
|
|
1574
|
+
const share = percent(headline(file, unit));
|
|
1575
|
+
const ranges =
|
|
1576
|
+
file.uncovered === null
|
|
1577
|
+
? ""
|
|
1578
|
+
: `: ${file.uncovered.slice(0, COVERAGE_RANGES).map(rangeLabel).join(", ")}${file.uncovered.length > COVERAGE_RANGES ? ", …" : ""}`;
|
|
1579
|
+
return [`\`${displayPath(file.path, null, common)}\``, share === null ? "—" : formatPercent(share), `${file.missed.toLocaleString("en-US")}${ranges}`];
|
|
1580
|
+
});
|
|
1581
|
+
const covered = files.length - missing.length;
|
|
1582
|
+
return paragraphs([
|
|
1583
|
+
[lead, ...others].join(" · "),
|
|
1584
|
+
rows.length === 0 ? "" : table(["File", unit === "lines" ? "Lines" : "Statements", `Uncovered ${unit}`], rows),
|
|
1585
|
+
moreLine(shown.length, missing.length, "file"),
|
|
1586
|
+
covered > 0 && missing.length > 0 ? `_${covered} more ${covered === 1 ? "file" : "files"} fully covered_` : "",
|
|
1587
|
+
]);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/** Cards listed per Board column before "… and N more". */
|
|
1591
|
+
const BOARD_COLUMN_ROWS = 20;
|
|
1592
|
+
|
|
1593
|
+
const BOARD_TITLE: Record<string, [string, string, string]> = {
|
|
1594
|
+
list: ["Board", "item", "items"],
|
|
1595
|
+
tasks: ["Tasks", "task", "tasks"],
|
|
1596
|
+
issues: ["Issues", "issue", "issues"],
|
|
1597
|
+
pulls: ["Pull requests", "pull request", "pull requests"],
|
|
1598
|
+
};
|
|
1599
|
+
|
|
1600
|
+
/** One card: `` `KEY` title `` (linked when it has a URL), then priority, owners, labels, due and live agents. */
|
|
1601
|
+
function boardCardLine(card: BoardCard, today: number): string {
|
|
1602
|
+
const url = card.url === null ? null : webUrl(card.url, ["http:", "https:"]);
|
|
1603
|
+
const parts: string[] = [];
|
|
1604
|
+
if (card.priority !== null && card.priority !== "none") parts.push(card.priority === "urgent" ? "‼️ urgent" : card.priority);
|
|
1605
|
+
if (card.owners.length > 0) parts.push(card.owners.join(", "));
|
|
1606
|
+
if (card.labels.length > 0) parts.push(card.labels.map((label) => label.name).join(", "));
|
|
1607
|
+
if (card.due !== null) {
|
|
1608
|
+
parts.push(cardLate(card, today) ? `**due ${formatDay(card.due, today)} (${relativeDue(card.due, today).text})**` : `due ${formatDay(card.due, today)}`);
|
|
1609
|
+
}
|
|
1610
|
+
if (card.parent !== null) parts.push(`sub-task of ${card.parent}`);
|
|
1611
|
+
if (card.agents > 0) parts.push(`🟢 ${card.agents} ${card.agents === 1 ? "agent" : "agents"} working`);
|
|
1612
|
+
const title = url === null ? card.title : link(card.title, url);
|
|
1613
|
+
return `- ${card.key === null ? "" : `\`${card.key}\` `}${title}${parts.length === 0 ? "" : ` · ${parts.join(" · ")}`}`;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/** A Board as a list per column, in the board's column order, with counts. */
|
|
1617
|
+
function boardMarkdown(items: Value | undefined, columns: Value | undefined, today: number): string {
|
|
1618
|
+
const read = readBoard(items, columns);
|
|
1619
|
+
if (!read.ok) return `_(${read.error})_`;
|
|
1620
|
+
const board = read.value;
|
|
1621
|
+
const [title, one, many] = BOARD_TITLE[board.source]!;
|
|
1622
|
+
const notes = [
|
|
1623
|
+
`${board.total}${board.truncated ? "+" : ""} ${board.total === 1 ? one : many}`,
|
|
1624
|
+
board.hidden > 0 ? `${board.hidden} in other columns not shown` : "",
|
|
1625
|
+
board.skipped > 0 ? `${board.skipped} without a title left out` : "",
|
|
1626
|
+
].filter((note) => note !== "");
|
|
1627
|
+
return paragraphs([
|
|
1628
|
+
`**${title}** · ${notes.join(" · ")}`,
|
|
1629
|
+
...board.columns.map((column) => {
|
|
1630
|
+
const shown = column.cards.slice(0, BOARD_COLUMN_ROWS);
|
|
1631
|
+
return [
|
|
1632
|
+
`**${column.label}** (${column.cards.length})`,
|
|
1633
|
+
...(shown.length === 0 ? ["_none_"] : shown.map((card) => boardCardLine(card, today))),
|
|
1634
|
+
...(shown.length < column.cards.length ? [moreLine(shown.length, column.cards.length, one)] : []),
|
|
1635
|
+
].join("\n");
|
|
1636
|
+
}),
|
|
1637
|
+
]);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
const ROADMAP_MARKS: Record<RoadmapStatus, string> = { done: "✅", active: "🔵", planned: "⚪", blocked: "⛔", "at-risk": "🟠" };
|
|
1641
|
+
|
|
1642
|
+
/** A Roadmap as its lanes, each item with its dates, status, progress, owner and what it comes after; late items marked. */
|
|
1643
|
+
function roadmapMarkdown(items: Value | undefined, today: number): string {
|
|
1644
|
+
const read = readRoadmap(items, today);
|
|
1645
|
+
if (!read.ok) return `_(${read.error})_`;
|
|
1646
|
+
const roadmap = read.value;
|
|
1647
|
+
if (roadmap.items.length === 0) return "**Roadmap** · no items";
|
|
1648
|
+
const first = Math.min(...roadmap.items.map((item) => item.start));
|
|
1649
|
+
const last = Math.max(...roadmap.items.map((item) => item.end));
|
|
1650
|
+
const late = roadmap.items.filter((item) => item.late).length;
|
|
1651
|
+
const header = [
|
|
1652
|
+
`**Roadmap** · ${formatRange(first, last, today)}`,
|
|
1653
|
+
`${roadmap.items.length} ${roadmap.items.length === 1 ? "item" : "items"}`,
|
|
1654
|
+
late > 0 ? `${late} late` : "",
|
|
1655
|
+
].filter((part) => part !== "");
|
|
1656
|
+
const byIndex = new Map(roadmap.items.map((item) => [item.index, item]));
|
|
1657
|
+
const lanes = roadmap.lanes.map((lane) => {
|
|
1658
|
+
const lines = lane.items.map((item) => {
|
|
1659
|
+
const when = item.milestone
|
|
1660
|
+
? `${formatDay(item.start, today)} (milestone)`
|
|
1661
|
+
: `${formatRange(item.start, item.end, today)} (${formatDays(itemDays(item))})`;
|
|
1662
|
+
const parts = [when];
|
|
1663
|
+
if (!item.milestone) parts.push(ROADMAP_STATUS_LABEL[item.status].toLowerCase());
|
|
1664
|
+
if (item.progress !== null && !item.milestone && item.status !== "done") parts.push(`${Math.round(item.progress)}%`);
|
|
1665
|
+
if (item.owner !== null) parts.push(item.owner);
|
|
1666
|
+
if (item.after !== null) {
|
|
1667
|
+
const before = item.afterIndex === null ? undefined : byIndex.get(item.afterIndex);
|
|
1668
|
+
parts.push(before === undefined ? `after ${item.after}` : `after ${before.title}${item.overlap ? " (overlaps it)" : ""}`);
|
|
1669
|
+
}
|
|
1670
|
+
if (item.late) parts.push(`**late: ${item.milestone ? "was due" : "ended"} ${formatDay(item.end, today)}**`);
|
|
1671
|
+
return `- ${item.milestone ? "◆" : ROADMAP_MARKS[item.status]} ${item.title} — ${parts.join(" · ")}`;
|
|
1672
|
+
});
|
|
1673
|
+
return [...(lane.name === "" ? [] : [`**${lane.name}**`]), ...lines].join("\n");
|
|
1674
|
+
});
|
|
1675
|
+
const skipped =
|
|
1676
|
+
roadmap.skipped.length === 0 ? "" : `_Left out (no usable dates): ${roadmap.skipped.map((skip) => `${skip.title} (${skip.reason})`).join(", ")}_`;
|
|
1677
|
+
return paragraphs([header.join(" · "), ...lanes, skipped]);
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
const HEALTH_MARKS: Record<Health, string> = {
|
|
1681
|
+
"off-track": "🔴",
|
|
1682
|
+
blocked: "⛔",
|
|
1683
|
+
"at-risk": "🟠",
|
|
1684
|
+
"on-track": "🟢",
|
|
1685
|
+
"not-started": "⚪",
|
|
1686
|
+
done: "✅",
|
|
1687
|
+
};
|
|
1688
|
+
|
|
1689
|
+
/** A Status report: the summary, then one line per item worst first, its note indented under it. */
|
|
1690
|
+
function statusMarkdown(items: Value | undefined, today: number): string {
|
|
1691
|
+
const read = readStatus(items, today);
|
|
1692
|
+
if (!read.ok) return `_(${read.error})_`;
|
|
1693
|
+
const report = read.value;
|
|
1694
|
+
if (report.items.length === 0) return "**Status** · nothing to report";
|
|
1695
|
+
const lines = report.items.slice(0, MARKDOWN_ROWS).map((item) => {
|
|
1696
|
+
const parts = [item.status === null ? (item.given ?? "no status") : HEALTH_LABEL[item.status]];
|
|
1697
|
+
if (item.progress !== null) parts.push(`${Math.round(item.progress)}%`);
|
|
1698
|
+
if (item.owner !== null) parts.push(item.owner);
|
|
1699
|
+
if (item.due !== null) {
|
|
1700
|
+
const due = relativeDue(item.due, today);
|
|
1701
|
+
parts.push(item.status === "done" ? formatDay(item.due, today) : item.late ? `**${due.text}**` : `${due.text} (${formatDay(item.due, today)})`);
|
|
1702
|
+
}
|
|
1703
|
+
const mark = item.status === null ? "⚪" : HEALTH_MARKS[item.status];
|
|
1704
|
+
const note = item.note === null ? [] : item.note.split(/\r?\n/).map((line) => ` ${line}`);
|
|
1705
|
+
return [`- ${mark} **${item.name}** — ${parts.join(" · ")}`, ...note].join("\n");
|
|
1706
|
+
});
|
|
1707
|
+
return paragraphs([`**Status** · ${healthSummary(report)}`, lines.join("\n"), moreLine(Math.min(report.items.length, MARKDOWN_ROWS), report.items.length, "item")]);
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
const VERDICT_DOTS: Record<string, string> = { better: "🟢", worse: "🔴", same: "⚪" };
|
|
1711
|
+
|
|
1712
|
+
/** One ranked run: `1. \`cmd\` — 7.8 ms ± 0.75 ms (min … max) · 3.92 ± 0.44 × slower`. */
|
|
1713
|
+
function runLine(run: Run, rank: number, unit: string): string {
|
|
1714
|
+
const spread = run.stddev === null ? "" : ` ± ${perfAmount(run.stddev, unit, 2, run.mean)}`;
|
|
1715
|
+
const range = run.min === run.max ? "" : ` (${perfAmount(run.min, unit)} … ${perfAmount(run.max, unit)})`;
|
|
1716
|
+
const relative = rank === 1 ? "fastest" : `${formatRatio(run.ratio, run.ratioStddev)} × slower`;
|
|
1717
|
+
return `${rank}. \`${run.name}\` — ${perfAmount(run.mean, unit)}${spread}${range} · ${relative}${run.failed ? " · exited non-zero" : ""}`;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
/** Before/after rows as a table with each change and its verdict (rows measured under different conditions say so instead); ranked runs (hyperfine, vitest bench) as numbered lists per group; every run of an experiment as a table per group. */
|
|
1721
|
+
function benchmarkMarkdown(results: Value | undefined, unit: string | null): string {
|
|
1722
|
+
const read = readBenchmark(results, unit);
|
|
1723
|
+
if (!read.ok) return `_(${read.error})_`;
|
|
1724
|
+
const report = read.report;
|
|
1725
|
+
if (report.kind === "all-runs") return allRunsMarkdown(report);
|
|
1726
|
+
if (report.kind === "compare") {
|
|
1727
|
+
if (report.rows.length === 0) return "**Benchmark** · no results to compare";
|
|
1728
|
+
const { better, worse, same } = report.counts;
|
|
1729
|
+
const counts = [
|
|
1730
|
+
better > 0 ? `${better} ${report.words.better}` : "",
|
|
1731
|
+
worse > 0 ? `${worse} ${report.words.worse}` : "",
|
|
1732
|
+
same > 0 ? `${same} within noise` : "",
|
|
1733
|
+
report.incomparable > 0 ? `${report.incomparable} not comparable (conditions differ)` : "",
|
|
1734
|
+
report.geomean === null || Math.abs(1 - report.geomean) < 0.0005
|
|
1735
|
+
? ""
|
|
1736
|
+
: `${Math.abs((1 - report.geomean) * 100).toFixed(1)}% ${report.geomean < 1 ? report.words.better : report.words.worse} overall (geometric mean)`,
|
|
1737
|
+
].filter((part) => part !== "");
|
|
1738
|
+
const shown = report.rows.slice(0, MARKDOWN_ROWS);
|
|
1739
|
+
const withConditions = report.rows.some((row) => row.comparability !== null);
|
|
1740
|
+
const amount = (value: number, samples: readonly number[], rowUnit: string | null) =>
|
|
1741
|
+
`${perfAmount(value, rowUnit)}${samples.length > 1 ? ` (mean of ${samples.length})` : ""}`;
|
|
1742
|
+
const rows = shown.map((row) => {
|
|
1743
|
+
const change = row.percent === null ? formatDelta(row.delta, row.unit) : `${formatChange(row.percent)} (${formatDelta(row.delta, row.unit)})`;
|
|
1744
|
+
return [
|
|
1745
|
+
row.better === "higher" ? `${row.name} (higher is better)` : row.name,
|
|
1746
|
+
amount(row.before, row.beforeSamples, row.unit),
|
|
1747
|
+
amount(row.after, row.afterSamples, row.unit),
|
|
1748
|
+
row.comparable ? `${VERDICT_DOTS[row.verdict]} ${change}` : `⚠️ not comparable · measured ${change}`,
|
|
1749
|
+
...(withConditions ? [comparisonConditions(row, report.shared)] : []),
|
|
1750
|
+
];
|
|
1751
|
+
});
|
|
1752
|
+
const shared = formatConditions(report.shared);
|
|
1753
|
+
return paragraphs([
|
|
1754
|
+
`**Benchmark** · ${counts.join(" · ")}`,
|
|
1755
|
+
shared === "" ? "" : `Same conditions for every row: ${shared}`,
|
|
1756
|
+
table(["", "Before", "After", "Change", ...(withConditions ? ["Conditions"] : [])], rows),
|
|
1757
|
+
moreLine(shown.length, report.rows.length, "row"),
|
|
1758
|
+
]);
|
|
1759
|
+
}
|
|
1760
|
+
const groups = report.groups.filter((group) => group.runs.length > 0);
|
|
1761
|
+
if (groups.length === 0) return "**Benchmark** · no results";
|
|
1762
|
+
return paragraphs(
|
|
1763
|
+
groups.map((group) => {
|
|
1764
|
+
const [fastest, ...slower] = group.runs;
|
|
1765
|
+
const lead = slower.slice(0, 3).map((run) => `${formatRatio(run.ratio, run.ratioStddev)} × faster than \`${run.name}\``);
|
|
1766
|
+
const title = `**${group.name ?? "Benchmark"}** · \`${fastest.name}\` is fastest${lead.length === 0 ? "" : `: ${lead.join(", ")}`}`;
|
|
1767
|
+
const shown = group.runs.slice(0, MARKDOWN_ROWS);
|
|
1768
|
+
return paragraphs([title, shown.map((run, index) => runLine(run, index + 1, report.unit)).join("\n"), moreLine(shown.length, group.runs.length, "run")]);
|
|
1769
|
+
}),
|
|
1770
|
+
);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
/** One span: indented by depth, its name, status and size for requests, start → end and duration; ◆ on the critical path. */
|
|
1774
|
+
function spanLine(span: Span, critical: boolean): string {
|
|
1775
|
+
const request = span.request;
|
|
1776
|
+
const status = request === null ? "" : request.status > 0 ? ` ${request.status}` : ` ${request.failure ?? "failed"}`;
|
|
1777
|
+
const size = request === null ? null : (request.transfer ?? request.size);
|
|
1778
|
+
const parts = [`${formatMs(span.start)} → ${formatMs(span.end)} (${formatMs(span.end - span.start)})`, size === null ? "" : formatBytes(size), span.detail ?? ""].filter((part) => part !== "");
|
|
1779
|
+
return `${" ".repeat(span.depth)}- ${span.failed ? "🔴 " : ""}\`${span.name}\`${status} · ${parts.join(" · ")}${critical ? " ◆" : ""}`;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
/** A waterfall as text: the total, failures and critical path, then each span (nested) with its times. */
|
|
1783
|
+
function waterfallMarkdown(spans: Value | undefined, unit: string | null): string {
|
|
1784
|
+
const read = readWaterfall(spans, unit);
|
|
1785
|
+
if (!read.ok) return `_(${read.error})_`;
|
|
1786
|
+
const data = read.waterfall;
|
|
1787
|
+
const counted = data.spans.filter((span) => !span.track);
|
|
1788
|
+
const noun = data.source === "har" ? "request" : data.source === "trace" ? "event" : "span";
|
|
1789
|
+
if (counted.length === 0) return `**Waterfall** · no ${noun}s`;
|
|
1790
|
+
const failed = counted.filter((span) => span.failed).length;
|
|
1791
|
+
const header = [
|
|
1792
|
+
`**Waterfall** · ${counted.length} ${noun}${counted.length === 1 ? "" : "s"} · ${formatMs(data.total)}`,
|
|
1793
|
+
failed > 0 ? `${failed} failed` : "",
|
|
1794
|
+
data.critical.length > 0 ? `critical path ${formatMs(data.criticalMs)} (◆)` : "",
|
|
1795
|
+
...data.markers.map((marker) => `${marker.label} ${formatMs(marker.at)}`),
|
|
1796
|
+
].filter((part) => part !== "");
|
|
1797
|
+
const onPath = new Set(data.critical);
|
|
1798
|
+
const shown = data.spans.slice(0, MARKDOWN_ROWS);
|
|
1799
|
+
// Thread rows of a trace count among the rows left out, so the note says rows.
|
|
1800
|
+
return paragraphs([header.join(" · "), shown.map((span) => spanLine(span, onPath.has(span.index))).join("\n"), moreLine(shown.length, data.spans.length, "row")]);
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/** Children a Treemap rendering lists per level. */
|
|
1804
|
+
const TREEMAP_TOP = 15;
|
|
1805
|
+
const TREEMAP_INNER = 5;
|
|
1806
|
+
|
|
1807
|
+
/** A treemap as a nested list: the largest groups with size and share, and the largest items inside each. */
|
|
1808
|
+
function treemapMarkdown(items: Value | undefined, unit: string | null): string {
|
|
1809
|
+
const read = readTreemap(items, unit);
|
|
1810
|
+
if (!read.ok) return `_(${read.error})_`;
|
|
1811
|
+
const { root, unit: shownUnit } = read.treemap;
|
|
1812
|
+
if (root.value <= 0) return "**Treemap** · nothing to show";
|
|
1813
|
+
const line = (node: TreeNode, depth: number) => `${" ".repeat(depth)}- ${node.name} — ${perfAmount(node.value, shownUnit)} (${formatShare(node.value, root.value)})`;
|
|
1814
|
+
const lines = root.children.slice(0, TREEMAP_TOP).flatMap((node) => [
|
|
1815
|
+
line(node, 0),
|
|
1816
|
+
...node.children.slice(0, TREEMAP_INNER).map((child) => line(child, 1)),
|
|
1817
|
+
...(node.children.length > TREEMAP_INNER ? [` - _… and ${node.children.length - TREEMAP_INNER} more_`] : []),
|
|
1818
|
+
]);
|
|
1819
|
+
const name = root.name === "" ? "" : ` · ${root.name}`;
|
|
1820
|
+
return paragraphs([
|
|
1821
|
+
`**Treemap**${name} · ${perfAmount(root.value, shownUnit)} · ${root.children.length} ${root.children.length === 1 ? "item" : "items"}`,
|
|
1822
|
+
lines.join("\n"),
|
|
1823
|
+
moreLine(Math.min(root.children.length, TREEMAP_TOP), root.children.length, "item"),
|
|
1824
|
+
]);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/** Errors (an error and its causes) a StackTrace's markdown lists, and frames per error. */
|
|
1828
|
+
const TRACE_SECTIONS = 6;
|
|
1829
|
+
const TRACE_FRAMES = 12;
|
|
1830
|
+
/** Lines from the end of a Logs output its markdown shows. */
|
|
1831
|
+
const LOG_TAIL = 30;
|
|
1832
|
+
const LOG_LINE_CHARS = 300;
|
|
1833
|
+
|
|
1834
|
+
/** `fn (path:line:col)`, paths relative to what the app's frames share; a runtime frame as printed (`Array.map (<anonymous>)`). */
|
|
1835
|
+
function traceFrameLine(frame: TraceFrame, common: string): string {
|
|
1836
|
+
const location = frame.location;
|
|
1837
|
+
if (location === null) return frame.text.replace(/^at\s+/, "");
|
|
1838
|
+
const where = `${displayPath(location.path, null, common)}:${location.line}${location.column === null ? "" : `:${location.column}`}`;
|
|
1839
|
+
return frame.fn === null ? where : `${frame.fn} (${where})`;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
/**
|
|
1843
|
+
* An error and its causes: per error a line with its type and message, then a
|
|
1844
|
+
* block with the rest of the message and its frames (innermost first, the
|
|
1845
|
+
* first frame of the app's own code marked `→`, dependency runs folded).
|
|
1846
|
+
*/
|
|
1847
|
+
function stackTraceMarkdown(value: Value | undefined, title: string): string {
|
|
1848
|
+
const read = readTrace(value);
|
|
1849
|
+
if (!read.ok) return `_(${read.error})_`;
|
|
1850
|
+
const { sections } = read.trace;
|
|
1851
|
+
const common = commonDirectory(
|
|
1852
|
+
sections.flatMap((s) => s.frames.flatMap((f) => (f.app && f.location?.linkable ? [f.location.path.replace(/^file:\/\//, "")] : []))),
|
|
1853
|
+
);
|
|
1854
|
+
const places = memberPlaces(sections);
|
|
1855
|
+
const parts = sections.slice(0, TRACE_SECTIONS).map((section, index) => {
|
|
1856
|
+
const label = relationLabel(section.relation, places[index]);
|
|
1857
|
+
const [first, ...rest] = section.message.split("\n");
|
|
1858
|
+
const type = section.type === "" ? "" : `**${section.type}**`;
|
|
1859
|
+
const head = `${label === "" ? "" : `${label}: `}${type}${first === "" ? "" : `${type === "" ? "" : ": "}${first}`}` || "**Error**";
|
|
1860
|
+
const marked = section.frames.findIndex((f) => f.app);
|
|
1861
|
+
const rows = frameRows(section.frames, new Set());
|
|
1862
|
+
const lines = rows
|
|
1863
|
+
.slice(0, TRACE_FRAMES)
|
|
1864
|
+
.map((row) =>
|
|
1865
|
+
row.type === "fold"
|
|
1866
|
+
? ` … ${row.count} library frames`
|
|
1867
|
+
: `${row.index === marked ? "→" : " "} ${traceFrameLine(section.frames[row.index], common)}`,
|
|
1868
|
+
);
|
|
1869
|
+
if (rows.length > TRACE_FRAMES) lines.push(` … ${rows.length - TRACE_FRAMES} more`);
|
|
1870
|
+
if (section.omitted > 0) lines.push(` … ${section.omitted} more as in ${read.trace.language === "java" ? "the error above" : "the cause"}`);
|
|
1871
|
+
const body = [...rest, ...(rest.length > 0 && lines.length > 0 ? [""] : []), ...lines];
|
|
1872
|
+
const fields = section.fields.map(([key, text]) => `${key} ${text}`).join(" · ");
|
|
1873
|
+
return paragraphs([fields === "" ? head : `${head} \n${fields}`, body.length === 0 ? "" : fence(body.join("\n"), "")]);
|
|
1874
|
+
});
|
|
1875
|
+
return paragraphs([title === "" ? "" : `**${title}**`, ...parts, moreLine(Math.min(sections.length, TRACE_SECTIONS), sections.length, "error")]);
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
/** A command's output: the command, exit code and counts, the last lines (where a failure usually is, repeats folded), and the first error when it is earlier. */
|
|
1879
|
+
function logsMarkdown(value: Value | undefined, command: string, exit: Value | undefined): string {
|
|
1880
|
+
const read = readLogs(value);
|
|
1881
|
+
if (!read.ok) return `_(${read.error})_`;
|
|
1882
|
+
const { lines, counts, context, utc } = read.output;
|
|
1883
|
+
const clip = (text: string) => (text.length > LOG_LINE_CHARS ? `${text.slice(0, LOG_LINE_CHARS - 1)}…` : text);
|
|
1884
|
+
const nouns: Record<string, string> = { fatal: "fatal", error: "error", warn: "warning" };
|
|
1885
|
+
const facts = [
|
|
1886
|
+
command === "" ? "**Output**" : command.includes("`") ? `\`\` $ ${command} \`\`` : `\`$ ${command}\``,
|
|
1887
|
+
finite(exit) ? `${exit === 0 ? TONE_DOTS.good : TONE_DOTS.bad} exit ${exit}` : "",
|
|
1888
|
+
`${lines.length} ${lines.length === 1 ? "line" : "lines"}`,
|
|
1889
|
+
...LOG_LEVELS.filter((level) => Object.hasOwn(nouns, level) && counts[level] > 0).map(
|
|
1890
|
+
(level) => `${counts[level]} ${nouns[level]}${counts[level] === 1 || level === "fatal" ? "" : "s"}`,
|
|
1891
|
+
),
|
|
1892
|
+
...context.map(([key, text]) => `${key} ${text}`),
|
|
1893
|
+
utc ? "times UTC" : "",
|
|
1894
|
+
].filter((fact) => fact !== "");
|
|
1895
|
+
if (lines.length === 0) return paragraphs([facts.join(" · "), "_No output._"]);
|
|
1896
|
+
const earlier = Math.max(0, lines.length - LOG_TAIL);
|
|
1897
|
+
const tail = foldRepeats(lines, lines.slice(earlier).map((_, offset) => earlier + offset)).map(
|
|
1898
|
+
(row) => `${clip(lines[row.index].text)}${row.count > 1 ? ` (× ${row.count})` : ""}`,
|
|
1899
|
+
);
|
|
1900
|
+
const error = firstError(lines);
|
|
1901
|
+
return paragraphs([
|
|
1902
|
+
facts.join(" · "),
|
|
1903
|
+
earlier > 0 ? `_… ${earlier} earlier ${earlier === 1 ? "line" : "lines"}_` : "",
|
|
1904
|
+
fence(tail.join("\n"), "text"),
|
|
1905
|
+
error !== -1 && error < earlier ? `First error, line ${lines[error].number}: \`${clip(lines[error].text).replace(/`/g, "'")}\`` : "",
|
|
1906
|
+
]);
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// ---------------------------------------------------------------------------
|
|
1910
|
+
// Pipeline and Dependencies
|
|
1911
|
+
|
|
1912
|
+
const PIPELINE_DOTS: Record<PipelineStatus, string> = {
|
|
1913
|
+
failure: "🔴",
|
|
1914
|
+
in_progress: "🟡",
|
|
1915
|
+
queued: "⏳",
|
|
1916
|
+
cancelled: "⚫",
|
|
1917
|
+
success: "🟢",
|
|
1918
|
+
neutral: "⚪",
|
|
1919
|
+
skipped: "⚪",
|
|
1920
|
+
};
|
|
1921
|
+
|
|
1922
|
+
/** Jobs a stage lists before "… and N more"; failed and running jobs are always listed. */
|
|
1923
|
+
const PIPELINE_STAGE_JOBS = 15;
|
|
1924
|
+
|
|
1925
|
+
/** `label` linked to `url` where it is a web link, else as text. */
|
|
1926
|
+
function maybeLink(label: string, url: string | null): string {
|
|
1927
|
+
const parsed = url === null ? null : webUrl(url, ["https:", "http:"]);
|
|
1928
|
+
return parsed === null ? escapeProse(label) : link(label, parsed);
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
/** `- 🔴 [job](url) — 5m 53s — failed at step 14 "Test build" (1m 25s)`, its message below. */
|
|
1932
|
+
function pipelineJobLine(job: PipelineJob, label: string): string {
|
|
1933
|
+
const time = job.status === "in_progress" ? (job.ms === null ? "running" : `running ${formatElapsed(job.ms)}`) : job.ms === null ? "" : formatElapsed(job.ms);
|
|
1934
|
+
const step = job.failedStep < 0 ? null : job.steps[job.failedStep];
|
|
1935
|
+
const running = job.steps.find((candidate) => candidate.status === "in_progress");
|
|
1936
|
+
const where =
|
|
1937
|
+
step !== null
|
|
1938
|
+
? `failed at ${step.number === null ? "" : `step ${step.number} `}"${step.name}"${step.ms === null ? "" : ` (${formatElapsed(step.ms)})`}`
|
|
1939
|
+
: running !== undefined
|
|
1940
|
+
? `at "${running.name}"`
|
|
1941
|
+
: "";
|
|
1942
|
+
const parts = [`- ${PIPELINE_DOTS[job.status]} ${maybeLink(label, job.url)}`, job.detail ?? "", time, where].filter((part) => part !== "");
|
|
1943
|
+
const message = job.message ?? step?.message ?? null;
|
|
1944
|
+
return `${parts.join(" — ")}${message === null ? "" : `\n ${escapeProse(message.split("\n")[0])}`}`;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/** A CI run: its state, branch, commit and time, then its jobs by stage (or a PR's checks by workflow) with the failing step. */
|
|
1948
|
+
function pipelineMarkdown(value: Value | undefined): string {
|
|
1949
|
+
const read = readPipeline(value);
|
|
1950
|
+
if (!read.ok) return `_(${read.error})_`;
|
|
1951
|
+
const run = read.run;
|
|
1952
|
+
const title = run.title ?? run.name ?? (run.source === "checks" ? "Checks" : "Pipeline");
|
|
1953
|
+
const sha = run.sha === null ? "" : `\`${run.sha.slice(0, 7)}\``;
|
|
1954
|
+
const facts = [
|
|
1955
|
+
`${PIPELINE_DOTS[run.status]} ${STATUS_LABEL[run.status]}${run.detail === null ? "" : ` (${run.detail})`}`,
|
|
1956
|
+
run.title !== null && run.name !== null ? escapeProse(run.name) : "",
|
|
1957
|
+
run.branch === null ? sha : `\`${run.branch}\`${sha === "" ? "" : ` @ ${sha}`}`,
|
|
1958
|
+
run.event?.replace(/_/g, " ") ?? "",
|
|
1959
|
+
run.ms === null ? "" : formatElapsed(run.ms),
|
|
1960
|
+
].filter((part) => part !== "");
|
|
1961
|
+
const noun = run.source === "checks" ? "check" : "job";
|
|
1962
|
+
const header = `**${maybeLink(title, run.url)}** — ${facts.join(" · ")}`;
|
|
1963
|
+
if (run.jobCount === 0) return `${header}\n\nNo ${noun}s.`;
|
|
1964
|
+
const counts = `${run.jobCount} ${noun}${run.jobCount === 1 ? "" : "s"}: ${jobCountsText(run.counts, " · ")}`;
|
|
1965
|
+
const many = run.stages.length > 1;
|
|
1966
|
+
const stages = run.stages.map((stage, index) => {
|
|
1967
|
+
const self = stage.jobs.length === 1 && stage.jobs[0].name === stage.name;
|
|
1968
|
+
const matrix = stage.name === null ? matrixPrefix(stage.jobs.map((job) => job.name)) : null;
|
|
1969
|
+
const label = self ? null : (stage.name ?? matrix?.label ?? (many ? `Stage ${index + 1}` : null));
|
|
1970
|
+
// Jobs arrive failures first; the rest are cut past the page.
|
|
1971
|
+
const pressing = stage.jobs.filter((job) => job.status === "failure" || job.status === "in_progress").length;
|
|
1972
|
+
const shown = stage.jobs.slice(0, Math.max(PIPELINE_STAGE_JOBS, pressing));
|
|
1973
|
+
const lines = shown.map((job) => pipelineJobLine(job, job.name.slice(matrix?.length ?? 0)));
|
|
1974
|
+
const rest = stage.jobs.length - shown.length;
|
|
1975
|
+
if (rest > 0) lines.push(`- … and ${rest} more ${noun}${rest === 1 ? "" : "s"}`);
|
|
1976
|
+
const meta = [`${stage.jobs.length} ${noun}${stage.jobs.length === 1 ? "" : "s"}`, stage.ms === null || stage.ms === 0 ? "" : formatElapsed(stage.ms)].filter((part) => part !== "");
|
|
1977
|
+
return label === null ? lines.join("\n") : `${PIPELINE_DOTS[stage.status]} **${escapeProse(label)}** (${meta.join(" · ")}):\n${lines.join("\n")}`;
|
|
1978
|
+
});
|
|
1979
|
+
return paragraphs([`${header}\n${counts}`, ...stages]);
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
const DEPENDENCY_DOTS: Record<DependencySeverity, string> = { critical: "🔴", high: "🔴", moderate: "🟠", low: "⚪", info: "⚪" };
|
|
1983
|
+
|
|
1984
|
+
/** Vulnerable packages, and outdated packages per group, a Dependencies rendering lists. */
|
|
1985
|
+
const DEPENDENCY_ROWS = 25;
|
|
1986
|
+
|
|
1987
|
+
const DEPENDENCY_SOURCE: Record<string, string> = { npm: "npm outdated", pnpm: "pnpm outdated", pip: "pip list --outdated" };
|
|
1988
|
+
|
|
1989
|
+
/** `to` with the part that changed from `from` in bold: 4.**18.1**. */
|
|
1990
|
+
function changedVersion(from: string, to: string): string {
|
|
1991
|
+
const [same, changed] = splitChange(from, to);
|
|
1992
|
+
return changed === "" ? same : `${same}**${changed}**`;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function vulnerableLine(item: VulnerablePackage): string {
|
|
1996
|
+
const reach =
|
|
1997
|
+
item.direct === true ? "direct" : item.pulledInBy.length > 0 ? `via ${item.pulledInBy.join(", ")}` : item.direct === false ? "transitive" : "";
|
|
1998
|
+
const first = item.advisories[0];
|
|
1999
|
+
const advisory =
|
|
2000
|
+
first === undefined
|
|
2001
|
+
? item.through.length > 0
|
|
2002
|
+
? `through ${item.through.join(", ")}`
|
|
2003
|
+
: ""
|
|
2004
|
+
: `${maybeLink(first.title, first.url)}${item.advisories.length > 1 ? ` (+${item.advisories.length - 1} more)` : ""}`;
|
|
2005
|
+
const target = item.fix === null || item.fix.name === null ? "" : ` ${item.fix.name}${item.fix.version === null ? "" : ` ${item.fix.version}`}`;
|
|
2006
|
+
const fix = item.fix === null ? "no fix yet" : `fix:${target === "" ? " available" : target}${item.fix.major ? " (semver-major)" : ""}`;
|
|
2007
|
+
const head = `- ${DEPENDENCY_DOTS[item.severity]} **${item.severity}** \`${item.name}\`${item.range === null ? "" : ` ${item.range}`}`;
|
|
2008
|
+
return [head, reach, advisory, fix].filter((part) => part !== "").join(" — ");
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
function outdatedLine(item: OutdatedPackage): string {
|
|
2012
|
+
const tags = [
|
|
2013
|
+
item.type === "dev" || item.type === "optional" || item.type === "peer" ? item.type : "",
|
|
2014
|
+
item.deprecated ? "deprecated" : "",
|
|
2015
|
+
item.breaking ? "0.x: may break" : "",
|
|
2016
|
+
item.severity ?? "",
|
|
2017
|
+
].filter((tag) => tag !== "");
|
|
2018
|
+
const suffix = `${tags.length === 0 ? "" : ` (${tags.join(", ")})`}${item.note === null ? "" : ` — ${escapeProse(item.note)}`}`;
|
|
2019
|
+
if (item.current === null) {
|
|
2020
|
+
const versions = [item.wanted === null ? "" : `wants ${item.wanted}`, item.latest === null || item.latest === item.wanted ? "" : `latest ${item.latest}`].filter((part) => part !== "");
|
|
2021
|
+
return `- \`${item.name}\` — not installed${versions.length === 0 ? "" : ` (${versions.join(", ")})`}${suffix}`;
|
|
2022
|
+
}
|
|
2023
|
+
const wanted = item.wanted !== null && item.wanted !== item.current && item.wanted !== item.latest ? item.wanted : null;
|
|
2024
|
+
const steps = [item.current];
|
|
2025
|
+
if (wanted !== null) steps.push(`${changedVersion(item.current, wanted)} (wanted)`);
|
|
2026
|
+
if (item.latest !== null) steps.push(`${changedVersion(wanted ?? item.current, item.latest)}${item.ahead ? " (older than installed)" : ""}`);
|
|
2027
|
+
return `- \`${item.name}\` ${steps.join(" → ")}${suffix}`;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
/** A dependency report: vulnerabilities by severity with their fixes, then outdated packages by semver bump. */
|
|
2031
|
+
function dependenciesMarkdown(value: Value | undefined): string {
|
|
2032
|
+
const read = readDependencies(value);
|
|
2033
|
+
if (!read.ok) return `_(${read.error})_`;
|
|
2034
|
+
const report = read.report;
|
|
2035
|
+
const vulnerable = DEPENDENCY_SEVERITIES.some((severity) => report.severities[severity] > 0);
|
|
2036
|
+
const parts: string[] = [];
|
|
2037
|
+
if (vulnerable) {
|
|
2038
|
+
const strip = DEPENDENCY_SEVERITIES.filter((severity) => report.severities[severity] > 0)
|
|
2039
|
+
.map((severity) => `${DEPENDENCY_DOTS[severity]} ${report.severities[severity]} ${severity}`)
|
|
2040
|
+
.join(" · ");
|
|
2041
|
+
const scanned = report.scanned === null ? "" : ` (${report.scanned} dependencies scanned)`;
|
|
2042
|
+
parts.push(`**Vulnerabilities**${report.auditSource === null ? "" : ` — ${report.auditSource}`}: ${strip}${scanned}`);
|
|
2043
|
+
const more = moreLine(Math.min(report.vulnerable.length, DEPENDENCY_ROWS), report.vulnerable.length, "package");
|
|
2044
|
+
parts.push([...report.vulnerable.slice(0, DEPENDENCY_ROWS).map(vulnerableLine), ...(more === "" ? [] : [more])].join("\n"));
|
|
2045
|
+
} else if (report.auditSource !== null) {
|
|
2046
|
+
parts.push(`**Vulnerabilities** — ${report.auditSource}: none found${report.scanned === null ? "" : ` in ${report.scanned} dependencies`}.`);
|
|
2047
|
+
}
|
|
2048
|
+
if (report.outdated.length > 0) {
|
|
2049
|
+
const groups = groupOutdated(report.outdated);
|
|
2050
|
+
const source = report.source === null ? "" : (DEPENDENCY_SOURCE[report.source] ?? "");
|
|
2051
|
+
const summary = groups.map((group) => `${group.packages.length} ${GROUP_LABEL[group.key].toLowerCase()}`).join(" · ");
|
|
2052
|
+
parts.push(`**Outdated**${source === "" ? "" : ` — ${source}`}: ${report.outdated.length} ${report.outdated.length === 1 ? "package" : "packages"} (${summary})`);
|
|
2053
|
+
for (const group of groups) {
|
|
2054
|
+
const lines = group.packages.slice(0, DEPENDENCY_ROWS).map(outdatedLine);
|
|
2055
|
+
const more = moreLine(lines.length, group.packages.length, "package");
|
|
2056
|
+
parts.push([`${GROUP_LABEL[group.key]} (${group.packages.length}):`, ...lines, ...(more === "" ? [] : [more])].join("\n"));
|
|
2057
|
+
}
|
|
2058
|
+
} else if (report.source !== null || !vulnerable) {
|
|
2059
|
+
parts.push(report.auditSource === null ? "**Dependencies** — every dependency is on its latest version." : "Every dependency is on its latest version.");
|
|
2060
|
+
}
|
|
2061
|
+
return paragraphs(parts);
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
/** Plain text with the characters markdown would read as emphasis, code or links escaped. */
|
|
2065
|
+
function plainMarkdown(text: string): string {
|
|
2066
|
+
return text.replace(/[*_`[\]\\<>]/g, "\\$&");
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
/** A before/after row's conditions: what differs, what it varies on purpose, what one side leaves out, then what both share beyond the report's shared conditions. */
|
|
2070
|
+
function comparisonConditions(row: Comparison, shared: Context): string {
|
|
2071
|
+
const comparability = row.comparability;
|
|
2072
|
+
if (comparability === null) return "";
|
|
2073
|
+
return [
|
|
2074
|
+
...comparability.differing.map((item) => `${item.key} differs: ${item.before} before, ${item.after} after`),
|
|
2075
|
+
...comparability.varied.map((item) => `${item.key} ${item.before ?? "not recorded"} → ${item.after ?? "not recorded"} (varied on purpose)`),
|
|
2076
|
+
...comparability.oneSided.map((item) => `${item.key} ${item.before ?? item.after} (${item.before === null ? "after" : "before"} only)`),
|
|
2077
|
+
...Object.entries(comparability.same)
|
|
2078
|
+
.filter(([key]) => !Object.hasOwn(shared, key))
|
|
2079
|
+
.map(([key, value]) => `${key} ${value}`),
|
|
2080
|
+
].join(" · ");
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
/** Every run of an experiment: a summary line, the conditions all runs share, then a table per group with ★ on each metric's best and failed runs kept. */
|
|
2084
|
+
function allRunsMarkdown(report: AllRunsReport): string {
|
|
2085
|
+
if (report.runs.length === 0) return "**Benchmark** · no runs";
|
|
2086
|
+
const total = report.runs.length;
|
|
2087
|
+
const summary = [
|
|
2088
|
+
`**Benchmark** · ${total} ${total === 1 ? "run" : "runs"}`,
|
|
2089
|
+
report.counts.ok < total ? `${report.counts.ok} measured` : "",
|
|
2090
|
+
...unmeasuredCounts(report.runs).map((entry) => `${entry.count} ${entry.word}`),
|
|
2091
|
+
report.varying.length > 0 ? `conditions differ: ${report.varying.join(", ")}` : "",
|
|
2092
|
+
].filter((part) => part !== "");
|
|
2093
|
+
const shared = formatConditions(report.shared);
|
|
2094
|
+
const metricHeads = report.metrics.map((metric) => `${metric.label} ${metric.better === "higher" ? "↑" : "↓"}`);
|
|
2095
|
+
const cohorts = groupRuns(report, report.groupBy);
|
|
2096
|
+
let budget = MARKDOWN_ROWS;
|
|
2097
|
+
const sections = cohorts.map((cohort) => {
|
|
2098
|
+
const conditions = cohort.varying;
|
|
2099
|
+
const notes = cohort.runs.some((run) => run.note !== null);
|
|
2100
|
+
const shown = cohort.runs.slice(0, Math.max(0, budget));
|
|
2101
|
+
budget -= shown.length;
|
|
2102
|
+
const rows = shown.map((run) => [
|
|
2103
|
+
run.name,
|
|
2104
|
+
...report.metrics.map((metric) => {
|
|
2105
|
+
if (run.status !== "ok") return runStatusWord(run);
|
|
2106
|
+
if (!Object.hasOwn(run.values, metric.key)) return "—";
|
|
2107
|
+
const value = run.values[metric.key];
|
|
2108
|
+
const text = perfAmount(value, metric.unit, 4);
|
|
2109
|
+
return cohort.best[metric.key] === value ? `**${text}** ★` : text;
|
|
2110
|
+
}),
|
|
2111
|
+
...(conditions.length === 0 ? [] : [conditions.flatMap((key) => (Object.hasOwn(run.context, key) ? [`${key} ${run.context[key]}`] : [])).join(" · ")]),
|
|
2112
|
+
...(notes ? [run.note ?? ""] : []),
|
|
2113
|
+
]);
|
|
2114
|
+
const title =
|
|
2115
|
+
cohort.key === null ? "" : `**${cohort.value === null ? `${cohort.key} not recorded` : `${cohort.key}: ${cohort.value}`}** · ${cohort.runs.length} ${cohort.runs.length === 1 ? "run" : "runs"}`;
|
|
2116
|
+
return paragraphs([
|
|
2117
|
+
title,
|
|
2118
|
+
table(["Run", ...metricHeads, ...(conditions.length === 0 ? [] : ["Conditions"]), ...(notes ? ["Note"] : [])], rows),
|
|
2119
|
+
moreLine(shown.length, cohort.runs.length, "run"),
|
|
2120
|
+
]);
|
|
2121
|
+
});
|
|
2122
|
+
const unlike = cohorts.some((cohort) => cohort.varying.length > 0 && Object.keys(cohort.best).length > 0);
|
|
2123
|
+
return paragraphs([
|
|
2124
|
+
summary.join(" · "),
|
|
2125
|
+
shared === "" ? "" : `Same conditions for every run: ${shared}`,
|
|
2126
|
+
...sections,
|
|
2127
|
+
`↑ higher is better · ↓ lower is better · ★ best of the measured runs${report.groupBy === null ? "" : ` per ${report.groupBy}`}${unlike ? ", among runs whose conditions differ" : ""}`,
|
|
2128
|
+
]);
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
/** One compared image: its label, where it is (an https image inline), its capture facts and note. */
|
|
2132
|
+
function compareImageLine(image: CompareImage, position: number): string {
|
|
2133
|
+
const remote = remoteImage(image.src, image.label);
|
|
2134
|
+
const facts = image.meta.map(([key, value]) => `${key} ${value}`).join(" · ");
|
|
2135
|
+
const parts = [remote ?? `\`${image.src}\``, facts, image.note === null ? "" : escapeProse(image.note)].filter((part) => part !== "");
|
|
2136
|
+
return `${position + 1}. **${escapeProse(image.label)}**${position === 0 ? " (reference)" : ""} — ${parts.join(" — ")}`;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
/** Compare as its images (the reference first) and each condition mismatch against the reference; the pixel views need BB. */
|
|
2140
|
+
function compareMarkdown(value: Value | undefined): string {
|
|
2141
|
+
const read = readCompare(value);
|
|
2142
|
+
if (!read.ok) return `_(${read.error})_`;
|
|
2143
|
+
const [reference, ...others] = read.images;
|
|
2144
|
+
if (reference === undefined) return "**Image comparison** · no images";
|
|
2145
|
+
const warnings = others.flatMap((other) => {
|
|
2146
|
+
const warning = conditionsWarning(conditionDifferences(reference, other), reference.label, other.label);
|
|
2147
|
+
return warning === null ? [] : [`⚠️ ${others.length > 1 ? `${escapeProse(other.label)}: ` : ""}${escapeProse(warning)}`];
|
|
2148
|
+
});
|
|
2149
|
+
const title =
|
|
2150
|
+
others.length === 0
|
|
2151
|
+
? `**Image comparison** · only the reference so far`
|
|
2152
|
+
: `**Image comparison** · ${escapeProse(reference.label)} vs ${others.map((other) => escapeProse(other.label)).join(", ")}`;
|
|
2153
|
+
return paragraphs([
|
|
2154
|
+
title,
|
|
2155
|
+
read.images.map(compareImageLine).join("\n"),
|
|
2156
|
+
warnings.join("\n"),
|
|
2157
|
+
read.ignored > 0 ? `_${read.ignored} ${read.ignored === 1 ? "entry" : "entries"} without a src left out_` : "",
|
|
2158
|
+
others.length === 0 ? "" : "_Side by side, wipe, overlay and the pixel difference with its changed regions show in BB._",
|
|
2159
|
+
]);
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
/** A value as a code span, its fence longer than any backtick run inside. */
|
|
2163
|
+
function provenanceCode(value: string): string {
|
|
2164
|
+
const longest = (value.match(/`+/g) ?? []).reduce((max, run) => Math.max(max, run.length), 0);
|
|
2165
|
+
const ticks = "`".repeat(longest + 1);
|
|
2166
|
+
return longest === 0 ? `${ticks}${value}${ticks}` : `${ticks} ${value} ${ticks}`;
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
const PROVENANCE_MARKS: Readonly<Record<string, string>> = { done: "✅", failed: "❌", running: "🔄", none: "❔", waiting: "⏳", "not-reached": "⚪", skipped: "⏭️" };
|
|
2170
|
+
|
|
2171
|
+
/** `branch `main` ✗ expected `feat/399``, a URL as a link. */
|
|
2172
|
+
function provenanceFact(fact: ProvenanceFact, requested: boolean): string {
|
|
2173
|
+
const url = fact.key === "url" ? webUrl(fact.value, ["https:", "http:"]) : null;
|
|
2174
|
+
const value = url === null ? provenanceCode(fact.short) : link(fact.short, url);
|
|
2175
|
+
if (fact.check === null) return `${escapeProse(fact.label)} ${value}${requested && fact.confirmed ? " ✓" : ""}`;
|
|
2176
|
+
const other = fact.check.kind === "expected" ? `expected ${provenanceCode(shortValue(fact.key, fact.check.expected))}` : `was ${provenanceCode(shortValue(fact.key, fact.check.previous))}`;
|
|
2177
|
+
return `${escapeProse(fact.label)} ${value} ✗ ${other}`;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
function provenanceEvidence(evidence: ProvenanceEvidence): string {
|
|
2181
|
+
const label = evidence.label === null ? "" : `${escapeProse(evidence.label)}: `;
|
|
2182
|
+
switch (evidence.kind) {
|
|
2183
|
+
case "url": {
|
|
2184
|
+
const url = webUrl(evidence.url, ["https:", "http:"]);
|
|
2185
|
+
return url === null ? `${label}${provenanceCode(evidence.url)}` : link(evidence.label ?? evidence.url.replace(/^https?:\/\//, ""), url);
|
|
2186
|
+
}
|
|
2187
|
+
case "image":
|
|
2188
|
+
return `${label === "" ? "image: " : label}${provenanceCode(evidence.src)}`;
|
|
2189
|
+
case "file":
|
|
2190
|
+
return `${label}${provenanceCode(evidence.line === null ? evidence.path : `${evidence.path}:${evidence.line}`)}`;
|
|
2191
|
+
case "text":
|
|
2192
|
+
return `${label}${provenanceCode(evidence.text.replace(/\s*\n\s*/g, " "))}`;
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
/** A provenance chain: the verdict and what is wrong first, then each stage with its outcome, facts (wrong ones with both values) and evidence. */
|
|
2197
|
+
function provenanceMarkdown(stages: Value | undefined, expected: Value | undefined): string {
|
|
2198
|
+
const read = readProvenance(stages, expected);
|
|
2199
|
+
if (!read.ok) return `_(${read.error})_`;
|
|
2200
|
+
const chain = read.provenance;
|
|
2201
|
+
if (chain.stages.length === 0) return "**Provenance** · no stages yet";
|
|
2202
|
+
const { verdict } = chain;
|
|
2203
|
+
const requested = new Set(chain.expected.map((fact) => fact.key));
|
|
2204
|
+
const head = [`${TONE_DOTS[verdict.tone]} **${escapeProse(verdict.title)}**`, ...verdict.lines.map((line) => `- ${escapeProse(line.text)}`)].join("\n");
|
|
2205
|
+
const wanted =
|
|
2206
|
+
chain.expected.length === 0
|
|
2207
|
+
? ""
|
|
2208
|
+
: ` · requested: ${chain.expected
|
|
2209
|
+
.map((fact) => {
|
|
2210
|
+
const wrong = chain.mismatches.some((mismatch) => mismatch.key === fact.key && mismatch.check.kind === "expected");
|
|
2211
|
+
return `${escapeProse(fact.label)} ${provenanceCode(fact.short)} ${wrong ? "✗" : fact.confirmed ? "✓" : "(not confirmed)"}`;
|
|
2212
|
+
})
|
|
2213
|
+
.join(", ")}`;
|
|
2214
|
+
const lines = chain.stages.map((stage, index) => {
|
|
2215
|
+
const wrong = stage.facts.some((fact) => fact.check !== null);
|
|
2216
|
+
const mark = stage.receipt === "receipt" ? (stage.status === "done" && wrong ? "⚠️" : PROVENANCE_MARKS[stage.status]) : PROVENANCE_MARKS[stage.receipt];
|
|
2217
|
+
const state = stage.receipt === "receipt" ? (stage.status === "done" ? "" : STAGE_STATUS_LABEL[stage.status].toLowerCase()) : RECEIPT_LABEL[stage.receipt];
|
|
2218
|
+
const meta = [state, stage.note ?? "", stage.ms === null ? "" : formatElapsed(stage.ms), stage.last ? "last receipt" : ""].filter((part) => part !== "");
|
|
2219
|
+
const out = [`${index + 1}. ${mark} **${escapeProse(stage.name)}**${meta.length === 0 ? "" : ` — ${meta.map(escapeProse).join(" · ")}`}`];
|
|
2220
|
+
if (stage.facts.length > 0) out.push(` ${stage.facts.map((fact) => provenanceFact(fact, requested.has(fact.key))).join(" · ")}`);
|
|
2221
|
+
if (stage.detail !== null) out.push(` ${escapeProse(stage.detail.replace(/\s*\n\s*/g, " "))}`);
|
|
2222
|
+
if (stage.evidence.length > 0) out.push(` Evidence: ${stage.evidence.map(provenanceEvidence).join(" · ")}`);
|
|
2223
|
+
return out.join("\n");
|
|
2224
|
+
});
|
|
2225
|
+
return paragraphs([head, `${escapeProse(chainSummary(chain))}${wanted}`, lines.join("\n")]);
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
/** A check as one glyph: checked results filled, a claim 💬, nothing run ⚪, n/a ➖. */
|
|
2229
|
+
function acceptanceMark(check: Check): string {
|
|
2230
|
+
if (check.notApplicable) return "➖";
|
|
2231
|
+
return { passed: "✅", failed: "❌", blocked: "⛔", untested: "⚪", claimed: "💬", skipped: "⏭️" }[check.status];
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
/** Evidence inline: a file as `path:line`, a web link, an image by its path, the first line of a tool's text. */
|
|
2235
|
+
function acceptanceEvidence(item: AcceptanceEvidence): string {
|
|
2236
|
+
const label = item.label === null ? "" : `${escapeProse(item.label)}: `;
|
|
2237
|
+
if (item.kind === "file") return `${label}\`${item.path}${item.line === null ? "" : `:${item.line}${item.endLine === null ? "" : `-${item.endLine}`}`}\``;
|
|
2238
|
+
if (item.kind === "text") {
|
|
2239
|
+
const first = item.text.split("\n")[0]!.trim();
|
|
2240
|
+
return `${label}“${escapeProse(first.length > 120 ? `${first.slice(0, 119)}…` : first)}”`;
|
|
2241
|
+
}
|
|
2242
|
+
const href = item.kind === "url" ? item.url : item.src;
|
|
2243
|
+
const url = webUrl(href, ["https:", "http:"]);
|
|
2244
|
+
return url === null ? `${label}\`${href}\`` : link(item.label ?? (item.kind === "image" ? "image" : url.host + url.pathname.replace(/\/$/, "")), url);
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
/** A check's detail lines under its list item: the note, method, run and time, and its evidence. */
|
|
2248
|
+
function acceptanceDetail(check: Check): string[] {
|
|
2249
|
+
const facts = [check.method, check.run, check.at].filter((part) => part !== null);
|
|
2250
|
+
return [
|
|
2251
|
+
check.note === null ? "" : ` ${escapeProse(check.note.replace(/\s*\n\s*/g, " "))}`,
|
|
2252
|
+
facts.length === 0 ? "" : ` _${facts.map(escapeProse).join(" · ")}_`,
|
|
2253
|
+
check.evidence.length === 0 ? "" : ` Evidence: ${check.evidence.map(acceptanceEvidence).join(" · ")}`,
|
|
2254
|
+
].filter((part) => part !== "");
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
/**
|
|
2258
|
+
* Acceptance as text: the verdict first, what needs the user, the owner's
|
|
2259
|
+
* limitations, then a matrix as a table of marks with every gap listed under
|
|
2260
|
+
* it, or one line per criterion with its note and evidence.
|
|
2261
|
+
*/
|
|
2262
|
+
function acceptanceMarkdown(criteria: Value | undefined, columns: Value | undefined): string {
|
|
2263
|
+
const read = readAcceptance(criteria, columns);
|
|
2264
|
+
if (!read.ok) return `_(${read.error})_`;
|
|
2265
|
+
const report = read.report;
|
|
2266
|
+
const verdict = acceptanceVerdict(report);
|
|
2267
|
+
const title = `**${escapeProse(report.title ?? "Acceptance")}**`;
|
|
2268
|
+
if (report.criteria.length === 0) return `${title} · no criteria yet`;
|
|
2269
|
+
const more = report.criteria.length > MARKDOWN_ROWS ? `_… and ${report.criteria.length - MARKDOWN_ROWS} more criteria_` : "";
|
|
2270
|
+
const contradiction = ownerContradiction(report, verdict);
|
|
2271
|
+
const cells = cellsLine(verdict);
|
|
2272
|
+
const head = [
|
|
2273
|
+
[
|
|
2274
|
+
title,
|
|
2275
|
+
`${report.criteria.length} ${report.criteria.length === 1 ? "criterion" : "criteria"}`,
|
|
2276
|
+
verdict.optional.total > 0 ? `${verdict.optional.total} optional` : "",
|
|
2277
|
+
report.columns.length > 0 ? `${report.columns.length} conditions` : "",
|
|
2278
|
+
report.hiddenColumns > 0 ? `${report.hiddenColumns} more not shown` : "",
|
|
2279
|
+
]
|
|
2280
|
+
.filter((part) => part !== "")
|
|
2281
|
+
.join(" · "),
|
|
2282
|
+
`**${verdict.headline}**: ${[verdict.proven, ...verdict.parts].join(" · ")}`,
|
|
2283
|
+
cells === null ? "" : `_${cells}_`,
|
|
2284
|
+
contradiction === null ? "" : `⚠️ ${contradiction}`,
|
|
2285
|
+
report.statedVerdict === null ? "" : `Stated verdict: “${escapeProse(report.statedVerdict)}”`,
|
|
2286
|
+
].filter((part) => part !== "");
|
|
2287
|
+
const needs =
|
|
2288
|
+
verdict.needsYou.length === 0
|
|
2289
|
+
? ""
|
|
2290
|
+
: ["**Needs you**", ...verdict.needsYou.map((item) => `- ${escapeProse(item.what || "Your input")} — ${escapeProse(needsPlaces(item))}`)].join("\n");
|
|
2291
|
+
const limitations = report.limitations.length === 0 ? "" : ["Limitations the owner stated:", ...report.limitations.map((item) => `- ${escapeProse(item)}`)].join("\n");
|
|
2292
|
+
const shown = report.criteria.slice(0, MARKDOWN_ROWS);
|
|
2293
|
+
if (report.columns.length === 0) {
|
|
2294
|
+
const lines = shown.map((criterion) => {
|
|
2295
|
+
const check = criterion.own;
|
|
2296
|
+
const optional = criterion.required ? "" : " _(optional)_";
|
|
2297
|
+
return [`- ${acceptanceMark(check)} ${escapeProse(criterion.name)}${optional} — ${escapeProse(checkLabel(check))}`, ...acceptanceDetail(check)].join("\n");
|
|
2298
|
+
});
|
|
2299
|
+
return paragraphs([head.join("\n"), needs, limitations, [lines.join("\n"), more].filter((part) => part !== "").join("\n")]);
|
|
2300
|
+
}
|
|
2301
|
+
const rows = shown.map((criterion) => [
|
|
2302
|
+
`${acceptanceMark(criterion.own)} ${criterion.name}${criterion.required ? "" : " (optional)"}`,
|
|
2303
|
+
...criterion.cells!.map((check) => acceptanceMark(check)),
|
|
2304
|
+
]);
|
|
2305
|
+
const marks = new Set(shown.flatMap((criterion) => [criterion.own, ...criterion.cells!].map(acceptanceMark)));
|
|
2306
|
+
const legend = Object.entries({ "✅": "passed", "❌": "failed", "⛔": "blocked", "⚪": "not tested", "💬": "only claimed", "⏭️": "skipped", "➖": "not applicable" })
|
|
2307
|
+
.filter(([mark]) => marks.has(mark))
|
|
2308
|
+
.map(([mark, word]) => `${mark} ${word}`)
|
|
2309
|
+
.join(" · ");
|
|
2310
|
+
// Each open criterion's own note (when it has one), then its open cells; only cells are counted.
|
|
2311
|
+
let gapCells = 0;
|
|
2312
|
+
const gaps = shown.flatMap((criterion) => {
|
|
2313
|
+
if (!isGap(criterion)) return [];
|
|
2314
|
+
const own = acceptanceDetail({ ...criterion.own, method: null, run: null, at: null });
|
|
2315
|
+
const row = own.length === 0 ? [] : [[`- ${acceptanceMark(criterion.own)} **${escapeProse(criterion.name)}**${criterion.own.by === null ? "" : ` (${escapeProse(criterion.own.by)})`}`, ...own].join("\n")];
|
|
2316
|
+
const cells = criterion.cells!.flatMap((check, position) => {
|
|
2317
|
+
if (!isCellGap(check)) return [];
|
|
2318
|
+
const where = `${escapeProse(criterion.name)} · ${escapeProse(report.columns[position] ?? "")}`;
|
|
2319
|
+
return [[`- ${acceptanceMark(check)} ${where} — ${escapeProse(checkLabel(check))}`, ...acceptanceDetail(check)].join("\n")];
|
|
2320
|
+
});
|
|
2321
|
+
gapCells += cells.length;
|
|
2322
|
+
return [...row, ...cells];
|
|
2323
|
+
});
|
|
2324
|
+
const gapList = gaps.slice(0, MARKDOWN_ROWS);
|
|
2325
|
+
return paragraphs([
|
|
2326
|
+
head.join("\n"),
|
|
2327
|
+
needs,
|
|
2328
|
+
limitations,
|
|
2329
|
+
[table(["Criterion", ...report.columns], rows), more].filter((part) => part !== "").join("\n"),
|
|
2330
|
+
`_${legend}_`,
|
|
2331
|
+
gapList.length === 0 ? "" : [`**Gaps** (${gapCells} ${gapCells === 1 ? "cell" : "cells"})`, ...gapList, moreLine(gapList.length, gaps.length, "gap")].filter((part) => part !== "").join("\n"),
|
|
2332
|
+
]);
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
/** Words of a lone transcript shown before "…". */
|
|
2336
|
+
const TRANSCRIPT_EXCERPT_WORDS = 80;
|
|
2337
|
+
|
|
2338
|
+
/** Transcripts as WER per hypothesis, then each error with its time; a lone transcript as its text. */
|
|
2339
|
+
function transcriptMarkdown(value: Value | undefined, audio: string): string {
|
|
2340
|
+
const read = readTracks(value);
|
|
2341
|
+
if (!read.ok) return `_(${read.error})_`;
|
|
2342
|
+
const analysis = analyse(read.tracks);
|
|
2343
|
+
if (analysis === null) return "**Transcript** · no words";
|
|
2344
|
+
const { reference, refTokens, comparisons, regions } = analysis;
|
|
2345
|
+
const end = Math.max(0, ...read.tracks.flatMap((track) => track.words.map((word) => word.end ?? 0)));
|
|
2346
|
+
const facts = [`${refTokens.length} ${refTokens.length === 1 ? "word" : "words"}`, end > 0 ? formatClock(end, false) : ""].filter((part) => part !== "");
|
|
2347
|
+
const recording = audio === "" ? "" : `Recording: \`${audio}\``;
|
|
2348
|
+
if (comparisons.length === 0) {
|
|
2349
|
+
const words = reference.words.slice(0, TRANSCRIPT_EXCERPT_WORDS).map((word) => word.text);
|
|
2350
|
+
return paragraphs([
|
|
2351
|
+
[`**Transcript** · ${reference.name}`, ...facts].join(" · "),
|
|
2352
|
+
`> ${words.join(" ")}${reference.words.length > words.length ? " …" : ""}`,
|
|
2353
|
+
recording,
|
|
2354
|
+
]);
|
|
2355
|
+
}
|
|
2356
|
+
const score = (index: number) => {
|
|
2357
|
+
const comparison = comparisons[index];
|
|
2358
|
+
return `WER ${comparison.wer === null ? "—" : formatWer(comparison.wer)} (${countsText(comparison.counts)})`;
|
|
2359
|
+
};
|
|
2360
|
+
const head =
|
|
2361
|
+
comparisons.length === 1
|
|
2362
|
+
? `**Transcript** · ${read.tracks[1].name} against ${reference.name}: ${score(0)} · ${facts.join(" · ")}`
|
|
2363
|
+
: [
|
|
2364
|
+
[`**Transcript** · ${comparisons.length} transcripts against ${reference.name}`, ...facts].join(" · "),
|
|
2365
|
+
...comparisons.map((comparison, index) => `- ${read.tracks[comparison.track].name}: ${score(index)}`),
|
|
2366
|
+
].join("\n");
|
|
2367
|
+
const shown = regions.slice(0, MARKDOWN_ROWS);
|
|
2368
|
+
const lines = shown.map((region) => {
|
|
2369
|
+
const at = region.start === null ? "" : `${formatClock(region.start)} `;
|
|
2370
|
+
const who = comparisons.length > 1 ? `${read.tracks[region.track].name}: ` : "";
|
|
2371
|
+
return `- ${at}${who}${describeRegion(region)}`;
|
|
2372
|
+
});
|
|
2373
|
+
return paragraphs([head, lines.length === 0 ? "" : [...lines, moreLine(shown.length, regions.length, "error")].filter((part) => part !== "").join("\n"), recording]);
|
|
2374
|
+
}
|