Skip to content

feat(inspect): describe a module tree without running it#38

Open
rickliujh wants to merge 7 commits into
mainfrom
feat/inspect-command
Open

feat(inspect): describe a module tree without running it#38
rickliujh wants to merge 7 commits into
mainfrom
feat/inspect-command

Conversation

@rickliujh

Copy link
Copy Markdown
Owner

Adds loom inspect, a subcommand that describes what a module is made of — its submodule hierarchy, the operations each one runs, and the parameters they require — without running any of it.

Why

Composition makes a module hard to read ahead of a run. The operations that will execute live in submodules pulled from other repositories, and the parameters they need are handed down through templates rather than declared where you invoke. Until now the only way to find out what a module required was to run it and read the failure.

What it looks like

$ loom inspect ./platform-rollout -p env=prod

[≡ platform-rollout ≡] ./platform-rollout
├─ params
│    env         provided  = "prod"
│    commitHash  dynamic   $ git rev-parse --short HEAD
├─ operations (1, in order)
│    announce  shell  echo rolling out to {{ .env }}
└─ ▸ svc-a-prod (service-onboard)  ../child  if: test -d ./svc-a
   ├─ params
   │    service    provided    = "svc-a"
   │    namespace  provided    = "prod-apps" ← {{ .env }}-apps
   │    region     required    must be supplied
   │    stamp      unresolved  resolved at run time ← {{ .commitHash }}
   ├─ target  https://github.com/acme/svc-a-gitops.git (main) → loom/onboard-svc-a
   └─ operations (3, in order)
        render   newFiles  templates → manifests
        gate     shell     kubeconform manifests  if: test -f manifests/app.yaml
        open-pr  pr        github: Onboard {{ .service }}

⚠ 1 required parameter(s) not supplied — a run would fail:
  region  platform-rollout › svc-a-prod

Modules are labelled with their instance name — the parent's modules[].name, rendered — the same identity the run log and loom diff use, with metadata.name alongside when they differ. A value a parent hands down keeps the expression that produced it (← {{ .env }}-apps), so a templated hand-off stays traceable.

The summary collects every required parameter the tree is missing, each located by the module that declares it. In a composed tree that module is frequently not the one you invoke.

Flags: -p / --params-file, --depth, --no-fetch, -o tree|json.

Nothing executes

No operation runs, no dynamicParams command is evaluated, no if condition is tested, and no target repo is cloned. The sole side effect is cloning a module source that is a git URL — unavoidable if its contents are to be read — into temp dirs removed before the command returns. --no-fetch skips even that.

Templates are rendered only against params whose values are actually known, so one depending on run-time state is reported as unresolved rather than resolved to a <no value> placeholder and passed down as if real.

Reuse over new code

  • module.ResolveSource resolves root and child sources (git URLs, //subdir, local paths).
  • config.Load / Validate / ValidateInDir, split so a structural error fails a module while a missing newFiles.source only warns — the description stays available, which is the whole point of the command.
  • cmd.parseParams for -p / --params-file.
  • pkg/action/registry.go refactored so FromOperation and the new DescribeOperation read one switch. A second switch would let a new action type be wired into one and forgotten in the other, so a module could be described as one kind and run as another.
  • internal/log gained a Style type exposing the pretty handler's palette. pkg/action had already forked its own copy of the escape codes for diff headers; this stops a third copy appearing, and the tree shares the run log's ≡ … ≡ chip and hand-off marker.

Robustness

  • A failure is contained to the node it happens at, so a broken submodule never hides the rest of the tree.
  • A module that is its own ancestor is reported as a cycle and not expanded. Identity is the absolute dir for a local source and the URL for a remote one, since each remote fetch lands in a fresh temp dir.
  • --depth truncates rather than failing.

Testing

  • specs/inspect.md adds rules IN1–IN15, each referenced by a test — the spectrace guard passes with nothing added to uncovered.
  • 20 new tests across pkg/module and cmd. Full suite green, including -race.
  • Driven end to end against an offline fixture (local git target, newFiles + patch + shell + commitPush), plus remote module sources via file:// URLs, cycles, depth limits, and --no-fetch. No temp clones leaked.
  • Regression check on the registry.go refactor: run --local-run, run --dry-run, and diff --quick produce byte-identical logs and git state against a binary built from main.

Note for reviewers

-p supplies the root module only. A requirement reported deep in the tree is satisfied by the parent forwarding a value, not by another -p. This surprised me while writing the tests, so it is called out in both the reference page and the composition guide.

Commits are split by layer and each is independently green.

🤖 Generated with Claude Code

rickliujh and others added 6 commits July 25, 2026 22:29
The pretty handler's colors were reachable only from inside internal/log,
so pkg/action forked its own copy of the escape codes for diff headers. A
third surface would have forked a third copy.

Style applies the same palette to text that is not a log record, and its
zero value is uncolored so piped output stays byte-comparable. isTerminal
becomes IsTerminal for callers outside the package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Describing an operation needs the same knowledge FromOperation already
has — which field of an Operation is set. Adding a second switch for it
would let a new action type be wired into one and forgotten in the other,
so a module could be described as one kind and run as another.

classify becomes the single switch, returning the Action plus the kind and
one-line detail. FromOperation and the new DescribeOperation are both thin
readers of it, and DescribeOperation rejects exactly what FromOperation
rejects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Composition makes a module hard to read ahead of a run: the operations
that will execute live in submodules pulled from other repositories, and
the parameters they require are handed down through templates rather than
declared where you invoke. The only way to find out what a module needed
was to run it and read the failure.

Inspect walks the tree and reports it: each module's operations in
execution order, its target, and every parameter with the origin of its
value — supplied, defaulted, dynamic, or missing. A value a parent hands
down keeps the expression that produced it, so a templated hand-off stays
traceable.

Nothing executes. No operation runs, no dynamicParams command is
evaluated, and no if condition is tested; the sole side effect is cloning
a module source that is a git URL, into temp dirs removed before Inspect
returns. Templates are rendered only against params whose values are
actually known, so one depending on run-time state is reported as
unresolved rather than resolved to a placeholder.

Failures are contained to the node they happen at, so a broken submodule
never hides the rest of the tree, and a walk stops at a module that is its
own ancestor rather than recursing forever.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Renders the inspected tree as indented text, borrowing the run log's
vocabulary — the "≡ … ≡" root chip, the "▸" hand-off marker, the shared
palette — so a module looks the same whether you are inspecting it or
watching it run. Each module's params, target, operations, and submodules
hang off it as branches, so nesting reads from indentation alone.

The report closes with every required parameter the tree is still missing,
each located by the breadcrumb of the module that declares it: in a
composed tree that module is frequently not the one you invoke. Missing
params do not fail the command — reporting them is the point — but a
module that could not be described does.

-o json emits the same tree plus the missingParams and problems roll-ups,
for CI checks that want to assert a module is fully parameterized.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the CLI reference page and wires it into the nav, README, and the two
guides where it answers a question the reader already has: getting-started
gains a step for seeing what a module needs before running it, and
module-composition notes that a tree can require a value never visible at
the top level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Walking the whole tree by default made the common question — what is this
module, and what does it need — expensive to ask. A module composing half
a dozen remote submodules cloned all of them before printing anything,
and buried its own operations in a wall of output.

The default is now one module: described in full, with what it composes
listed by name. A listed module is never resolved, so the default view
does no network I/O at all. Three ways to go deeper, each explicit:
--full for the whole tree, --depth N for a level or two, and --module
NAME to make a submodule the subject — matched on the tail of its
breadcrumb, so a bare name works until two modules share one, and
qualifying it is then an error message rather than a wrong answer.

Listing rather than dropping is what makes the shallow default honest.
The summary's claims are scoped to the modules actually read, and any it
skipped are named alongside, because a tree can only be called fully
parameterized on the strength of modules someone looked at. The same
reasoning applies to exit status: a module that was never resolved
cannot have failed to resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rickliujh

Copy link
Copy Markdown
Owner Author

Reworked the default view: one module, submodules listed — expansion is now opt-in.

Walking the whole tree by default made the common question expensive to ask. A module composing several remote submodules cloned all of them before printing anything, and buried its own operations in a wall of output.

[≡ platform-rollout ≡] ./platform-rollout
├─ params
│    env         provided  = "prod"
│    commitHash  dynamic   $ git rev-parse --short HEAD
├─ operations (1, in order)
│    announce  shell  echo rolling out to {{ .env }}
├─ ▸ svc-a-prod  ../child  if: test -d ./svc-a  …
└─ ▸ svc-b  ../child  …

✔ every required parameter of the module(s) shown is satisfied

2 submodule(s) not expanded — they may need parameters of their own:
  platform-rollout › svc-a-prod
  platform-rollout › svc-b

  --full describes all of them; --module svc-a-prod describes one

Three ways to go deeper:

  • --full — the whole tree (the old default)
  • --depth N — a level or two
  • -m, --module NAME — make a submodule the subject, matched on the tail of its breadcrumb, so a bare name works until two modules share one; svc-b/docs disambiguates, and matching several is an error listing the candidates rather than a silent wrong pick

A focused module is headed by its breadcrumb and shows the values its parents actually hand it:

[≡ docs ≡] ../grandchild
in platform-rollout › svc-b › docs
├─ params
│    title  provided  = "svc-b docs" ← {{ .service }} docs

Why listing, not truncating

A module past the depth limit is still reported, carrying what its parent declares (name, source, if) and marked listed. It is never resolved — so the default view does no network I/O, and a listed remote module is not cloned.

That is also what keeps the shallow default honest. Everything the summary claims is scoped to the modules actually read, and the ones skipped are named alongside: a tree can only be called fully parameterized on the strength of modules someone looked at. Same reasoning for exit status — a module that was never resolved cannot have failed to resolve, so a broken submodule surfaces at --full, not at the default depth, where it is reported as unexpanded instead.

-o json gains an unexpanded roll-up for this, so a CI check can assert both:

loom inspect ./mod --full -p env=prod -o json | jq -e '.missingParams == [] and .unexpanded == []'

Notes

  • --full with an explicit --depth is rejected rather than silently picking a winner.
  • The summary's --module hint is verified to resolve — it qualifies the name when submodules share one, instead of suggesting a command that errors as ambiguous.
  • Spec gains IN16 (default depth) and IN17 (module selection); IN5, IN9, and IN15 updated for listing and scoping. All covered by tests.
  • Full suite green. Re-confirmed run --local-run output is still byte-identical to a binary built from main.

--module took one name, so comparing two siblings meant two invocations
and holding the first in your head. It is now repeatable: each subject is
reported with its own breadcrumb, in the order asked for, under one
summary — what you have to supply is a single list however many modules
you looked at.

Subjects can overlap, since a module and one it composes may both be
named. Nodes are shared within a walk, so trimming one subject to the
depth limit would otherwise hollow out another sitting inside it; Clone
gives each subject its own copy. The roll-ups deduplicate for the same
reason: the same missing parameter reachable through two subjects is one
parameter, not two.

The JSON document now carries a modules list rather than a single module,
of one entry when nothing was selected, so consumers index it the same way
either way. The --module hint the summary prints picks the shortest
suffix that resolves, rather than only choosing between a bare name and
the fully qualified path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant