SPEC: terramate list --format json --include-modules
Terraform/OpenTofu module references in JSON stack output. Follow-up to #2324.
Background
The JSON stack metadata output (#2324) covers stack definitions, dependencies, bundles, and components. Module references — the module blocks in .tf/.tofu files — were explicitly deferred because they require scanning Terraform HCL files and cannot fully resolve remote modules without terraform init.
This spec adds module reference scanning as an opt-in extension to the JSON output. It scans what's available on disk without requiring initialization.
CLI Interface
terramate list --format json --include-modules [other flags...]
--include-modules — opt-in: parses module blocks from .tf/.tofu files in each stack, resolves local module paths, and recursively scans nested local modules. Adds modules and all_module_paths fields to each stack object.
- Requires
--format json — errors if used without it.
- Existing
terramate list --format json behavior is unchanged when --include-modules is not specified.
Module Object
Within each stack object (when --include-modules is active):
{
"modules": [
{
"name": "vpc",
"source": "./modules/vpc",
"type": "local",
"resolved": true,
"file": {
"basename": "main.tf",
"kind": "config",
"line": 1
},
"path": {
"absolute": "/modules/vpc",
"relative": "../modules/vpc"
},
"modules": [
{
"name": "subnets",
"source": "./subnets",
"type": "local",
"resolved": true,
"file": {
"basename": "main.tf",
"kind": "config",
"line": 1
},
"path": {
"absolute": "/modules/vpc/subnets",
"relative": "../../modules/vpc/subnets"
},
"modules": []
}
]
},
{
"name": "rds",
"source": "hashicorp/rds/aws",
"type": "remote",
"resolved": false,
"file": {
"basename": "main.tf",
"kind": "config",
"line": 1
}
}
]
}
all_module_paths
A flat, deduplicated, sorted summary of all local module directory paths referenced by this stack — both direct modules and nested submodules. This is a convenience field that saves consumers from traversing the module tree.
{
"all_module_paths": [
{
"path": {
"absolute": "/modules/monitoring",
"relative": "../../modules/monitoring"
},
"types": ["module"],
"exists": false
},
{
"path": {
"absolute": "/modules/vpc",
"relative": "../../modules/vpc"
},
"types": ["module"],
"exists": true
},
{
"path": {
"absolute": "/modules/vpc/subnets",
"relative": "../../modules/vpc/subnets"
},
"types": ["module", "submodule"],
"exists": true
}
]
}
all_module_paths fields
| Field |
Type |
Description |
path |
object |
Path object (same as #2324). relative is relative to the owning stack's directory. host included when --include-host-dir is active. |
types |
string[] |
Sorted array of reference types. Values: "module" (directly referenced in stack's .tf files), "submodule" (referenced inside another module, not directly by the stack). A path can have multiple types. |
exists |
bool |
Whether the directory exists on disk. |
Behavior
- Absent when
--include-modules is not active
- Absent when active but the stack has no local module references
- Present when the stack has at least one local module reference (resolved or not)
- Includes all local module paths regardless of whether they exist on disk — the
exists field indicates filesystem status. Remote modules are excluded.
- For unresolved local modules (
exists: false), the path is computed from the raw source attribute relative to the declaring file's directory, even though the target doesn't exist on disk. This differs from the modules tree where path is absent for unresolved entries.
- When override files exist for a module, override merging is applied: the effective source is determined by sorting entries by
(file.basename, file.line), last override wins. Only the effective path is included — not all override variants.
- Deduplicated by
path.absolute — when the same directory is referenced as both a direct module and a submodule, they merge into one entry with combined types
- Sorted by
path.absolute
- The
modules and all_module_paths fields have independent presence rules
Note: Watch file paths from #2324's watch field are not included in all_module_paths. Consumers who need a combined list of all change-triggering paths can merge watch and all_module_paths themselves.
Field Reference
Module fields
| Field |
Type |
Description |
name |
string |
The module label from the module "name" {} block. Unique within each scope (enforced by Terraform). |
source |
string |
The raw source attribute value as written in HCL. For nested modules, this is relative to the parent module's directory, not the stack — use path for resolved locations. |
type |
string |
"local" for .//../ sources, "remote" for everything else |
resolved |
bool |
true if the module source was found on disk and scanned |
file |
object |
Source file info: basename (filename, e.g. "main.tf"), kind ("config" for regular .tf/.tofu files, "override" for *_override.tf/override.tf files), and line (1-based line number of the module block in the file) |
path |
object |
Path object (same as #2324). Absent when resolved is false. relative is relative to the owning stack's directory — even for nested modules, relative is relative to the stack, not the parent module. |
modules |
array |
Nested module references found inside this module. [] when resolved but has no sub-modules. Absent when resolved is false (children unknown). |
Behavior details
Local modules (type: "local"):
- Source starts with
./ or ../
- Resolved by following the path relative to the file that contains the
module block
resolved is true if the target directory exists on disk
resolved is false if the target directory doesn't exist (e.g. path typo, generated module not yet created)
- When resolved,
.tf/.tofu files in the target directory are scanned recursively for nested module blocks
- Circular references are detected and scanning stops (no infinite loops)
Remote modules (type: "remote"):
- Any source that doesn't start with
./ or ../ — includes registry (hashicorp/rds/aws), git (git::https://...), GitHub (github.com/...), S3, GCS, etc.
- Always
resolved: false in this spec
- No
path field (not on disk)
- No
modules field (children unknown without downloading)
Override files:
- Terraform override files (
override.tf, *_override.tf, and .tofu equivalents) can redefine a module's source attribute
- An override must have a matching config entry — override-only modules are invalid in Terraform
- Multiple overrides can exist for the same module name (across files or within the same file). Terraform compounds them: last override wins, ordered by
(basename lexicographic, line number ascending).
- Override file merging is not performed in the
modules tree — all module blocks are parsed from each file independently and included as separate entries
- Each module entry includes
file.kind ("config" or "override") and file.line so consumers can determine override ordering
- In
all_module_paths, override merging is applied to produce a usable list: for each module name, the effective source is determined by sorting all entries by (file.basename, file.line) — if overrides exist, the last override's resolved path is used; otherwise the config entry's resolved path is used. When an override changes a module's source, the override's resolved submodule tree (if any) replaces the config entry's submodule tree entirely in all_module_paths.
path object:
Stack-level modules field:
Example: Full stack with modules
Note: Only showing the modules and all_module_paths fields. Other stack fields as defined in #2324.
{
"id": "a1111111-1111-1111-1111-111111111111",
"name": "infrastructure",
"path": { "absolute": "/stacks/infra" },
"modules": [
{
"name": "vpc",
"source": "../../modules/vpc",
"type": "local",
"resolved": true,
"file": {
"basename": "main.tf",
"kind": "config",
"line": 1
},
"path": {
"absolute": "/modules/vpc",
"relative": "../../modules/vpc"
},
"modules": [
{
"name": "flow_logs",
"source": "./flow-logs",
"type": "local",
"resolved": true,
"file": {
"basename": "main.tf",
"kind": "config",
"line": 1
},
"path": {
"absolute": "/modules/vpc/flow-logs",
"relative": "../../modules/vpc/flow-logs"
},
"modules": []
}
]
},
{
"name": "eks",
"source": "terraform-aws-modules/eks/aws",
"type": "remote",
"resolved": false,
"file": {
"basename": "main.tf",
"kind": "config",
"line": 1
}
},
{
"name": "monitoring",
"source": "../../modules/monitoring",
"type": "local",
"resolved": false,
"file": {
"basename": "main.tf",
"kind": "config",
"line": 1
}
},
{
"name": "vpc",
"source": "../../modules/vpc-dev",
"type": "local",
"resolved": true,
"file": {
"basename": "dev_override.tf",
"kind": "override",
"line": 1
},
"path": {
"absolute": "/modules/vpc-dev",
"relative": "../../modules/vpc-dev"
},
"modules": []
}
],
"all_module_paths": [
{
"path": {
"absolute": "/modules/monitoring",
"relative": "../../modules/monitoring"
},
"types": ["module"],
"exists": false
},
{
"path": {
"absolute": "/modules/vpc-dev",
"relative": "../../modules/vpc-dev"
},
"types": ["module"],
"exists": true
}
]
}
Notes:
- The
monitoring module is type: "local" but resolved: false — the directory doesn't exist on disk. It appears in all_module_paths with exists: false (path computed from raw source).
- The
vpc module appears twice in modules — once from main.tf (kind: "config") and once from dev_override.tf (kind: "override") with a different source. Both entries are included since override merging is not performed in the modules tree.
- In
all_module_paths, override merging is applied: vpc resolves to /modules/vpc-dev (from dev_override.tf) not /modules/vpc (from main.tf). The config entry's /modules/vpc and its submodule /modules/vpc/flow-logs are not included because the override replaced the effective source.
eks is excluded from all_module_paths (remote).
Scope
In scope
- Parsing
module blocks from .tf/.tofu files in stacks
- Classifying sources as
local vs remote
- Resolving local module paths and recursively scanning nested modules
- Circular reference detection
- Source file tracking with override identification (
file.basename, file.kind)
all_module_paths convenience summary (flat list of all local module directory paths with types and existence)
--include-modules opt-in flag
Out of scope
.terraform/modules/modules.json scanning (resolving remote modules post-init) — future follow-up
- Terraform override file merging (module blocks parsed independently per file)
- Module version/constraint information (
version attribute in module blocks)
- Module input/output variable scanning
- Terragrunt module references (handled separately via existing Terragrunt integration)
- Module registry API queries
- Any operation that requires network access or
terraform init
Related Issues & PRs
| Type |
# |
Title |
Disposition |
| ISSUE |
#723 |
Json stack list output |
Partially covered (module paths in JSON output) |
| ISSUE |
#2324 |
terramate list --format json spec |
Parent spec — this extends it |
SPEC:
terramate list --format json --include-modulesTerraform/OpenTofu module references in JSON stack output. Follow-up to #2324.
Background
The JSON stack metadata output (#2324) covers stack definitions, dependencies, bundles, and components. Module references — the
moduleblocks in.tf/.tofufiles — were explicitly deferred because they require scanning Terraform HCL files and cannot fully resolve remote modules withoutterraform init.This spec adds module reference scanning as an opt-in extension to the JSON output. It scans what's available on disk without requiring initialization.
CLI Interface
--include-modules— opt-in: parsesmoduleblocks from.tf/.tofufiles in each stack, resolves local module paths, and recursively scans nested local modules. Addsmodulesandall_module_pathsfields to each stack object.--format json— errors if used without it.terramate list --format jsonbehavior is unchanged when--include-modulesis not specified.Module Object
Within each stack object (when
--include-modulesis active):{ "modules": [ { "name": "vpc", "source": "./modules/vpc", "type": "local", "resolved": true, "file": { "basename": "main.tf", "kind": "config", "line": 1 }, "path": { "absolute": "/modules/vpc", "relative": "../modules/vpc" }, "modules": [ { "name": "subnets", "source": "./subnets", "type": "local", "resolved": true, "file": { "basename": "main.tf", "kind": "config", "line": 1 }, "path": { "absolute": "/modules/vpc/subnets", "relative": "../../modules/vpc/subnets" }, "modules": [] } ] }, { "name": "rds", "source": "hashicorp/rds/aws", "type": "remote", "resolved": false, "file": { "basename": "main.tf", "kind": "config", "line": 1 } } ] }all_module_pathsA flat, deduplicated, sorted summary of all local module directory paths referenced by this stack — both direct modules and nested submodules. This is a convenience field that saves consumers from traversing the module tree.
{ "all_module_paths": [ { "path": { "absolute": "/modules/monitoring", "relative": "../../modules/monitoring" }, "types": ["module"], "exists": false }, { "path": { "absolute": "/modules/vpc", "relative": "../../modules/vpc" }, "types": ["module"], "exists": true }, { "path": { "absolute": "/modules/vpc/subnets", "relative": "../../modules/vpc/subnets" }, "types": ["module", "submodule"], "exists": true } ] }all_module_pathsfieldspathrelativeis relative to the owning stack's directory.hostincluded when--include-host-diris active.types"module"(directly referenced in stack's.tffiles),"submodule"(referenced inside another module, not directly by the stack). A path can have multiple types.existsBehavior
--include-modulesis not activeexistsfield indicates filesystem status. Remote modules are excluded.exists: false), the path is computed from the rawsourceattribute relative to the declaring file's directory, even though the target doesn't exist on disk. This differs from themodulestree wherepathis absent for unresolved entries.(file.basename, file.line), last override wins. Only the effective path is included — not all override variants.path.absolute— when the same directory is referenced as both a direct module and a submodule, they merge into one entry with combinedtypespath.absolutemodulesandall_module_pathsfields have independent presence rulesField Reference
Module fields
namemodule "name" {}block. Unique within each scope (enforced by Terraform).sourcesourceattribute value as written in HCL. For nested modules, this is relative to the parent module's directory, not the stack — usepathfor resolved locations.type"local"for.//../sources,"remote"for everything elseresolvedtrueif the module source was found on disk and scannedfilebasename(filename, e.g."main.tf"),kind("config"for regular.tf/.tofufiles,"override"for*_override.tf/override.tffiles), andline(1-based line number of themoduleblock in the file)pathresolvedisfalse.relativeis relative to the owning stack's directory — even for nested modules,relativeis relative to the stack, not the parent module.modules[]when resolved but has no sub-modules. Absent whenresolvedisfalse(children unknown).Behavior details
Local modules (
type: "local"):./or../moduleblockresolvedistrueif the target directory exists on diskresolvedisfalseif the target directory doesn't exist (e.g. path typo, generated module not yet created).tf/.tofufiles in the target directory are scanned recursively for nestedmoduleblocksRemote modules (
type: "remote"):./or../— includes registry (hashicorp/rds/aws), git (git::https://...), GitHub (github.com/...), S3, GCS, etc.resolved: falsein this specpathfield (not on disk)modulesfield (children unknown without downloading)Override files:
override.tf,*_override.tf, and.tofuequivalents) can redefine a module'ssourceattribute(basename lexicographic, line number ascending).modulestree — allmoduleblocks are parsed from each file independently and included as separate entriesfile.kind("config"or"override") andfile.lineso consumers can determine override orderingall_module_paths, override merging is applied to produce a usable list: for each modulename, the effective source is determined by sorting all entries by(file.basename, file.line)— if overrides exist, the last override's resolved path is used; otherwise the config entry's resolved path is used. When an override changes a module's source, the override's resolved submodule tree (if any) replaces the config entry's submodule tree entirely inall_module_paths.pathobject:terramate list --format json— Machine-readable structured output #2324absolute— project-absolute path to the module directoryrelative— relative to the owning stack's directory (matches how local sources are written)hostsub-object included when--include-host-diris also activeStack-level
modulesfield:--include-modulesis not active (consistent with [SPEC]terramate list --format json— Machine-readable structured output #2324 absent-when-not-applicable pattern).tf/.tofufiles or nomoduleblocksExample: Full stack with modules
{ "id": "a1111111-1111-1111-1111-111111111111", "name": "infrastructure", "path": { "absolute": "/stacks/infra" }, "modules": [ { "name": "vpc", "source": "../../modules/vpc", "type": "local", "resolved": true, "file": { "basename": "main.tf", "kind": "config", "line": 1 }, "path": { "absolute": "/modules/vpc", "relative": "../../modules/vpc" }, "modules": [ { "name": "flow_logs", "source": "./flow-logs", "type": "local", "resolved": true, "file": { "basename": "main.tf", "kind": "config", "line": 1 }, "path": { "absolute": "/modules/vpc/flow-logs", "relative": "../../modules/vpc/flow-logs" }, "modules": [] } ] }, { "name": "eks", "source": "terraform-aws-modules/eks/aws", "type": "remote", "resolved": false, "file": { "basename": "main.tf", "kind": "config", "line": 1 } }, { "name": "monitoring", "source": "../../modules/monitoring", "type": "local", "resolved": false, "file": { "basename": "main.tf", "kind": "config", "line": 1 } }, { "name": "vpc", "source": "../../modules/vpc-dev", "type": "local", "resolved": true, "file": { "basename": "dev_override.tf", "kind": "override", "line": 1 }, "path": { "absolute": "/modules/vpc-dev", "relative": "../../modules/vpc-dev" }, "modules": [] } ], "all_module_paths": [ { "path": { "absolute": "/modules/monitoring", "relative": "../../modules/monitoring" }, "types": ["module"], "exists": false }, { "path": { "absolute": "/modules/vpc-dev", "relative": "../../modules/vpc-dev" }, "types": ["module"], "exists": true } ] }Notes:
monitoringmodule istype: "local"butresolved: false— the directory doesn't exist on disk. It appears inall_module_pathswithexists: false(path computed from rawsource).vpcmodule appears twice inmodules— once frommain.tf(kind: "config") and once fromdev_override.tf(kind: "override") with a different source. Both entries are included since override merging is not performed in themodulestree.all_module_paths, override merging is applied:vpcresolves to/modules/vpc-dev(fromdev_override.tf) not/modules/vpc(frommain.tf). The config entry's/modules/vpcand its submodule/modules/vpc/flow-logsare not included because the override replaced the effective source.eksis excluded fromall_module_paths(remote).Scope
In scope
moduleblocks from.tf/.tofufiles in stackslocalvsremotefile.basename,file.kind)all_module_pathsconvenience summary (flat list of all local module directory paths with types and existence)--include-modulesopt-in flagOut of scope
.terraform/modules/modules.jsonscanning (resolving remote modules post-init) — future follow-upversionattribute inmoduleblocks)terraform initRelated Issues & PRs
terramate list --format jsonspec