Repository navigation
Releases: lask-task-runner/lask
Release list
v0.7.0
A task can now be trusted with what it should not leak, and stopped before what it should not do.
Until now a credential had to sit in the process environment in plain text for get_env to read it, and from there it could reach a JSON event, a final diagnostic or the host's process list. Nothing told destroy apart from build, a flaky step had to be written out again to be retried, and a command left behind by race kept running. The dependency cache was trusted on sight, and a URL in lask.json could be read by git as an option. v0.7.0 reads secrets from Vault without changing the program, asks for a typed confirmation where a project says so, gives tasks retry, polling and timeouts, lets a type parameter carry a bound — and closes every one of those leaks.
Secrets from Vault
- A variable whose whole value is
{scheme://reference}is a secret reference, resolved whenget_env,find_envorget_env_orreads it:AWS_SECRET_ACCESS_KEY="{vault://secret/aws#secret_key}". The program stays the same; only the environment changes.has_envdoes not resolve it, and a malformed reference is an error, never a value. LASK_SECRETSlists the stores a run may use; any other scheme isE-IO-SECRET-PROVIDER. The first isvault(Vault and OpenBao), configured from Vault's own variables:VAULT_ADDR,VAULT_TOKEN, AppRole throughVAULT_ROLE_ID/VAULT_SECRET_ID,~/.vault-token,VAULT_NAMESPACEandVAULT_CACERT. KV version 2 mounts are detected from the server, and?version=nselects a version.- A resolved value is masked like a
!!one. A run logs in once and reads each secret once, acrossasynctoo, so the fields of a dynamic secret come from one lease. Nothing is written to disk and the process environment is never changed. lask secrets list [fn]reports the references in the environment without reaching a store.lask secrets check [fn] [--read]checks configuration, reachability, login and read permission per store before a task runs, reads a value only with--read, revokes any lease it issued, and exits 3 on failure. Neither prints a value or a credential.- Five new external I/O error codes:
E-IO-SECRET-PROVIDER,-REF,-UNREACHABLE,-AUTHand-NOT-FOUND.lask cmd --listnotes an environment that reads a reference, without resolving it.
Typed confirmation
lask.jsontakes aconfirmmap from a public function of the entry module to an entry, so runningdestroyordeploy --env prodbecomes a deliberate act:{"confirm": {"deploy": {"when": {"env": ["prod"]}}, "destroy": {}, "reset_db": {"phrase": "reset #{db}"}}}.whennames the parameter values that require confirmation;phraseis what to type, interpolating parameters.runandevalask before anything is evaluated, and only about the function the CLI calls. At a terminal the phrase is typed; elsewhere--confirmapproves, unlessLASK_CONFIRM=ttysays only a typed confirmation counts. A refusal isE-CLI-NOT-CONFIRMED, exit 4.- An entry belongs to the declaration the name resolves to, so a re-export is covered, and only the root project's file applies.
check,run,evalandenvscheck every entry against the program — the function, the parameters it names, the types of the values — and a mismatch isE-MODULE-CONFIRM-TARGET, located at the key inlask.json. --helpgains a Confirmation section and JSON help aconfirmfield; the editor's hover shows the same, and the language server reports errors inlask.jsononlask.json.
Retry, polling and timeouts
retry(delays, body)andretry_if(delays, when, body)run the body again after a failure, waiting the next delay each time. A strategy is just the array of delays, so its length bounds the attempts; once it runs out, orwhenrefuses, the last failure is re-raised unchanged.until(delays, done, body)polls untildoneaccepts the body's value and returns that value. When the delays run out it fails with code 124,E-RUNTIME-UNTIL-EXHAUSTED, naming the last value.timeout(seconds, body)abandons a body that outlives its limit: its command is stopped, the computations it started withasyncare cancelled, and the call fails with code 124,E-RUNTIME-TIMEOUT. The failure is raised at the call, so atryinside the body does not see it.backoff_fixed,backoff_linearandbackoff_exponentialbuild strategies, andbackoff_jitterrandomizes one:retry(backoff_exponential(1, 2, 3), \() -> build())waits 1, 2 and 4 seconds. Each retry and each missed check writes a line to the execution log.- An abandoned command is actually stopped.
racecancelled the computations that lost, but the command one of them was running kept going, and a container outlived itsdocker runclient. Now the process tree gets SIGTERM and, after 3 seconds, SIGKILL (on Windows,taskkill /T); containers are namedlask-<random>and stopped withdocker stop. The log records such a command askilledin place of its exit line.
Bounded type parameters
- A type parameter takes one bound: a named bound —
comparable,orderable,stringifiable— or a type bound, which admits what conforms to it.largest<T: orderable>(xs: Array<T>): T = last(sort(xs))is now a function a user can write. A type alias makes a bound of a program's own. - Inside the body, a type parameter has what its bound gives:
comparablegives==,stringifiablegives#{...},orderablegivessortand both of the others. A type bound gives what the bound has, exceptAny, which gives nothing. - The built-ins state their conditions as bounds:
sort,sort_by,unique,contains_array,index_of_array,to_stringandmark_secret. They used to check them at the call site only, so a built-in referenced as a function value skipped the check —s: Function<Array<Bool>, Array<Bool>> = sortwas accepted. A use outside a bound is the newE-TYPE-BOUND, at a call, at a reference, and where a bounded alias is applied. - The CLI decodes a type-bounded parameter at its bound and instantiates a named-bounded one from the arguments it was given. Help, function lists and hovers show bounds, for built-ins too.
Async
- A never-awaited
asyncis no longer cut short. At the end of a run,runandevalwait for every handle thatawait,allorracedid not consume, including any those start in turn, and report each as the runtime advisoryW-ASYNC-UNAWAITED, with where it started and how it ended. The result and the exit code of the run are unchanged. lask checkreportsW-ASYNC-UNUSEDfor a handle the text alone shows is never awaited: a discarded statement of typeAsyncHandle<T>orArray<AsyncHandle<T>>, or such a binding that nothing refers to. It is the first advisory the implementation reports:checkprints it and still exits 0, withseverity: "warning"in JSON, and the editor shows it as a warning.awaitre-raises a failure unchanged, as 6.3, 8.6 and 8.10 always said, so a command that fails insideasyncexits the process with its own code andcatch (e)sees itse.code.
REPL
:reload/:rreads the target module again and re-applies the declarations typed at the prompt, in order. Re-applying compiles and does not evaluate, so a reload runs nothing.- A module that is missing or does not compile, or a typed declaration that no longer compiles against it, fails the reload by name and leaves the session as it was. A reload never discards what was typed.
- Input starting with
:is a session command; an unknown one is reported rather than parsed as code.
Hardening
- A secret is masked everywhere lask writes to stderr, not only in the command log. With
--format jsona secret passed to a function appeared in its call event, and one a failed command printed to stderr appeared in the final diagnostic. Events, uncaught failures,W-ASYNC-UNAWAITEDand REPL errors are now masked. A secret that spans lines is also masked line by line. - A
dockerenvironment'senvvalues stay off the command line.--env NAME=valuemade a credential readable by every user of the host in the process list. Each variable is now named as--env NAMEand its value handed to docker through the client's environment. Variables the client reads itself (DOCKER_*,*_PROXY,PATH,HOME,SSH_AUTH_SOCK) are passed as before. - The Vault token follows one standby redirect at most. A 307 or 308 is followed once, as the Vault CLI does; any other redirect, a second one, or one from https to http is
E-IO-SECRET-UNREACHABLErather than a token sent to another host. - A dependency source can no longer be read as an option of git or curl. A git URL such as
--upload-pack=<command>ran that command duringlask deps sync.git,revandurlmay not start with-or contain whitespace, aurlmust behttps://,http://orfile://, and both tools take the source where it cannot be an option. A lock entry's hash andrevmust have their exact shapes, so neither can name a path outside the cache. - The dependency cache is verified where it is used. An entry used to be trusted from the moment
deps syncput it there, so anything that could write to a sharedLASK_CACHE_DIRcould replace a dependency's code. The loader now checks each entry against the lock's hash once per resolution, anddeps syncrefetches one that does not verify. - A dependency may not contain a symbolic link, and a local import inside a dependency must stay inside its tree. A link made a dependency's content depend on the machine reading it, and let an import reach any file.
Dependencies and the lock
- A git dependency's
revis the commit its content came from, recorded by whichever ofdeps addanddeps syncfetched it, and a tag that has moved since is `E-MOD...
v0.6.0
An environment is now something a library can hand you — configured, pinned, and with its command words attached.
Until now a command declaration took an image written with literal arguments, its words never left the module that wrote them, and #docker(...) could say nothing about a container beyond memory and cpus. A project could pin a toolchain, but not share one: the credentials, variables and mounts a tool needs had to be repeated at every call site, or wrapped in a module that re-implemented the tool's CLI as functions. v0.6.0 lets a command's environment be any expression, lets command words be imported, gives the container constructor the rest of its options — and makes the lock pin every registry image by digest, so what a library hands you is the same image on every machine.
Command words across modules
import commandbrings in the command words another module declares.import command { "python", "pip" } from "tools"makes a bare$ pip install ...dispatch to the environment thetoolsmodule declared for it.export command { "helm" } from "./lib/k8s.lask"imports and publishes again. A command declaration takesexport/internallike any other, and is exported by default.- No other import brings a command word in, and there is no renaming: the word is the program the environment runs. A named or namespace import changes nothing about where a command runs, so importing a module for its functions never reroutes your commands.
- A command's environment is any expression of type
Environment:command { "aws" } on tools.aws(profile = "dev"), a namespace member, a call with computed arguments. It is elaborated in the declaring module and evaluated when a command it selects runs. - It may not have an effect. No command, file,
stdin,logor random value, directly or through what it calls (E-TYPE-COMMAND-EFFECT);get_envis fine. This is what letslask cmdevaluate a declaration without running anything or consuming the program's stdin. - Dispatch compares where an environment comes from, not its value: the same literal, the same binding, or the same declaration. Two separate calls conflict, since only running them could show they agree — declare such words together.
- The words of a declaration are written in braces,
command { "go", "gofmt" } on e, for symmetry with the import form. With the braces the position ofonis lexical, so the editor marks it as a keyword there and leaves it an identifier elsewhere.
Container options
#docker(...)configures the container it launches. Beyondmemoryandcpus:memory_swapmemory_reservationcpu_sharescpuset_cpuscpuset_memspids_limitshm_sizeblkio_weightulimits;workdiruserenvplatformhostnameinit;read_onlytmpfscap_drop;networkdnsdns_searchadd_hostspublish;volumes; andbuild_argson a recipe.- Only options that stay inside the permission boundary of 10.7 are offered.
privileged,cap_add, devices and the host namespaces are left out: they would make that guarantee untrue rather than narrow it.publishandvolumeswiden it, and are written where the environment is. - An option given
nullis left out, as are thenullelements of a list or table, so a function that builds an environment can pass an optional parameter straight through.""is a value and is passed on —PAGER=""means something. A list or table already typedArray<String>orMap<String>is accepted as it is. - Environment values are keyed by argument name, not by the order the arguments were written in, so
#docker("a:1", memory = "1g", cpus = 2)and the same options reordered are one environment for dispatch. build_argscloses a gap 10.3 had already written down: the recipe hash covers the declared build arguments, and there was no way to declare any. Likedockerfileandcontext, it must be a literal, since it decides which imagelask env buildbuilds.- The environment in the command log is masked too. A
dockerenvironment can carry variables into the container, so a secret reaches the log through the environment value as readily as through the command string.
Images pinned by digest
- A run never pulls. Spec 10.3 and 10.4 always said so; none of it was implemented, and a run passed the tag to
docker run, which pulled whatever the tag named that day. Nowlask deps syncandlask env buildpull every registry image the program references, record its digest inlask.lock.json, andrun,eval,cmdand file access run<repository>@<digest>. - A fresh machine gets the very image the lock names, whatever the tag names upstream, and is checked to carry it (
E-IO-IMAGE-DIGEST). A missing image isE-IO-IMAGE-MISSING, naminglask env build. - A registry reference may omit its tag —
#alpinemeanslatest— since the lock pins what the bare name resolved to, and it cannot move under a run. deps syncmaterializes images after the modules, also in a project that declares no dependency, and--frozencovers images too.envs --checkchecks that each image is actually present, andenv listreports registry references and their pins, with JSON.- A reference computed at run time cannot be pinned: it runs as written if present. The REPL may still pull — it evaluates what is typed at it, which no lock records.
Language
- A secret binding may be
String | Null.--token!!: String | Null = nullsays "not given" the way every other optional parameter does, and a credential that may be unset can be read withfind_envand still be masked when present. Anullregisters nothing, so the text an absent value prints as is never masked elsewhere in a log.mark_secretbecomesT -> TforStringorString | Null. run(env, cmd)is the core command-execution function, formerlyrun_command(cmd, env): the environment comes first, as it does in$[env] cmd.- A re-exported name works everywhere its declaration does.
t.go()throughimport * as tfailed withinternal: missing declaration gowhentpublishedgobyexport { go } from ..., andlask runansweredno such functionfor it. Both now follow the re-export — keyword arguments,--helpbuilt from the declaring file under the published name, the function list, and shell completion for local paths — so a multi-file library that publishes its API from its entry module, as chapter 5 describes, has a CLI surface. - A recipe resolves inside the tree of the module that declares it.
#docker(dockerfile = "images/ansible/Dockerfile")written in a library read that path from the importing project and failed there. A library can now ship the recipes of its environments, built from the dependency cache.
In the editor
- Hovering a builtin documents it: the call with parameter names, the failure it raises, and the spec section it summarizes, condensed from chapter 15. Polymorphic builtins show their type parameters, as user declarations do, and a local that shadows a builtin is no longer mistaken for it.
Breaking changes
run_command(cmd, env)is nowrun(env, cmd). Rename the call and swap the arguments. A builtin call with keyword arguments is alwaysE-TYPE-KEYWORD.- A command declaration's words are written in braces.
command "go" on eis now a syntax error that says how to writecommand { "go" } on e. E-TYPE-COMMAND-DECLis replaced byE-TYPE-COMMAND-EFFECT. The literal-arguments rule it enforced is gone; what is refused now is an environment with an effect.- A project that runs containers needs
lask env build(orlask deps sync) before its first run. A registry image the lock does not pin, or one not on the daemon, isE-IO-IMAGE-MISSINGrather than a pull. Commit theimagessectionlask.lock.jsongains. build_argsmust be a literal, likedockerfileandcontext. Spec 10.2's claim that every container option had to be a literal is corrected: only these three are.
Lask is pre-1.0 and every feature is experimental until 1.0, so breaking changes remain possible; see compatibility.md.
Fixes and internals
- CI pins the image before running the containerized task in the colon-bearing-directory check, as the install test does, now that a run no longer pulls.
Examples
- example/ is reorganized into two groups, each numbered in reading order.
01-projectskeeps the end-to-end work:01-hello-world(was01-basic) and02-webapp-on-aws(was04-webapp). The Docker and Terraform projects are gone; what they showed is now in the language tour. - example/02-language is a tour of the whole language: fourteen topics, one runnable module each, covering the Quick Reference from values and types through to doc comments and the CLI. The commentary lives in the code beside the thing it describes, and because the comment above a task is also its help text,
lask run --helpin any of them is a second index into the same prose. - 02-webapp-on-aws takes its environments from lask-module-tools instead of wrapping Terraform and the AWS CLI through lask-terraform and lask-aws. The tools run as written —
$ terraform -chdir=infra apply ...,$ aws s3 sync ...— and their image tags, credentials and region are given once, in the environment each command word is declared on.
Documentation
- The Quick Reference, the specification and the READMEs follow every change above, and paragraphs are no longer hard-wrapped, so the docs pass cleanly through translation tools.
Full Changelog: v0.5.0...v0.6.0
v0.5.0
A value that may be absent now has a type — and a way back out of it.
Until now the only type for a value whose shape was not fixed was Any, and the only way out of Any was cast, which fails where a program wanted to test. A lookup that might find nothing had to fail rather than report it, and a published module could not write first without one copy per element type. v0.5.0 gives the language unions, a case that dispatches on them, type parameters and optional record fields — and nearly triples the built-in library, so that fewer tasks have to shell out to get ordinary work done.
Union types and type dispatch
T | Nullis the type of a value that may be absent.find_env("PORT")returnsString | Nullinstead of failing on an unset variable or inventing a sentinel;get_envremains the form for a variable the task requires in order to run at all.- A union is never inferred (4.3). It is the type of an expression only where an annotation or a callee's signature says so, so a heterogeneous array literal is still
Array<Any>and two branches of differing types are still a type error. Every union in a program is one that someone wrote. caseis the multi-way conditional, in three forms: value heads compared by==, type heads dispatched on the runtime type, and a scrutinee-less condition form in place of the chainedelse ifthe grammar never had. Exactly oneelsearm, last, is required — exhaustiveness is never inferred from the type of the scrutinee (E-SYNTAX-CASE-ELSE).- An arm narrows its scrutinee. Where the scrutinee is written as a plain local name, the
elsearm has the members that are left: afterNull -> 8080,to_number(p)sees aString.caseandcastrun the same runtime check (15.8) and differ only in what happens when it does not hold — one tests, the other fails. - Every form of
casenormalizes to nestedchoose, with the scrutinee bound once and evaluated exactly once however many arms are tested, so it adds no evaluation rule of its own. Two new error codes:E-SYNTAX-CASE-ELSEandE-TYPE-CASE-DUPLICATE, the latter for a later arm that can never be selected.
Type parameters
- A function declaration and a type alias may bind them:
first_or<T>(xs: Array<T>, fallback: T): T,type Opt<A> = A | Null,type Pair<A, B>. The cost of not having them was measurable — 43 of the library's 112 signatures were polymorphic, and every one was a function a user could not have written. - They are never written at a use site. A call instantiates them from the argument types and the expected type, in no particular order, by the rules built-in symbols already followed; 4.4 stops being "built-in polymorphism" and becomes one set of rules for both.
- Inside the body of its declaration a type parameter is rigid. It conforms only to itself and to
Any, so it is not comparable, not ordered, not stringifiable, not acasttarget and not acasetype head. A body that needs an operation takes it as an argument, which is what makes the feature safe without bounded quantification. - A default value is checked once, at the declaration, with the parameters rigid, so it has to hold for every instantiation:
--xs: Array<T> = []and--y: T | Null = nullare admitted,--y: T = 1is not. For the same reasonlask runcan instantiate every parameter atAny— whatever it hands over, the body can only pass along. E-TYPE-ARITYnow also covers a type argument count that does not match an alias's parameters.
Optional record fields
?after a field name makes the key optional —Record<name: String, tags?: Array<String>>— which is a different question from whether the value may be null. A JSON producer omits an optional field far more often than it writes an explicit null, andcastused to reject exactly that. The notation is TypeScript's, and so is the meaning:a?: Tqualifies the key alone.- An absent key and a null value stay different where it matters.
castaccepts a value whose optional keys are missing and still rejects one missing a required key (15.8); serialization omits an absent optional field while writing"b": nullfor a null one (13.1). - In memory the two read alike. An optional field reads as
T | Null(6.8), an absent key reading as null, which is what keeps field access from failing at run time. A program that must tell them apart casts the record to aMap<T>and askshas_key. - Conformance stays invariant. The required set, the optional set and each field type must all be identical, so
Record<a: String>conforms toRecord<a?: String>in neither direction: whether a key has to be there is part of the type. Optionality is never inferred, as unions are not.
Built-in library
- 42 functions to 115, with no existing signature changed. The additions sit where writing a task meant shelling out.
- Filesystem and path operations, both new.
read_filewrite_filefile_existsremove_filemake_dirlist_dirglob, andpath_joindirnamebasenameextnamenormalize_pathis_absolute_path. Each filesystem function takes theEnvironmentas its last argument, so where it reads is as explicit as where a command runs. There is deliberately no recursive removal: a destructive traversal stays a command, where the execution log can see it. - Arrays:
findfind_indexeveryanysortsort_byzipuniquerangeenumerateflattenflat_mapslicetakedropreversefirstlastsizeis_emptycontains_arrayindex_of_array.rangeis how aforexpression iterates a number of times andenumeratehow it iterates with an index, sincefortraverses an array and has no numeric form. - Strings:
to_stringto_numberlinessubstringpad_startpad_endrepeatstarts_withends_withindex_ofcontains, andregex_testregex_matchregex_replace. - Maps:
get_orsetremovemergeentriesfrom_entriesmap_values. Environment:find_envhas_envget_env_or. Data:base64_encodebase64_decodesha256md5. Alsoshell_quote,log,uuid,random_string, andminmaxsumpowsqrtclamp. - Absence is reported two ways, deliberately. A function that returns a position reports it as
-1(index_of,find_index); one that returns a value reports it asNull(find,find_env). Two new runtime error codes:E-RUNTIME-VALUEandE-RUNTIME-REGEX.
Language and editor
- Type annotations on local bindings. A binding inside a block takes one in the same shape as a top-level declaration,
!!marker included:cfg: Record<name: String> = cast(from_json(stdin)). This is how an expression that needs an expected type gets one inside a block (6.5), andcastis the case that motivates it. - Instantiation no longer depends on argument order. An argument whose own type comes from its position — a
cast, afail— is checked once the other arguments and the expected type have determined that position, and may stand anywhere in the call. Arguments still evaluate left to right. - Hover and
--helpshow a declaration with its binder,first_or<T>. Where the name is instead something to type — the usage line, a completion candidate, the function argument oflask run— it stays plain (11.6). Record field completion shows an optional field at the type it reads as, andcaseis painted as a control keyword.
Breaking changes
caseis a reserved word. A module using it as a declaration name or an identifier-form record field is nowE-SYNTAX-UNEXPECTED-TOKEN; such a field is written{"case": ...}and read asx["case"]. No module in this repository was affected.- An expression of type
Anyis refused in a polymorphic position. 4.4 has always said the way out ofAnyis a runtime check, but the matcher for polymorphic signatures accepted one anyway, somap(v, f)on av: Anywas quietly admitted and the element type became whatever the lambda said. Insert acast, or narrow withcase, first.
Lask is pre-1.0 and every feature is experimental until 1.0, so breaking changes remain possible; see compatibility.md.
Fixes and internals
- Running a command in a container now works on Windows. A bind mount was passed as
-v <source>:<target>, which packs source, target and mode into one colon-separated field. Every Windows path carries a colon in its drive letter, soC:\projwas read as sourceC, target\projand mode/work, which the daemon refused. Mounts now use--mount type=bind,source=...,target=..., where each field is named; the same break was reachable on POSIX, where a colon is legal in a directory name. - A CLI argument that does not match its parameter type says what did not fit. The one fact the user needs,
expected Number, got String, used to arrive wrapped in theShowoutput of the internal failure record, so the line read like a crash rather than a usage error. It is the first thing a new user sees on a typo. - The mount regression is now exercised in CI rather than by hand, the install test drives a released binary the way the README tells a reader to install it, and the
.debis named the way the release page names it. The build is warning-free again.
Documentation
- Quick Reference — the whole language and CLI on one page, ten minutes end to end, with every section linking to the chapter of the specification that defines it. spec.md is 4,300 lines and answers the questions nobody asks first; this is the way in.
- The README documents the
.debattached to every release, distinguishes it from the APT repository that is still planned, and leads with what it costs to...
v0.4.0
Where a command runs is now written down — or it does not run at all.
Until now a command execution expression with no $[...] fell back to the host. That fallback is the "works on my machine" failure the language exists to prevent: a deleted declaration or a mistyped program name went silent, and every line had to repeat the image it needed. v0.4.0 removes it, and gives the environment a place to be declared once.
Command declarations and explicit environments
commanddeclares which image provides a program.command "python", "pip" on #python:3.12.14-alpine3.24registers command words for the module; the tasks below then name only what they run:$ pip install -r api/requirements.txt && python -m unittest.- There is no default environment. An expression takes its environment from its
$[...]or from the programs its command string invokes. One that determines neither isE-TYPE-COMMAND-NOENV, caught bylask check. - Dispatch decides only where the text decides. Spec 10.9 scans quoted and nested regions, separators and assignment words, and reports a command word only where the text alone determines it. Selection is unanimity over structural equality —
#localincluded — so a line invoking both a host program and a containerised one isE-TYPE-COMMAND-CONFLICT, never a silent choice. lask cmdruns a declared command by hand.lask cmd go test ./...runs the project's pinned toolchain outside any task, as an argument vector with no shell, attaching the terminal when there is one. Everything after the command name goes to the program verbatim,--helpincluded;lask cmd --listshows what the module declares.- Six new error codes:
E-TYPE-COMMAND-NOENV,-CONFLICT,-DECL,-NAME,-DUPLICATE, alongside the existing-ENV.
Shell completion
lask completion bash|zsh|fishprints a script whose only job is to ask the binary, so an installed script survives upgrades.- It knows your module, not just the CLI.
lask run <TAB>completes the functions the entry module defines — less value bindings,@hidden,internal, and functions taking a positional Environment;lask run build --<TAB>completes that function's keyword parameters;lask cmd <TAB>completes declared commands. kebab and snake spellings mirror each other, and candidates come back in the style being typed. - Nothing is evaluated to answer a
<TAB>. The index parses the entry module and stops there: no elaboration, no import resolution, no dependency cache, no network. Completion works beforedeps synchas ever run, and a module that does not parse still yields declaration heads. A request answers in ~22 ms, so there is no cache to go stale. @completesteers a parameter (spec 3.1): static choices,@keys <binding>reading a top-level map literal without evaluating it,@file [*.ext],@dir.lask run install_completion --shell fishwrites the script into the directory that shell reads and prints the startup line rather than editing anyone's rc file.uninstall_completionis its counterpart.- The README now gives each shell a recipe that works on a stock machine — including that zsh loads nothing from
$fpathuntilcompinithas run, and that bash 3.2, still macOS's/bin/bash, silently reads nothing fromsource <(...).
In the editor
- Semantic tokens paint a command word that carried the environment as the reference it is, inside the command string. Unmatched words stay string text, which makes a bracket-less command with nothing highlighted the visible form of
E-TYPE-COMMAND-NOENV. - Inlay hints show the environment a bare command resolved to, written as the source the author could have written:
$[#node:20.20.2-alpine3.23] cd web && npm ci. A line that already carries$[...]gets no hint, since it says where it runs. - The spans come from elaboration, so the editor shares dispatch's own table and procedure — a word that lights up is a word that voted, and no second, looser matcher exists to drift from it.
exportandinternalare surfaced likeimport: painted as keywords, offered by completion, and read by the token-stream fallback.
Language
- Namespace-qualified type references.
import * as tf from "terraform"now lets an annotation name that module's public type aliases directly:f(): tf.TfOutputs = .... Alias-cycle detection follows qualified edges across modules. - Every string and command chunk carries its source span, so anything downstream can name a location inside a command string.
Breaking changes
- A command execution expression that determines no environment is a static error. Add a
command ... on <Environment>declaration for the programs a module runs, or write$[...]at the site. The examples andmain.laskin this repository show both. run_command(cmd: String, env: Environment)takes the environment as a required positional argument, closing the path by which the core function could still default to the host.exportandinternalare reserved words. A declaration, an identifier-form record field, or an unquoted object key by either name is nowE-SYNTAX-UNEXPECTED-TOKEN; such a field is written{"internal": true}and read asx["internal"]. No module in this repository, lask-terraform or lask-aws was affected.- A
commanddeclaration's environment arguments must be literals: dispatch has to compare two environments structurally, and the environment must be enumerable and pinnable without evaluating the module. - Spec correction in 11.8: standard output is always passed through and only standard error is relayed as the command execution log — the previous wording would have made the output of
lask cmdimpossible to pipe.
Fixes and internals
stripSpansDeclhad noDExportFromcase, so stripping spans from any module usingexport { .. } from ".."crashed at run time.- GHC 9.6.7 → 9.10.3 (Stackage LTS 24.58), with the build now warning-free: partial
headuses replaced, shadowing binds renamed, dead code and unused imports dropped. CoreProgramcarries each module's command table, solask envsandlask env buildaccount for a declared command's image even when no task uses it.
Examples
- example/04-webapp drives Terraform and AWS through the
lask-terraformandlask-awsmodules instead of hand-rolled command strings, and uses two official images —hashicorp/terraformandamazon/aws-cli— in place of a self-built combined Dockerfile, removing thebuild_infra_image()task and its "forgot to call it" failure mode. Exactly one line,test_e2e, still needs an explicit environment: it wants the Playwright image for its browsers rather than for the program it runs. - example/03-terraform bumps lask-terraform to v0.1.1.
- The README says plainly what Lask does not replace: not GitHub Actions, GitLab CI or Jenkins, but what their jobs run — so switching providers rewrites one step rather than the pipeline.
Full Changelog: v0.3.0...v0.4.0
v0.3.0
A module can now carry the toolchain it needs, and a task can explain itself.
Until now a module could publish functions and types but not the environment they run in, so a module driving Terraform against AWS was unusable without an image nobody could ship. And finding out what a task took as arguments meant reading its source. v0.3.0 closes both.
Distributable modules
- Environments travel with the module.
#dockergains a recipe form —#docker(dockerfile = "infra/Dockerfile", context = ".")— so a module ships the Dockerfile for its toolchain inside its own hash-pinned tree. Registry references now require a tag or digest. - Nothing is built implicitly. Images are content-addressed by recipe hash and materialized only by
lask deps syncorlask env build. ARUNline is arbitrary code, so no implicit path may reach one. - Intent and resolution are separate files.
dependencies.lask.jsonbecomeslask.jsonand records only where a dependency comes from;lask.lock.jsonrecords the resolved graph, transitive dependencies included, and is now required. What a project executes can be read from the project. - References are pinned and checked. Tags resolve to commit SHAs, a repointed tag is
E-MODULE-REV-MOVED, and a project file that disagrees with the lock isE-MODULE-LOCK-STALE. - Dependencies live in the project. The cache moves to
.lask/deps, so a copied project works offline. - New commands.
lask deps sync|add|diff|why,--frozen, andlask env build|list. - Visibility.
export/internalmark declarations, with omission meaningexport.export { a } from "path"re-exports declarations rather than values, so keyword arguments survive. An external import reaches only a tree's entry module, so a public API spanning files is re-exported frommain.lask.
Help and documentation comments
- A documentation comment above a declaration supplies a summary, a description, and
@param/@return/@example/@hidden. lask run <function> --helpprints the signature, the doc comment, each default value, and the environments the function reaches. With no function name it lists everything the module offers, so a repository's tasks are discoverable without reading the source.- Default values are shown as source text and never evaluated; the default of a
!!parameter is shown as<secret>. lask envs <function>is now limited to that function's call graph instead of enumerating the whole module.
Breaking changes
remote,#env,environments.lask.json,--env-fileand--ssh-*are removed. A host cannot be pinned, carries state between runs, and admits no boundary; reach one withsshinside a pinned image instead.dependencies.lask.jsonis renamed tolask.json, and a committedlask.lock.jsonis required. Ahashkey left in an existing project file is accepted and ignored, so nothing has to be edited by hand.- An external import naming a path inside a dependency tree is rejected.
- Registry image references without a tag or digest are rejected.
lask inferis removed.run/evalhelp is where a user reaches for a type now; type inference itself is untouched.
Fixes
- The lock was only required to cover the project file, not to agree with it, so a reference edited in place left the pinned content behind unnoticed.
checknow compares both. deps syncshort-circuited on a cache hit keyed by content hash, so a changedrevover an unchangedhashnever re-fetched. A reference differing from the locked one now forces a fetch, surfacing the drift asE-MODULE-HASH-MISMATCH.
Full Changelog: v0.2.0...v0.3.0