The library
waypost is a terminal UI and a web view built on top of one
library: @sksizer/detect-runners. Everything on this page
belongs to that library — it has no dependency on either UI, and everything
either UI shows comes from calling it.
collect()
Section titled “collect()”function collect(root: string, options?: CollectOptions): Promise<Catalog>Collection is a function, not a build step or a cached snapshot: every call
re-probes the directory. collect() itself holds nothing across calls, so a
catalog cannot silently go stale after someone adds a task — storing an answer
is a separate layer a caller opts into, described under The cache
below. Providers run concurrently —
probing moon costs roughly 700ms and probing just costs roughly 5ms, and run
serially those add up, while in parallel the slowest one sets the pace.
CollectOptions:
interface CollectOptions { providers?: RunnerProvider[] // defaults to builtinProviders only?: string[] // restrict to these provider ids exclude?: string[] // skip these provider ids allowExec?: boolean // defaults to false discovery?: 'git' | 'walk' | 'auto' // defaults to "auto" execWithheldHint?: string // appended to the diagnostic on a withheld runner excludePaths?: string[] // paths to leave out of discovery: a bare name // matches any segment, a value with a slash // matches a path from the root makeRunner?: (binary: string) => AsyncCommandRunner // defaults to the real subprocess seam which?: (binary: string) => string | null // defaults to the real PATH lookup probeObserver?: (probe: ProbeRecord) => void // what each provider's run measured and touched signal?: AbortSignal}discover(root, options?) is the cheaper sibling: it returns just the ids of
runners that are present, without enumerating their tasks.
Parse and exec providers
Section titled “Parse and exec providers”allowExec defaults to false. This is the single most important thing to
understand before pointing the library at a directory you did not write.
Some runners can only be read by asking them to describe themselves. moon query tasks runs the target repository’s own toolchain configuration — that
is arbitrary code, chosen by whoever wrote that repo, not by you. mise
merges task definitions across several config files and can execute [hooks]
while doing it. Reading those runners is not “parsing a file” — it is running
the target repository’s build tooling.
So every provider declares which kind it is:
type ProviderKind = 'parse' | 'exec'parse is a floor, not a promise never to spawn. A parse provider always
has an answer from files alone, so it is safe to run against any directory,
including one you have never seen before — and collect(root, { allowExec: false }), the default, runs only parse providers. An exec provider
cannot answer at all without spawning, so it is withheld entirely, reported
rather than silently dropped:
{ runner: "moon", status: "absent", skippedForExec: true, diagnostics: [{ severity: "info", message: "moon needs to run a subprocess…" }],}just is kind: "parse" despite being able to shell out: it has two paths —
just --dump when the binary is available and execution is allowed, and a
source scan of the justfile otherwise. The fallback finds the same recipes;
it just cannot see docs, parameter types, or private flags, and says so in
diagnostics.
collect() is deliberately trust-agnostic. It never reads the trust store and
has no idea one exists: allowExec is a plain boolean the caller already
resolved, so a programmatic consumer passing false is never affected by a
trust decision it did not ask about. The CLIs resolve the store above it,
combining the configured value, the flag and the per-repository record into
that one boolean — see Trust & execution. execWithheldHint is
the other half of that seam: the CLI hands down the sentence it wants a user
to read on a withheld runner, because the library cannot write it.
Checking whether a tool is installed is not execution. ctx.which() is a PATH
lookup with no subprocess, so it is available and safe under
allowExec: false.
Built-in runners
Section titled “Built-in runners”The library ships a set of built-in providers. None has a privilege your own provider would lack — see Extending.
| id | detects | kind |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| moon | .moon/workspace.yml; reads via moon query tasks / projects | exec |
| mise | mise.toml, mise-tasks/; reads via mise tasks --json | exec |
| just | justfile; reads via just --dump when exec is allowed, else a source scan | parse |
| node-scripts | package.json scripts, verb derived from the lockfile | parse |
| make | Makefile targets at column 0 | parse |
| cargo | Cargo.toml; synthesizes the conventional cargo verbs | parse |
| sbt | build.sbt, project/build.properties; reads via sbt projects tasks | exec |
| python | pyproject.toml’s configured [tool.*] sections, and [project.scripts] / [tool.poetry.scripts] routed by the lockfile’s manager | parse |
| composer | composer.json scripts | parse |
| deno-tasks | deno.json / deno.jsonc tasks | parse |
| turbo | turbo.json’s v2 tasks, with a v1 pipeline fallback | parse |
| maven | pom.xml; synthesizes the conventional lifecycle verbs | parse |
| swiftpm | Package.swift; synthesizes the conventional swift build/test/run verbs | parse |
| gradle | build.gradle(.kts), settings.gradle(.kts); reads via gradle tasks --all when exec is allowed, else synthesizes the lifecycle verbs; uses ./gradlew when a wrapper is present | parse |
| go-task | Taskfile.yml and its variants; reads via task --list-all --json when exec is allowed, else a narrow reader over the tasks: map | parse |
| rake | Rakefile; reads via rake -AT when exec is allowed, else a scan of the Ruby source | parse |
| docker-compose | compose.yaml and its three siblings, one winner per directory; the services: map read by a narrow hand-written YAML reader. Tasks are executes: "container" | parse |
| github-actions | .github/workflows/*.yml at the repository root; only workflows declaring a workflow_dispatch trigger become tasks, because only those can be dispatched. Tasks are executes: "remote" | parse |
builtinProviders is exported in this order and is also the order a
catalog’s reports appear in, before any runners.order configuration
reorders them.
The cache
Section titled “The cache”Some runners are slow to ask: moon starts its toolchain, sbt starts a JVM, mise
reads and merges a config tree. The library ships a store above collect() so a
surface can paint something at once and refresh behind it. It is a LAYER a caller
opts into, never something the library does behind a caller’s back — a
programmatic consumer that asked for a probe gets a probe.
import { cacheDir, collectAndCache, readCachedCatalog } from '@sksizer/detect-runners'
const store = cacheDir()// Paints now: hashes a few files, probes nothing.const stored = await readCachedCatalog(root, options, store)// Probes for real and stores what came back.const fresh = await collectAndCache(root, options, store)readCachedCatalog returns { catalog, runners, allFresh }. runners carries
one verdict per selected provider — { runner, state, reason?, ageMs?, estimateMs, slow }, where state is "fresh", "stale" or "miss". Stale
rows are kept and labelled rather than dropped: an answer that was true an hour
ago beats an empty screen, as long as the screen says which it is. The function
never throws — an unreadable store, an unlistable root, a fingerprint that cannot
be computed all degrade to a miss, which is the state a caller would have been in
with no cache at all.
collectAndCache returns collect()’s catalog untouched but for one truncation
diagnostic (below). Writing is best-effort: a read-only home or a full disk costs
the next run some time and never fails the collection.
estimateMs is the MEDIAN of a rolling window of the last five probe durations
measured in this repository, or null when nothing has been measured. Null is
not zero, and a consumer must not print a cost it does not have. slow is that
measurement crossing a threshold — measured, never declared, so a runner that is
quick in your repository is not labelled slow because it is slow in someone
else’s. There is no slow field on the provider contract.
Where it lives, and why not beside trust
Section titled “Where it lives, and why not beside trust”$XDG_CACHE_HOME/waypost/, or ~/.cache/waypost/. One file per (root, provider),
written to a temp file and renamed, and format-versioned both in the path and
inside each file.
XDG cache, deliberately not the ~/.local/state/waypost/ the trust store uses.
Losing this directory costs time and nothing else: a user who clears their caches
pays one slow probe. Losing the trust store would silently un-trust repositories,
and a consent record a cache sweep can erase is not a consent record. Everything
here is reconstructible by asking again; nothing in the trust store is.
The cache is no part of the execution gate. A cached answer is never a trust
grant, clearing the cache never changes what is trusted, and neither an errored
report nor one carrying skippedForExec is ever stored — writeEntry refuses
both, so an untrusted run cannot poison the answer a later trusted run should
give. Under allowExec: false a cached read reports each exec provider as fresh
and carries the same withheld report collect() would have produced, from the
same helper. Trust is resolved above collect() by @sksizer/sdlc-tool’s
policyExecGate, which never reads the cache — see
Trust & execution.
Invalidation is a content hash, never an mtime
Section titled “Invalidation is a content hash, never an mtime”A stored entry carries a digest over the bytes of the files it depends on.
touching a justfile does not invalidate it, and neither does a rewrite to
identical bytes — which is what a formatter, a git checkout of the same
content, or a rebuilt generated file does all day.
For a parse provider the coverage is close to exact, and free:
ProviderContext mediates every read a provider makes, so recording what it
touched records precisely what it read.
Reads alone would not be enough. A provider that discovers by listing — every
.github/workflows/*.yml, every compose.yaml below the root — depends on the
answer to its query, not only on the files that answer happened to name. A file
added later matching the same pattern was never read, so it moves no recorded
hash. The patterns a provider passes to ctx.files() are therefore recorded
beside the paths and re-expanded on every read: watch patterns it never had to
declare.
For an exec provider it is deliberately incomplete, and worth stating
plainly. Such a provider reads only its markers, while its TOOL merges files
the provider never opens — moon’s .moon/tasks/** and each project’s own
moon.yml, mise’s nested mise.toml files, sbt’s project/**.
RunnerProvider.watch closes the part a
basename can express, and exec entries additionally expire after four hours as a
backstop for what a fingerprint cannot see.
Even then it is a good signal, not a proof. A task set can change with no watched
file changing at all: a plugin version resolving differently, a global
~/.config/mise/config.toml, an environment variable a build script reads. Every
consumer of a cached answer must tolerate a stale hit.
One more limit: a fingerprint hashes at most 256 paths. A run that hits that cap
adds an info diagnostic to the runner’s report rather than letting the entry
look fresher than it is.
./duration
Section titled “./duration”import { humanAge, humanMs } from '@sksizer/detect-runners/duration'The two formatters every surface reporting on the cache needs: humanMs(1423)
is "1.4s", and humanAge(35_000) is "35s old" while humanAge(undefined) is
"of unknown age" — a miss has no age, and reading as "0s old" would claim a
freshness nothing measured. Both are also re-exported from the package entry;
the subpath exists so a browser bundle can take them without dragging in
node:fs, exactly as ./match does.
Only the number’s spelling is shared. The sentence around it is each surface’s
own: the CLIs say 35s old, refresh usually about 149ms, the terminal UI says
~149ms, the page says never timed here.
./match
Section titled “./match”import { exact, fuzzy, matchEntry, terms } from '@sksizer/detect-runners/match'Ranked search over catalog entries — the one matcher the terminal UI and the web view both use, so the same keystrokes rank the same way regardless of which one you’re in. It has its own export subpath because the web view loads it transpiled for the browser, and it is deliberately kept free of Node builtins and DOM APIs so that build doesn’t break.
The query language: terms are whitespace-separated and ANDed, every term must
match some field; a leading ' forces that term to match as a literal
substring rather than a fuzzy subsequence; mode: "exact" forces every term
that way; matching is case-insensitive throughout.
matchEntry(entry, terms, mode) scores one Matchable (a structural subset
of Task: path, display, doc, detail, tags) against a query,
returning null if any term misses, or a score plus the offsets that
matched, so a caller can highlight them.