@dotzen/dotzen 0.0.4 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,11 +4,15 @@ export interface Hcl2JsonRoot {
4
4
  resource?: Record<string, Record<string, unknown[]>>;
5
5
  variable?: Record<string, unknown[]>;
6
6
  locals?: unknown[];
7
+ /** `module "x" { source = …, <inputs> }` → `{ x: [{ source, … }] }`. */
8
+ module?: Record<string, unknown[]>;
7
9
  }
8
10
  /** Resolved `var.*` / `local.*` values, keyed by reference, raw form. */
9
11
  export type Scope = Map<string, unknown>;
10
12
  /** Collect `variable` defaults and `locals` from all parsed files. */
11
13
  export declare function buildScope(roots: Hcl2JsonRoot[]): Scope;
14
+ /** Follow a sole var/local reference to its raw resolved value (or undefined). */
15
+ export declare function resolveRaw(raw: unknown, scope: Scope, depth?: number): unknown | undefined;
12
16
  /**
13
17
  * Adapter boundary (doc 06): parser output -> dotzen's own model.
14
18
  * The engine never sees `Hcl2JsonRoot`. `scope` resolves var/local refs.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildScope = buildScope;
4
+ exports.resolveRaw = resolveRaw;
4
5
  exports.normalize = normalize;
5
6
  const vocabulary_1 = require("../vocabulary");
6
7
  const KNOWN_TYPES = new Set([
@@ -60,11 +61,28 @@ function buildScope(roots) {
60
61
  }
61
62
  function mapIngressObj(o, scope) {
62
63
  const oo = asObject(o);
63
- const cidrs = Array.isArray(oo.cidr_blocks) ? oo.cidr_blocks : [];
64
+ const raw = oo.cidr_blocks;
65
+ let cidrBlocks;
66
+ if (Array.isArray(raw)) {
67
+ cidrBlocks = raw.map((c) => resolveValue(c, scope));
68
+ }
69
+ else if (typeof raw === 'string') {
70
+ // A whole-list reference, e.g. `cidr_blocks = var.allowed_cidrs`
71
+ // (common in modules). Follow it to a concrete list; if it can't be
72
+ // resolved, keep it as one unresolved element so the check honestly
73
+ // degrades to "could not evaluate" instead of silently passing.
74
+ const resolved = resolveRaw(raw, scope);
75
+ cidrBlocks = Array.isArray(resolved)
76
+ ? resolved.map((c) => resolveValue(c, scope))
77
+ : [{ kind: 'unresolved', expr: raw }];
78
+ }
79
+ else {
80
+ cidrBlocks = [];
81
+ }
64
82
  return {
65
83
  fromPort: resolveValue(oo.from_port, scope),
66
84
  toPort: resolveValue(oo.to_port, scope),
67
- cidrBlocks: cidrs.map((c) => resolveValue(c, scope)),
85
+ cidrBlocks,
68
86
  };
69
87
  }
70
88
  /** Inline `<name> { ... }` blocks (name is 'ingress' or 'egress'). */
@@ -8,6 +8,8 @@ import { NormalizedResource } from './model';
8
8
  * `projectRoot` (defaults to `dir`) so output is readable and portable —
9
9
  * and, for multi-root layouts, shows which root each finding came from.
10
10
  * A single `parseTf` call builds ONE scope, so calling it once per root
11
- * keeps each root's `var`/`local` values isolated.
11
+ * keeps each root's `var`/`local` values isolated. Local `module {}` calls
12
+ * are followed (doc 08), evaluating the module's resources with the
13
+ * caller's inputs threaded in as `var.*`.
12
14
  */
13
15
  export declare function parseTf(dir: string, projectRoot?: string, environmentOverride?: string): Promise<Result<NormalizedResource[], DotzenError>>;
package/dist/hcl/parse.js CHANGED
@@ -44,27 +44,16 @@ function findTfFiles(dir) {
44
44
  return entries.filter((e) => e.endsWith('.tf')).map((e) => path.join(dir, e));
45
45
  }
46
46
  const toPosix = (p) => p.split(path.sep).join('/');
47
- /**
48
- * Read a terraform directory, parse each .tf via the official parser
49
- * (hcl2json / WASM), and normalize into dotzen's model. Async because
50
- * the WASM parser is async. Reported file paths are made relative to
51
- * `projectRoot` (defaults to `dir`) so output is readable and portable —
52
- * and, for multi-root layouts, shows which root each finding came from.
53
- * A single `parseTf` call builds ONE scope, so calling it once per root
54
- * keeps each root's `var`/`local` values isolated.
55
- */
56
- async function parseTf(dir, projectRoot = dir, environmentOverride) {
47
+ /** Parse every `.tf` under `dir` (no normalization). */
48
+ async function parseDir(dir) {
57
49
  if (!fs.existsSync(dir))
58
50
  return (0, result_1.err)({ kind: 'PathNotFound', path: dir });
59
- const files = findTfFiles(dir);
60
- // Pass 1: parse every file (variables/locals may live in a different
61
- // file from the resources that reference them).
62
- const parsedFiles = [];
63
- for (const file of files) {
51
+ const out = [];
52
+ for (const file of findTfFiles(dir)) {
64
53
  const text = fs.readFileSync(file, 'utf8');
65
54
  try {
66
55
  const parsed = (await (0, hcl2json_1.parse)(path.basename(file), text));
67
- parsedFiles.push({ file, text, parsed });
56
+ out.push({ file, text, parsed });
68
57
  }
69
58
  catch (e) {
70
59
  return (0, result_1.err)({
@@ -74,12 +63,91 @@ async function parseTf(dir, projectRoot = dir, environmentOverride) {
74
63
  });
75
64
  }
76
65
  }
77
- // Pass 2: build the cross-file var/local scope, then normalize with it.
78
- const scope = (0, normalize_1.buildScope)(parsedFiles.map((p) => p.parsed));
66
+ return (0, result_1.ok)(out);
67
+ }
68
+ // Keys on a `module` block that are meta, not `var` inputs (doc 08).
69
+ const MODULE_META = new Set([
70
+ 'source',
71
+ 'version',
72
+ 'count',
73
+ 'for_each',
74
+ 'providers',
75
+ 'depends_on',
76
+ 'lifecycle',
77
+ ]);
78
+ // A local relative source we can follow (doc 08 v1: local sources only).
79
+ const isLocalSource = (s) => typeof s === 'string' && (s.startsWith('./') || s.startsWith('../'));
80
+ /**
81
+ * Module-following (doc 08). For each local `module { source, <inputs> }`
82
+ * call in the caller's files, parse the module dir and normalize its
83
+ * resources with a scope = the module's own defaults/locals overlaid with
84
+ * the caller's inputs (resolved in the caller's scope) as `var.*`. This is
85
+ * what turns caller-`var` values (cidrs, tags) into concrete verdicts.
86
+ * v1: local sources, single level, no count/for_each.
87
+ */
88
+ async function followModules(callerFiles, rootDir, projectRoot, callerScope, environmentOverride) {
89
+ const rootRel = toPosix(path.relative(projectRoot, rootDir));
90
+ const out = [];
91
+ for (const { file, parsed } of callerFiles) {
92
+ for (const [, calls] of Object.entries(parsed.module ?? {})) {
93
+ for (const raw of Array.isArray(calls) ? calls : []) {
94
+ const block = (raw ?? {});
95
+ if (!isLocalSource(block.source))
96
+ continue; // remote → not followed
97
+ const moduleDir = path.resolve(path.dirname(file), block.source);
98
+ // Confine to the scanned project; never escape it.
99
+ if (path.relative(projectRoot, moduleDir).startsWith('..'))
100
+ continue;
101
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- moduleDir confined to projectRoot above
102
+ if (!fs.existsSync(moduleDir))
103
+ continue;
104
+ const parsedModule = await parseDir(moduleDir);
105
+ if (!parsedModule.ok)
106
+ return parsedModule;
107
+ // Module scope: its own defaults/locals, then caller inputs win.
108
+ const moduleScope = (0, normalize_1.buildScope)(parsedModule.value.map((f) => f.parsed));
109
+ for (const [name, value] of Object.entries(block)) {
110
+ if (MODULE_META.has(name))
111
+ continue;
112
+ const resolved = (0, normalize_1.resolveRaw)(value, callerScope);
113
+ moduleScope.set(`var.${name}`, resolved !== undefined ? resolved : value);
114
+ }
115
+ for (const m of parsedModule.value) {
116
+ const modRel = toPosix(path.relative(projectRoot, m.file));
117
+ const trace = rootRel ? `${rootRel} › ${modRel}` : modRel;
118
+ out.push(...(0, normalize_1.normalize)(m.parsed, trace, m.text, moduleScope, environmentOverride));
119
+ }
120
+ }
121
+ }
122
+ }
123
+ return (0, result_1.ok)(out);
124
+ }
125
+ /**
126
+ * Read a terraform directory, parse each .tf via the official parser
127
+ * (hcl2json / WASM), and normalize into dotzen's model. Async because
128
+ * the WASM parser is async. Reported file paths are made relative to
129
+ * `projectRoot` (defaults to `dir`) so output is readable and portable —
130
+ * and, for multi-root layouts, shows which root each finding came from.
131
+ * A single `parseTf` call builds ONE scope, so calling it once per root
132
+ * keeps each root's `var`/`local` values isolated. Local `module {}` calls
133
+ * are followed (doc 08), evaluating the module's resources with the
134
+ * caller's inputs threaded in as `var.*`.
135
+ */
136
+ async function parseTf(dir, projectRoot = dir, environmentOverride) {
137
+ const parsedFiles = await parseDir(dir);
138
+ if (!parsedFiles.ok)
139
+ return parsedFiles;
140
+ // Build the cross-file var/local scope, then normalize direct resources.
141
+ const scope = (0, normalize_1.buildScope)(parsedFiles.value.map((p) => p.parsed));
79
142
  const resources = [];
80
- for (const { file, text, parsed } of parsedFiles) {
143
+ for (const { file, text, parsed } of parsedFiles.value) {
81
144
  const rel = toPosix(path.relative(projectRoot, file));
82
145
  resources.push(...(0, normalize_1.normalize)(parsed, rel, text, scope, environmentOverride));
83
146
  }
147
+ // Follow local module calls, threading caller inputs into their vars.
148
+ const followed = await followModules(parsedFiles.value, dir, projectRoot, scope, environmentOverride);
149
+ if (!followed.ok)
150
+ return followed;
151
+ resources.push(...followed.value);
84
152
  return (0, result_1.ok)(resources);
85
153
  }
@@ -32,6 +32,7 @@ export declare enum AwsResource {
32
32
  EcsTaskDefinition = "aws_ecs_task_definition",
33
33
  SecretsmanagerSecretVersion = "aws_secretsmanager_secret_version",
34
34
  RdsCluster = "aws_rds_cluster",
35
+ RdsClusterInstance = "aws_rds_cluster_instance",
35
36
  RedshiftCluster = "aws_redshift_cluster",
36
37
  ElasticacheReplicationGroup = "aws_elasticache_replication_group",
37
38
  S3BucketServerSideEncryptionConfiguration = "aws_s3_bucket_server_side_encryption_configuration",
@@ -37,6 +37,7 @@ var AwsResource;
37
37
  AwsResource["EcsTaskDefinition"] = "aws_ecs_task_definition";
38
38
  AwsResource["SecretsmanagerSecretVersion"] = "aws_secretsmanager_secret_version";
39
39
  AwsResource["RdsCluster"] = "aws_rds_cluster";
40
+ AwsResource["RdsClusterInstance"] = "aws_rds_cluster_instance";
40
41
  AwsResource["RedshiftCluster"] = "aws_redshift_cluster";
41
42
  AwsResource["ElasticacheReplicationGroup"] = "aws_elasticache_replication_group";
42
43
  AwsResource["S3BucketServerSideEncryptionConfiguration"] = "aws_s3_bucket_server_side_encryption_configuration";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotzen/dotzen",
3
- "version": "0.0.4",
3
+ "version": "0.1.1",
4
4
  "description": "Prose as Code — governance for AI-generated Terraform (AWS, Azure, GCP).",
5
5
  "license": "MIT",
6
6
  "keywords": [