CLI
Commands, flags, output, and exit codes of the spyglass binary.
The same scans and server are available from code: see the library.
Usage
Spyglass — workspace analysis and a local viewer
spyglass [path...] Scan, serve, and open the browser
spyglass scan [path...] Scan and save each workspace's dataset
spyglass serve [path...] Serve the saved datasets without scanning
spyglass report <package> [path...] Print a package report from the saved dataset
spyglass lens <id> [path...] Print a lens from the saved dataset
spyglass check [path...] Check the workspace's rules against the saved dataset
spyglass plan <opportunity> [path...] Plan one opportunity and authorize it; writes nothing in the workspace
spyglass apply <opportunity> [path...] Preview a planned change; write and verify it with --write
spyglass export <dir> [path...] Write a static viewer of the saved datasets to <dir>
The path defaults to the current directory. spyglass, scan, serve, and export read
several paths as one collection; the other commands read one workspace.
A directory named like a command is written as a path: ./scan, ./serve, ./report, ./lens, ./check, ./plan, ./apply, ./export.
--state <path> State root (default: ~/.foundry/spyglass)
--history spyglass, scan: analyze Git checkpoints as well
--port <number> spyglass, serve: local port (default: 4173; 0 chooses a free port)
--no-open spyglass, serve: do not open the browser
--write apply: write the change, verify it, then scan again
--format <format> report, lens, check, plan, apply: terminal, text, markdown (default: terminal; text when piped)
--density <density> report, lens, check, plan, apply: compact, normal, expanded (default: normal)
--json Print the result as one line of JSON
--quiet Print no progress
--help, -h Show this help
Exit codes: 0 done, 1 failed, 2 bad arguments, 3 a rule broken (check).
The first argument is a command only when it is exactly scan, serve,
report, lens, check, plan, apply, or export; anything else is
the workspace path, for the default command. A directory with a command’s name is written as a path,
spyglass ./report. The path defaults to the current directory and must be
an existing directory.
Several workspaces
spyglass, scan, serve, and export read two or more paths as one
collection: workspaces read together, such as a product split across
repositories that depend on each other. One path runs exactly as it always
has.
- Each workspace is scanned on its own, one after the other, with its own state, dataset, cache, and lock; nothing is saved for the collection. What ties them is the collection manifest, derived from the saved datasets: each workspace, its packages qualified by its workspace id, and the crossings, a package’s declared dependency on a package of another workspace. See output.
- The workspaces are ordered by resolved root, whatever the order given. A
workspace inside another exits
2:Workspace <outer> holds workspace <inner>; the workspaces of a collection cannot overlap. - Two paths that name one workspace, the same path twice or a path and a
link to it, exit
2, naming the path given twice:<path> names the same workspace as <earlier path>, <root>; give each workspace once. report,lens,check,plan, andapplyread one workspace’s dataset and refuse a second path with exit2:spyglass report reads one workspace's dataset; give one path.- When a workspace’s scan fails, the workspaces scanned before it keep
their saved datasets, and the command fails with
Spyglass could not scan <root>: <message>.
Flags
| Flag | Commands | Default | Effect |
|---|---|---|---|
--state <path> |
all | ~/.foundry/spyglass |
The state root. Each workspace’s state directory, <state>/<id>/, holds its workspace record, output, and cache; see output. |
--history |
spyglass, scan |
off | Analyze Git checkpoints as well; see history. |
--port <number> |
spyglass, serve |
4173 |
The port to serve on, 0 to 65535. 0 picks a free port. |
--no-open |
spyglass, serve |
open | Do not open the browser after serving. |
--write |
apply |
off | Write the change, verify it, then scan again; see apply. |
--format <format> |
report, lens, check, plan, apply |
terminal on a terminal, text when piped |
terminal, text, or markdown. |
--density <density> |
report, lens, check, plan, apply |
normal |
compact, normal, or expanded. |
--json |
all | off | Print the result as one line of JSON instead of text. |
--quiet |
all | off | Print no progress. |
--help, -h |
Print the help and exit. |
An unknown flag is an error, even next to --help. Otherwise --help
wins over the rest of the line. A flag the command does not take is an
error too: spyglass serve does not take --history.
What it prints
- stdout carries the command’s result and nothing else. stderr carries progress, warnings, and errors.
- Progress goes to stderr. In a terminal (stderr and stdin both TTYs), a
live display takes the bottom rows: the phase and its elapsed time, each
phase done with its duration, a bar of packages analyzed, the packages
being analyzed and the latest finished, as analyzed, reused, or failed.
scanandapplydraw the full panel;opendraws two rows, since the browser is where it is heading. When the scan ends the display clears and one summary line stays:Scanned 16 packages in 41s: 3 analyzed, 13 reused.Ctrl-C ends the scan as it always did, exit 130. - Piped, progress is one line per phase, then
internals: <package>per package; for a collection,workspace: <root>before each workspace’s lines.--quietsilences progress in both forms, and nothing else.NO_COLORkeeps the live display but draws it without colour. scanends withDataset saved to <dir>on stdout; for a collection, one such line per workspace, thenCollection: <n> workspaces, <n> crossings.reportprints the package report, rendered for the format.lensprints the lens, rendered for the format.checkprints theruleslens, rendered for the format, and exits3when a rule is broken.applyprints the outcome, each statement it edits or would edit, before and after, and after a write every check it ran; scan progress goes to stderr.planprints the verdict for one opportunity, its planned changes, what stops it, and the verification an apply would run, rendered for the format.exportprintsStatic viewer saved to <dir>.- A server prints
Spyglass: <url>andWorkspace: <root>on stdout, oneWorkspace:line per workspace of a collection, then runs untilSIGINTorSIGTERM, such as Ctrl-C, which stop it and exit0. The default command prints the scan’s line, then the server’s. - A failure prints
Spyglass: <message>on stderr and nothing more on stdout; the default command may already have printed `Dataset saved to` when serving then fails. A usage error adds `Run spyglass --help for usage.`
--json
With --json, stdout is one line: the library’s result as JSON.
| Command | The line |
|---|---|
scan |
{ "manifest", "output", "history"? }: the dataset’s manifest, the directory it is saved in, and history’s manifest when the scan includes it |
report |
The package report exactly as the dataset holds it, output/packages/<name>.json |
lens |
The lens’s output exactly as the dataset holds it, output/lenses/<id>.json |
check |
{ "generatedAt", "outcome", "lens" }: the checked dataset’s generatedAt, clean, violations, or incomplete, and the rules lens as saved |
plan |
{ "site", "opportunity", "plan", "verdict", "record"? }: the symbol as path#name, the opportunity’s id, the saved plan as its package report holds it, the verdict (its kind, with readiness’s assessment once readiness judged the plan, and the record when authorized), and the path the record was saved to, only when authorized |
apply |
{ "site", "planId", "mode", "status", "refusal"?, "edits", "changedFiles", "verification"?, "scan"?, "timings"? }: mode is preview or write; status is preview, refused, applied, or rolled-back; refusal says why, by kind; edits are { file, before, after }, after null for a removed statement; verification holds every check after a write; scan is the scan after it, { status, output, durationMs, error? }; timings how long the workspace’s scripts ran before and after the write |
export |
{ "directory", "manifest" }: where the static viewer is, and the manifest of the dataset it holds |
serve |
{ "root", "url" }, printed once the server listens |
spyglass |
One line once the server listens: the scan’s fields and the server’s, { "manifest", "output", "history"?, "root", "url" } |
scan of a collection |
{ "manifest", "workspaces" }: the collection manifest, and each workspace’s { "manifest", "output", "history"? } in the collection’s order |
export of a collection |
{ "directory", "manifest", "workspaces" }: where the static viewer is, the collection manifest it holds, and each workspace’s { "id", "root", "dataset" }, dataset being the directory its dataset was copied to |
serve of a collection |
{ "roots", "url" }: each workspace root in the collection’s order |
spyglass of a collection |
{ "manifest", "workspaces", "roots", "url" }: the collection scan’s fields and the server’s |
--json does not change stderr: progress still goes there, live or as
lines, unless --quiet is given. report --json, lens --json, check --json, and
plan --json, and apply --json take no --format or --density.
check --json and apply --json exit as the command does.
Exit codes
| Code | When |
|---|---|
0 |
The command finished, or --help; for check, no rule is broken, or none is declared; for plan, any verdict, refused and blocked included; for apply, a preview, or a write that verified and whose scan published |
1 |
The command ran and failed: a path that does not exist or is not a directory, a package that failed analysis, a workspace being scanned, exported, or applied, --history without Git, the viewer’s files missing, a port in use, no saved dataset or one with another schema version, an unknown package, a dataset saved without the lens asked for or with another version of it, a directory export refuses, check could not check a require rule in a package that failed analysis, which outranks any violation, plan or apply was given an opportunity the dataset does not hold, found the dataset stale, or found an unfinished apply, apply found no record or refused, rolled back, or wrote a verified change whose final swap then failed, or check found an unfinished apply |
2 |
Bad arguments: an unknown flag, a flag the command does not take, a bad value, an unknown lens, a missing package, lens, opportunity, or directory, an opportunity of another shape, two paths that name one workspace, two paths of which one holds the other, or a second path for report, lens, check, plan, or apply |
130, 143 |
apply --write was stopped by Ctrl-C (130) or SIGTERM (143), and restored every file it wrote |
3 |
check only: a rule is broken |
Codes above 2 are for commands’ own results: spyglass check exits 3 on
a violation, and spyglass apply exits 130 or 143 once a write Ctrl-C or
SIGTERM interrupted is restored, so that a CI job can tell a broken rule from a failed run.
This differs from Marbles, whose usage errors exit 1; from ESLint, where
1 means lint errors and 2 a failed run; and from tsc, where 1 and
2 both mean type errors.
A browser that fails to open is not a failure: the URL is printed on stderr
instead, with or without --quiet.
Report
spyglass report <package> [path...] prints one package’s report from the
saved dataset; it scans nothing. The package is its package.json name.
An unknown name fails and lists the dataset’s packages. terminal uses
colour unless NO_COLOR is set, and wraps to the terminal’s width; piped
output is 80 columns wide. Every format draws with Unicode glyphs.
markdown suits documentation and pull requests.
Lens
spyglass lens <id> [path...] prints one lens from the saved dataset; it scans
nothing, and renders as report does. Every scan saves every lens; see
output for each one’s fields.
| Lens | What it shows |
|---|---|
domains |
The declared domains: the places each one’s facets name, the facets that name none, and a domain label for each placed place in a domain |
file-roles |
Each package’s files by role, and its high fan-in files |
frequent-change |
The files at or above the hotspots’ commit gate |
import-traffic |
Each package pair the imports cross, with the files at each end |
nesting |
The folder tree, path regions, and the seams between them |
package-sizes |
Each package’s dependency layer and files by kind |
placement |
Where each place is used against where it lives: fits, spread, or departs |
proximity |
What sits close without being related, and what is related without sitting close |
responsibility-groups |
Each package’s responsibilities joined into groups |
rules |
The declared rules against the dataset: each place that breaks a require rule, with its saved plan’s status and blocker reasons, the rules whose subject names no place, and the packages a require could not be checked in |
An unknown id is a usage error that lists the ids. A dataset saved before a lens existed, or with another version of it, fails and asks for a new scan.
Check
spyglass check [path...] checks the workspace’s rules
against the saved dataset; it scans nothing and runs no configuration. It
prints the rules lens that the last scan saved, rendered as lens
renders, so a rule edited since then takes a scan to show: a warm scan
takes a few seconds, since rules invalidate no package. In CI, run
spyglass scan --quiet and then spyglass check.
A require of internalize means that, under its subject, no
package-public symbol is unused outside its package. Each such symbol is a
violation, listed with its saved plan’s status and blocker reasons: a
publishable package’s exports are blocked, since consumers outside the
workspace may import them and Spyglass cannot see them. A permit breaks
nothing.
| Exit | When |
|---|---|
0 |
No rule is broken, or no rule is declared (the output says so) |
3 |
At least one rule is broken |
1 |
The check could not run, or a require rule meets a package that failed analysis, which outranks any violation |
A rule whose subject names no place in the dataset, matched as a domain’s facet is, is listed as unmatched and breaks nothing; check its spelling.
When a require meets a package that failed analysis, check exits 1
and names the rule and the package on stderr. A package that keeps failing
under a wide require, such as one on packages, makes every check exit
1; narrowing the subject to the packages that analyze fixes it.
Plan
spyglass plan <opportunity> [path...] plans one internalize opportunity from
the saved dataset and asks whether Spyglass may apply it. The opportunity is
written as check lists it, internalize-symbol:<path#name>, as its saved
plan’s id, plan:internalize-symbol:<path#name>, or as the bare
<path#name>. Any other shape is a usage error; an opportunity the dataset
does not hold fails.
It takes the rules from the rules lens the last scan saved, as check
does, so it runs no configuration, and a rule edited since then takes a
scan to count. It answers with one verdict, checked cheapest first:
| Verdict | When |
|---|---|
refused-by-rule |
No permit or require rule of internalize covers the symbol. The output names a rule that would. Nothing runs. |
not-executable |
No mutator executes the plan: the saved plan is not ready, or it needs an edit internalize-export does not make. A publishable package’s plan is blocked, and the output says why. Nothing runs. |
readiness |
Readiness judged the plan and did not authorize it: stale, unsupported, blocked, such as a test that fails before any change, or not-authorized, such as a package with no test script. |
authorized |
Every check passed. |
Before it authorizes, readiness runs the baseline of each verification
step: the package’s test script, and its typecheck script where it has
one; without one, Spyglass checks the types itself. So plan runs the
workspace’s own scripts, with CI=1, which can take minutes. Spyglass writes
nothing in the workspace, though the scripts may write what they always
write, such as coverage or build info.
An authorized plan’s record, ids and fingerprints only, is saved to
plans/<digest>.json in the workspace’s state directory; see
output. Any other verdict deletes the
opportunity’s earlier record, so a record exists only while the last plan
authorized it. A later apply authorizes again before it writes, so a record
is never a promise that the sources still match.
plan takes no lock: it can run while a scan does, and its scripts can run
beside another command’s. It refuses while an apply’s journal,
apply/journal.json in the state directory, exists.
The saved plan is the last scan’s, so plan first checks that the saved
dataset is current, and fails at once when it is not; see
current.
A current dataset
A scan records one hash of everything its analyses read: every source
file, Git’s commits and tracked files, the UTC day it ran on, the
configuration’s exclusions and profile, and the analysis version. plan
and apply recompute that hash, in about two seconds on a workspace of
1,500 files, and go on only when it is the recorded one. So a dataset is
current on the UTC day it was scanned, with no commit and no source edit
since. Otherwise they fail with “The saved dataset is stale”, and
spyglass scan makes it current again; after a commit or on a new UTC day
that scan derives the workspace again, which is most of a cold scan’s time. A dataset saved before Spyglass recorded the hash cannot be shown current
either.
plan runs no configuration, so it takes the exclusions-and-profile part
of the hash from the dataset rather than from the configuration: an edited
exclusion is caught by apply, which loads the configuration, and not by
plan. Rules are not part of the hash; apply compares them with the
record.
Apply
spyglass apply <opportunity> [path...] applies the plan spyglass plan
authorized for an opportunity, written as plan takes it. Without
--write it is a preview: it prints each statement it would edit, before
and after, and Spyglass writes no source file. With --write it writes the
change, verifies it, and scans again.
Apply never trusts the record on its own. Holding the state directory’s lock, in both modes, it:
- finishes an earlier apply that did not: one interrupted while it wrote
is restored from its journal, which it reports and exits
1; one that wrote and verified its change waits forspyglass scan; - loads the configuration as it is now, and refuses a dataset that is not current, before any script runs: the saved package report is what the write is checked against;
- authorizes the plan again, which runs the workspace’s test and typecheck scripts again, and refuses unless the result is the record’s. A refusal names what moved, in this order: the sources, with the files, the rules, the mutator’s capabilities, the verification, the plan, and the rollback; “the Spyglass that made the record” when only the record’s hash differs, as after a Spyglass upgrade;
- refuses a plan whose rollback cannot restore every file it writes, or that creates or deletes a file;
- refuses when a file it writes has uncommitted changes; an unrelated
dirty file, or one the plan only reads, does not stop it.
--writeneeds Git: outside a repository, without Git, or with a repository Git cannot read, it refuses and says which; - checks the saved plan against the saved package report, which a current dataset holds as a fresh analysis would. “Before” is fixed there, when currency is checked: a file edited elsewhere while the scripts run is not caught by these checks, but by the verification after the write, which reads the staged scan of the sources as they then are, and rolls back.
Then, with --write:
- it checks each file it writes against the hash the rollback recorded, just before writing;
- it saves the journal,
apply/journal.jsonin the state directory, with each file’s original bytes, and flushes it to disk before the first write; - it writes each file atomically, keeping its mode and any byte order mark;
- it checks that no new TypeScript error appeared in the package, that the symbol still exists and no public route exposes it, that its external use is still none, and that the package’s surface changed as the plan predicted; then it runs the test and typecheck scripts again, each of which must pass, or show no failure it did not show before the write;
- on any failed check or error it restores every file it wrote, byte for
byte, with its mode, and exits
1; a script killed on its own, as by its ten-minute timeout or too much output, is a failed check. On Ctrl-C orSIGTERM, during the write or any check, the scripts included, it restores them too, says it was interrupted, and exits130for Ctrl-C,143forSIGTERM; a second signal does not stop the restore. A script that traps the signal and exits0does not hide it. A file that changed after Spyglass wrote it is not overwritten: the journal stays, and the error names the file and the journal, which keeps every original. Restore that file from Git (itsHEADcontent) and the nextspyglass applyfinishes the restore; delete the journal to keep the files as they are; - the structural checks read the scan after the write: it scans again, internals included, staged beside the dataset, keeping the dataset’s history, since history covers past commits and apply commits nothing. A scan that fails leaves nothing to check against, so it fails the write, which is restored. On any failure the staged dataset is discarded and the published one is untouched;
- once every check passes, the staged dataset is swapped in and recorded, and the record, if it is still the one it loaded, and the journal are deleted.
If only that last swap, or recording it, fails, the change stays written
and verified, the journal stays, and apply exits 1: run spyglass scan,
which publishes the dataset and then deletes the journal. Until then
plan, apply, and check refuse, and a scan refuses while an apply’s
write was interrupted, until spyglass apply restores it.
A write that rolls back leaves the scan cache holding the written sources’ analysis, so the next scan analyzes the package again and derives the workspace again, as after any one-file edit.
Each write and restore replaces the file by a rename, so it keeps the
bytes and the mode, but not another user’s ownership, extended attributes,
ACLs, or other hard links to the file. A crash can leave a
<file>.<uuid>.tmp beside a file; it is safe to delete. The scripts run
again after the write may write what they always write, such as coverage.
| Exit | When |
|---|---|
0 |
A preview printed, or a change written, verified, and scanned |
1 |
No record, a refusal (a stale dataset included), a rollback (a scan after the write that failed included), an earlier interrupted write restored, or a verified write whose final swap failed |
2 |
Bad arguments, such as --write on another command |
130, 143 |
Ctrl-C (130) or SIGTERM (143) stopped the write, and every file it wrote is restored |
A refusal’s kind says why: pending-write (an earlier apply did not
finish), refused-by-rule, not-executable, or readiness (authorizing
again did not authorize it), authorization-changed (with the parts that
moved), rollback (the plan creates or deletes a file, or a file it writes
cannot be restored), dirty (with the files), no-git (with the
reason), or revalidation (the mutator’s fresh analysis found the saved
plan stale, blocked, or unsupported, with its blockers). stale-dataset (the dataset is not current, with the
reason) refuses before anything runs. timings
is { baselineMs, afterMs }: how long the workspace’s scripts ran under the
lock before the write, and again after it.
Export
spyglass export <dir> [path...] writes a static viewer: the viewer’s files at
the root of <dir>, the workspace’s saved dataset, whole, under
<dir>/data/, and .spyglass-static-viewer.json, which lists the top-level
entries Spyglass wrote. Any static file host serves it, under any path; the
viewer reads ./data/ relative to its page. It scans nothing.
<dir> must be absent, an empty directory (a .DS_Store is ignored), or a
previous static viewer that holds nothing but what its
.spyglass-static-viewer.json lists. A previous static viewer is replaced only
once the new one is complete; a failed export leaves it as it was, though a
crash can leave <dir>.export-<id> or <dir>.previous-<id> beside it.
Anything else is refused. The check covers the top level only: a file you
add inside one of Spyglass’s own entries, such as data/, is replaced with it. The refusals:
<dir> holds files Spyglass did not write, such as <entry>. Choose another directory.for anything else in it, such as a.gitdirectory or aCNAMEfile.<dir> is not a directory. Choose another directory.and<dir> is a symbolic link; Spyglass does not write through links. Choose another directory.<dir>must not hold the workspace, and must not overlap the state directory or the viewer’s files.
With several paths, spyglass export <dir> <path> <path>... writes the
collection as the server serves it: each workspace’s saved dataset, whole,
under <dir>/data/<workspace id>/, and <dir>/data/collection-manifest.json,
derived from the copied datasets; there is no <dir>/data/manifest.json.
<dir> must not hold any of the workspaces or overlap any of their state
directories. It holds every workspace’s lock while it copies, and refuses
while any of them has an unfinished apply. The viewer cannot show a
collection yet.
To publish from a Git checkout, such as a gh-pages branch, export into a
subdirectory of it, or export elsewhere and copy the files in. A static
viewer inside the workspace is allowed; a later scan analyzes its files only
if a package pattern of the workspace covers them.
An export holds the state directory’s lock while it copies, as a scan does, so it fails at once while a scan of the workspace runs, and a scan fails while it copies. It refuses while an apply is unfinished, since the dataset may then not describe the sources. An apply holds the lock too, for as long as it authorizes the plan again, its test and typecheck scripts included, even for a preview: a scan or export started meanwhile fails at once.
The server
serve binds to 127.0.0.1 on the chosen port and answers GET and
HEAD only. /data/<path> serves output/<path> byte for byte with
Cache-Control: no-store. Every other path serves the bundled viewer, with
/ as index.html. A path that resolves outside those two directories,
including through a symlink, is 404. The cache is never served. There is
no API and no directory listing.
For a collection, /data/collection-manifest.json is the collection
manifest, derived from the saved datasets at each request, and
/data/<workspace id>/<path> serves that workspace’s output/<path>. Any
other path under /data/, an unknown workspace id included, is 404.
While a workspace’s dataset cannot be read, such as while a scan swaps it,
the collection manifest is 503 with Retry-After: 1 and a message
naming the workspace. The viewer draws a collection as one archipelago
per workspace, side by side, with the crossings between them drawn on the
map and listed in Place.
The default command checks for the viewer’s files before it scans, so a
checkout without a build reports The viewer's files are missing at once.
A port in use is only found once the scan has saved the dataset; serve it
on another port with spyglass serve --port <number>.
serve and export first check for the viewer’s files. Then serve,
report, lens, and export read the saved dataset’s manifest, and refuse a
workspace with no saved dataset or one saved with another schema version. A workspace whose directory has been removed
fails at once, since its path no longer resolves, even though its dataset is
still in the state root. serve takes no lock: a scan that runs while it
serves replaces the dataset atomically, and later requests see the new one.