Skip to content

Instantly share code, notes, and snippets.

@bbarker
Last active October 29, 2026 19:06
Show Gist options
  • Select an option

  • Save bbarker/adf53bc413d52f28eb04bec92b804203 to your computer and use it in GitHub Desktop.

Select an option

Save bbarker/adf53bc413d52f28eb04bec92b804203 to your computer and use it in GitHub Desktop.
Check a Unison type witness's typeLinks against its constructors' indices, and a hand-kept list of a GADT's constructors, through ucm transcript.fork
#!/usr/bin/env nu
# Checks a Unison type witness against the function that links its types,
# and optionally a hand-kept list of a GADT's constructors against the GADT.
#
# A witness is a GADT with one constructor per type, each fixing its index:
#
# type Stored a where
# SubmissionS : Stored Submission
# AccountS : Stored Account
#
# and a function pairs each constructor with that type's link:
#
# Stored.link : Stored a -> Link.Type
# Stored.link = cases
# SubmissionS -> typeLink Submission
# AccountS -> typeLink Account
#
# The compiler checks every use of a witness against its index, but not the
# links: `typeLink` is a value, and `SubmissionS -> typeLink Account`
# compiles. A unit test could repeat the expected links, but that is another
# hand-written mapping to keep up to date. Instead, this asks UCM to `view`
# the two definitions and compares each arm's `typeLink` with its
# constructor's declared index, as text: both are printed with the same
# names.
#
# It checks a source form, not what the function computes, so each arm must
# return its link directly:
#
# SubmissionS -> typeLink Submission
#
# or, with `--wrapper StoredType.StoredType`, inside that one data
# constructor, with any string literals around exactly one `typeLink` (a
# first string literal must name the same type, fully or by a suffix):
#
# SubmissionS -> StoredType "Submission" (typeLink Submission)
#
# The wrapper is asked of UCM, and must be a data constructor: a function
# there could ignore the link it is given. An arm in any other shape - an
# `if`, a `match`, a helper call, two links - is rejected rather than
# guessed at.
#
# `--tables` and `--list` check a second thing the compiler leaves alone: a
# hand-kept list of a GADT's constructors, wrapped to hide their indices,
#
# type Table k v where
# Submissions : Table SubmissionId Submission
# Accounts : Table AccountId Account
#
# Table.all : [SomeTable]
# Table.all = [SomeTable Submissions, SomeTable Accounts]
#
# Unison checks that a `match` covers every constructor, but cannot list a
# type's constructors as values, so a constructor left out of the list
# compiles. This fails on one left out, an entry that names none, and one
# listed twice.
#
# nu unison-witness.nu @me/myapp/main myapp.db.Stored myapp.db.Stored.link
# nu unison-witness.nu @me/myapp/main myapp.db.Stored myapp.db.Stored.link \
# --tables myapp.db.Table --list myapp.db.Table.all --load scratch.u
# nu unison-witness.nu @me/myapp/main myapp.db.Stored myapp.db.Stored.describe \
# --wrapper StoredType.StoredType
#
# It runs `ucm transcript.fork`, which works on a copy of the codebase, so it
# needs no lock and can run while UCM is open. `--load` loads and updates a
# scratch file in the fork first, so a change can be checked before it is
# applied. A transcript that fails - a scratch file that does not typecheck,
# a name that does not exist - fails the check before anything is read from
# it. Exits 1 on any disagreement, or on anything it cannot read, so a check
# that read nothing cannot pass.
#
# In AlygnApp, `cargo xtask release` runs it on the service's table
# witnesses and table list (crates/xtask/src/unison_witness.rs), and
# `nu scripts/unison-witness.nu self-test` runs its tests, which an xtask
# unit test runs with the rest (crates/xtask/src/nu_scripts.rs).
use std/assert
# Every run of whitespace made one space, so a signature or an arm the
# printer wrapped reads as one line.
export def flatten-answer []: string -> string {
str replace --all --regex '\s+' ' '
}
# The text between `start` and the first of `ends` after it, or null when
# there is no `start`.
def between [start: string, ends: list<string>]: string -> any {
let text = $in
let from = $text | str index-of $start
if $from < 0 { return null }
let rest = $text | str substring ($from + ($start | str length))..
let cuts = $ends | each {|end| $rest | str index-of $end } | where {|at| $at >= 0 }
if ($cuts | is-empty) { $rest } else { $rest | str substring ..<($cuts | math min) }
}
# A dotted name with its dots escaped for a regex.
def escaped []: string -> string { str replace --all "." '\.' }
# Whether `printed` names `index`: the same name, or one ending in the other
# at a namespace boundary (`myapp.coach.Submission` for `coach.Submission`,
# or `Submission` for `coach.Submission`).
export def names-type [printed: string, index: string]: nothing -> bool {
let ends = {|longer, shorter| $longer == $shorter or ($longer | str ends-with $".($shorter)") }
(do $ends $printed $index) or (do $ends $index $printed)
}
# Each constructor of `witness` and the type its index fixes, read from the
# flattened answer.
export def witness-constructors [witness: string, links: string, prompt: string]: string -> record {
let flat = $in
let short = $witness | split row "." | last
let declaration = $flat | between $"type ($witness) a where " ["{{" $"($links) :" $prompt "```"]
if $declaration == null {
return {constructors: [], problems: [$"UCM printed no `type ($witness) a where` declaration"]}
}
let constructors = $declaration
| parse --regex ('(?<name>\w+) : (?:[\w.]*\.)?' + ($short | escaped) + ' (?<index>[\w.]+)')
if ($constructors | is-empty) {
{constructors: [], problems: [$"read no constructors of ($witness)"]}
} else {
{constructors: $constructors, problems: []}
}
}
# Each arm of `links`: its constructor, its first string literal (or null)
# and the type its one `typeLink` names. An arm in another shape is a
# problem, not an arm.
export def link-arms [links: string, prompt: string, wrapper?: string]: string -> record {
let flat = $in
let body = $flat | between $"($links) = cases " ["```" $prompt]
if $body == null { return {arms: [], problems: [$"UCM printed no `($links) = cases`"]} }
let pairs = $body | split row " -> " | window 2
let count = $pairs | length
let read = $pairs | enumerate | each {|at|
let constructor = $at.item.0 | split row " " | last | split row "." | last
# Each arm's text runs on to the next arm's constructor; drop it.
let words = $at.item.1 | str trim | split row " "
let rhs = if $at.index < ($count - 1) { $words | drop 1 | str join " " } else { $words | str join " " }
let direct = $rhs | parse --regex '^typeLink (?<link>[\w.]+)$'
let wrapped = if $wrapper == null { [] } else {
let named = $wrapper | split row "." | last | escaped
$rhs | parse --regex ('^(?:[\w.]*\.)?' + $named + '(?<strings>(?: "[^"]*")*) \(typeLink (?<link>[\w.]+)\)(?: "[^"]*")*$')
}
if ($direct | length) == 1 {
{arm: {constructor: $constructor, printed: null, link: $direct.0.link}}
} else if ($wrapped | length) == 1 {
let printed = $wrapped.0.strings | parse --regex '"(?<text>[^"]*)"' | get text | first 1
{arm: {constructor: $constructor, printed: ($printed | get 0?), link: $wrapped.0.link}}
} else {
let shapes = if $wrapper == null { "`C -> typeLink T`" } else { $"`C -> typeLink T`, or `C -> ($wrapper | split row '.' | last) \"name\" \(typeLink T\)`" }
{problem: $"the arm for `($constructor)` is not one of the supported shapes \(($shapes)\): ($rhs)"}
}
}
{
arms: ($read | where {|row| 'arm' in $row } | get arm)
problems: ($read | where {|row| 'problem' in $row } | get problem)
}
}
# Every disagreement between the witness's constructors and the arms.
export def link-mismatches [constructors: list, arms: list, links: string]: nothing -> list<string> {
let wrong = $arms | each {|arm|
let match = $constructors | where name == $arm.constructor
if ($match | is-empty) {
$"`($arm.constructor)` has an arm in ($links) but is not a constructor"
} else if $match.0.index != $arm.link {
$"`($arm.constructor)` stands for `($match.0.index)`, but ($links) links `($arm.link)`"
} else if $arm.printed != null and not (names-type $arm.printed $match.0.index) {
$"`($arm.constructor)` stands for `($match.0.index)`, but ($links) prints it as \"($arm.printed)\""
}
} | compact
let missing = $constructors
| where {|constructor| $constructor.name not-in ($arms | get constructor) }
| each {|constructor| $"`($constructor.name)` has no arm in ($links)" }
$wrong | append $missing
}
# Every constructor of `tables` the list leaves out, every entry naming
# none, and every constructor listed twice. Reads either way UCM prints a
# list: one entry a line (`[ SomeTable A , SomeTable B ]` once flattened)
# or, for a short one, on one line (`[SomeTable A, SomeTable B]`).
export def list-problems [tables: string, list: string, prompt: string]: string -> record {
let flat = $in
let short = $tables | split row "." | last
let declared_at = $flat | parse --regex ('type ' + ($tables | escaped) + '(?: \w+)* where (?<body>.*)')
if ($declared_at | is-empty) {
return {tables: 0, problems: [$"UCM printed no `type ($tables) ... where` declaration"]}
}
let declaration = $declared_at.0.body | between "" [$"($list) :" $prompt "{{" "```"]
let declared = $declaration | parse --regex ('(?<name>\w+) : (?:[\w.]*\.)?' + ($short | escaped) + ' ') | get name
if ($declared | is-empty) { return {tables: 0, problems: [$"read no constructors of ($tables)"]} }
let listed = $flat | between $"($list) = [" ["]"]
if $listed == null { return {tables: ($declared | length), problems: [$"UCM printed no `($list) = [ ... ]`"]} }
let entries = $listed | split row "," | each {|entry| $entry | str trim | split row " " | last | split row "." | last }
let left_out = $declared | where {|name| $name not-in $entries }
| each {|name| $"`($name)` is a constructor of ($tables), but ($list) leaves it out" }
let unknown = $entries | where {|entry| $entry not-in $declared }
| each {|entry| $"($list) lists `($entry)`, which is not a constructor of ($tables)" }
let twice = $declared | where {|name| ($entries | where {|entry| $entry == $name } | length) > 1 }
| each {|name| $"($list) lists `($name)` more than once" }
{tables: ($declared | length), problems: ($left_out | append $unknown | append $twice)}
}
# Whether UCM's `names` answer for `wrapper` shows a data constructor: a
# term whose hash carries a constructor index (`#abc#0`). A function's
# hash has none, and a function could ignore the link it is given.
export def wrapper-problems [wrapper: string, prompt: string]: string -> list<string> {
let flat = $in
let answer = $flat | between $"($prompt) names ($wrapper) " [$prompt "```"]
if $answer == null { return [$"UCM printed no `names` answer for ($wrapper)"] }
if ($answer | parse --regex '#\w+#\d+ Term ' | is-empty) {
[$"`($wrapper)` is not a data constructor, so an arm wrapping a link in it is not checked: ($answer | str trim)"]
} else { [] }
}
# The whole check over one transcript answer: how many types and tables it
# read, and every problem it found.
export def check-answer [
answer: string
project_branch: string
witness: string
links: string
tables?: string
list?: string
wrapper?: string
]: nothing -> record {
let prompt = $"($project_branch)>"
let flat = $answer | flatten-answer
let read = $flat | witness-constructors $witness $links $prompt
let arms = $flat | link-arms $links $prompt $wrapper
let wrapping = if $wrapper == null { [] } else { $flat | wrapper-problems $wrapper $prompt }
let listing = if $tables == null { {tables: 0, problems: []} } else { $flat | list-problems $tables $list $prompt }
let mismatches = if ($read.problems | is-empty) and ($arms.problems | is-empty) {
link-mismatches $read.constructors $arms.arms $links
} else { [] }
{
types: ($read.constructors | length)
tables: $listing.tables
problems: ($wrapping | append $read.problems | append $arms.problems | append $mismatches | append $listing.problems)
}
}
def main [
project_branch: string # the project and branch, e.g. @me/myapp/main
witness: string # the witness type, fully qualified
links: string # the function from the witness to links, fully qualified
--codebase: path # the directory holding .unison (default: your home)
--load: path # a scratch file to load and update in the fork first
--tables: string # a GADT whose constructors `--list` must list, fully qualified
--list: string # the hand-kept list of its constructors, fully qualified
--wrapper: string # a data constructor an arm may wrap its link in, as UCM names it
] {
if ($tables == null) != ($list == null) {
print -e "--tables and --list go together"
exit 1
}
let dir = mktemp -d
let script = $dir | path join "witness.md"
let loaded = if $load == null { "" } else {
$"($project_branch)> load ($load | path expand)\n($project_branch)> update\n"
}
let listing = if $tables == null { "" } else { $"($project_branch)> view ($tables) ($list)\n" }
let naming = if $wrapper == null { "" } else { $"($project_branch)> names ($wrapper)\n" }
$"```ucm\n($loaded)($project_branch)> view ($witness) ($links)\n($listing)($naming)```\n" | save $script
let codebase = $codebase | default $nu.home-dir
let ran = do { cd $dir; ^ucm --codebase $codebase transcript.fork $script } | complete
let answer_file = $dir | path join "witness.output.md"
# A failed `load` or `update` would leave the `view`s reading what the
# codebase already held, so a failed transcript is never parsed.
if $ran.exit_code != 0 or not ($answer_file | path exists) {
rm -rf $dir
print -e $"the transcript failed \(exit ($ran.exit_code)\):\n($ran.stdout | lines | last 12 | str join "\n")"
exit 1
}
let answer = open --raw $answer_file
rm -rf $dir
let result = check-answer $answer $project_branch $witness $links $tables $list $wrapper
if ($result.problems | is-empty) {
print $"($result.types) types: every arm of ($links) links its constructor's own type"
if $tables != null { print $"($result.tables) constructors of ($tables): ($list) lists each once" }
} else {
print -e ($result.problems | str join "\n")
exit 1
}
}
# The script's tests, over answers shaped as UCM prints them.
def "main self-test" [] {
let prompt = "@me/myapp/main>"
let viewed = '@me/myapp/main> view myapp.db.Stored myapp.db.Stored.describe
{{
A witness of each type a table stores.
}}
type myapp.db.Stored a where
SubmissionAt_2_5_0S :
db.Stored schema_2_5_0.Submission
SubmissionS : db.Stored coach.Submission
TextS : db.Stored Text
myapp.db.Stored.describe : db.Stored a -> StoredType
myapp.db.Stored.describe = cases
SubmissionAt_2_5_0S ->
StoredType
"myapp.legacy.schema_2_5_0.Submission"
(typeLink schema_2_5_0.Submission)
SubmissionS ->
StoredType "Submission" (typeLink coach.Submission)
TextS -> StoredType "Text" (typeLink Text)
'
let tables = '@me/myapp/main> view myapp.db.Table myapp.db.Table.all
type myapp.db.Table k v where
Submissions : db.Table SubmissionId coach.Submission
SubmissionsAt_2_5_0 :
db.Table SubmissionId schema_2_5_0.Submission
Notes :
db.Table
NoteId
Note
myapp.db.Table.all : [SomeTable]
myapp.db.Table.all =
[ SomeTable Submissions
, SomeTable Notes
, SomeTable SubmissionsAt_2_5_0
]
```
'
let named = '@me/myapp/main> names StoredType.StoredType
Hash Kind Names
#puvp0d9mtd#0 Term lib.store.StoredType.StoredType
```
'
let answer = "``` ucm\n" + $viewed + "\n" + ($tables | str replace "```\n" "") + $named
let check = {|text| check-answer $text "@me/myapp/main" myapp.db.Stored myapp.db.Stored.describe myapp.db.Table myapp.db.Table.all StoredType.StoredType }
# The answer as printed passes, through the printer's wrapping.
let passed = do $check $answer
assert equal $passed {types: 3, tables: 3, problems: []}
# A link to another type, and a printed name of another type, are named.
let swapped = $answer | str replace '"Submission" (typeLink coach.Submission)' '"Submission" (typeLink schema_2_5_0.Submission)'
assert equal (do $check $swapped).problems ["`SubmissionS` stands for `coach.Submission`, but myapp.db.Stored.describe links `schema_2_5_0.Submission`"]
let misnamed = $answer | str replace 'StoredType "Text"' 'StoredType "Nat"'
assert equal (do $check $misnamed).problems ["`TextS` stands for `Text`, but myapp.db.Stored.describe prints it as \"Nat\""]
assert (names-type "Submission" "coach.Submission")
assert not (names-type "OldSubmission" "coach.Submission")
# A constructor with no arm is named.
let short = $answer | str replace " TextS -> StoredType \"Text\" (typeLink Text)\n" ""
assert equal (do $check $short).problems ["`TextS` has no arm in myapp.db.Stored.describe"]
# An arm in another shape is rejected, never read: two links, an `if`,
# or anything after the link.
for odd in [
'TextS -> if true then typeLink Nat else typeLink Text'
'TextS -> StoredType "Text" (typeLink Text) |> withLink (typeLink Nat)'
'TextS -> describeText'
'TextS -> replaceLink (typeLink Text)'
] {
let found = (do $check ($answer | str replace 'TextS -> StoredType "Text" (typeLink Text)' $odd)).problems
assert equal ($found | length) 1
assert str contains $found.0 "is not one of the supported shapes"
}
# A bare `typeLink` arm reads too.
let bare = $answer | str replace 'TextS -> StoredType "Text" (typeLink Text)' 'TextS -> typeLink Text'
assert equal (do $check $bare).problems []
# A table left out of the list, and one listed twice, are named.
let missing = $answer | str replace " , SomeTable Notes\n" ""
assert equal (do $check $missing).problems ["`Notes` is a constructor of myapp.db.Table, but myapp.db.Table.all leaves it out"]
let twice = $answer | str replace " , SomeTable Notes\n" " , SomeTable Notes\n , SomeTable Notes\n"
assert equal (do $check $twice).problems ["myapp.db.Table.all lists `Notes` more than once"]
# A list short enough for one line reads the same.
let one_line = $answer | str replace " [ SomeTable Submissions\n , SomeTable Notes\n , SomeTable SubmissionsAt_2_5_0\n ]" " [SomeTable Submissions, SomeTable Notes, SomeTable SubmissionsAt_2_5_0]"
assert not ($one_line == $answer)
assert equal (do $check $one_line) {types: 3, tables: 3, problems: []}
# Without `--wrapper`, only bare arms are accepted.
let unwrapped = check-answer $answer "@me/myapp/main" myapp.db.Stored myapp.db.Stored.describe
assert equal ($unwrapped.problems | length) 3
assert str contains $unwrapped.problems.0 "(`C -> typeLink T`)"
# A wrapper that is a function, not a data constructor, is named.
let helper = $answer | str replace '#puvp0d9mtd#0 Term' '#fbp9g5a18i Term'
assert str contains (do $check $helper).problems.0 "is not a data constructor"
# An answer without the definitions is a problem, not a pass.
assert equal ((check-answer "``` ucm\n```\n" "@me/myapp/main" myapp.db.Stored myapp.db.Stored.describe).problems | length) 2
print "unison-witness.nu: self-test passed"
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment