@dotzen/dotzen 0.0.2 → 0.0.4

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.
package/README.md CHANGED
@@ -22,13 +22,37 @@ export const spec = [
22
22
 
23
23
  Each finding is `block` (fails the build), `warn`, or `require_approval` (pauses CI for sign-off). When a value can't be resolved statically, dotzen reports **"could not evaluate"** rather than guessing — a false positive is worse than an honest gap.
24
24
 
25
+ **Org-specific tags:** tag taxonomies vary by org, so `mustHaveTags` accepts your own keys — declare them in your own `enum` (keeps typos as compile errors) and mix with the built-in `Tag`:
26
+
27
+ ```ts
28
+ enum OrgTag {
29
+ ApmId = 'apm_id',
30
+ CmdbAppId = 'cmdb_app_id',
31
+ }
32
+ rule()
33
+ .resource(AwsResource.S3Bucket)
34
+ .mustHaveTags(OrgTag.ApmId, OrgTag.CmdbAppId, Tag.Environment)
35
+ .message('...')
36
+ ```
37
+
38
+ dotzen resolves tags through `var`/`local` refs and `merge(<literal>, var.tags)` — so a required key hardcoded in the module passes, while one that might come from a caller's `var.tags` reports "could not evaluate" (never a false violation).
39
+
25
40
  ## Getting started
26
41
 
27
42
  ```bash
28
43
  npx @dotzen/dotzen init # scaffold .zen/spec.ts + dotzen.json
44
+ npm i -D @dotzen/dotzen # editor autocomplete + type-checking for spec.ts
29
45
  npx @dotzen/dotzen check # evaluate ./terraform against the spec
30
46
  ```
31
47
 
48
+ Two modes, both supported:
49
+
50
+ - **Authoring** — install as a devDependency so your editor resolves the
51
+ DSL types (`import { rule } from '@dotzen/dotzen'`) and gives you
52
+ autocomplete + compile-time safety while you write rules.
53
+ - **Running** — `npx @dotzen/dotzen check` stays zero-install (nothing to add
54
+ to your project) — ideal for CI. The engine resolves the DSL import itself.
55
+
32
56
  - `--format json` for machine-readable output.
33
57
  - Pin the version in `dotzen.json` (never `@latest` in CI).
34
58
 
package/dist/cli/main.js CHANGED
@@ -96,6 +96,9 @@ function runInit(dir, terraform) {
96
96
  process.stdout.write(` created ${c}\n`);
97
97
  for (const s of res.skipped)
98
98
  process.stdout.write(` skipped ${s} (already exists)\n`);
99
+ process.stdout.write('\nFor editor autocomplete + type-checking of .zen/spec.ts, install the\n' +
100
+ 'types locally: npm i -D @dotzen/dotzen (and add node_modules/ to\n' +
101
+ '.gitignore). Running `check` via npx needs no local install.\n');
99
102
  if (res.detected) {
100
103
  const roots = Array.isArray(res.terraform) ? res.terraform : [res.terraform];
101
104
  const fmt = (r) => typeof r === 'string' ? `"${r}"` : `"${r.path}" (${r.environment})`;
@@ -117,9 +117,17 @@ function evalMustHaveTags(c, r) {
117
117
  };
118
118
  const present = new Set(r.tags.keys);
119
119
  const missing = c.tags.filter((t) => !present.has(t));
120
- if (missing.length > 0)
121
- return { kind: 'violation', detail: `missing tags: ${missing.join(', ')}` };
122
- return { kind: 'pass' };
120
+ if (missing.length === 0)
121
+ return { kind: 'pass' };
122
+ // A `partial` set (e.g. merge(<literal>, var.tags)) proves presence but not
123
+ // absence — a var arg may supply the missing key, so don't claim a
124
+ // violation; degrade honestly instead.
125
+ if (r.tags.kind === 'partial')
126
+ return {
127
+ kind: 'cannotEvaluate',
128
+ reason: `tag(s) not in the resolvable portion (a var/merge arg may add them): ${missing.join(', ')}`,
129
+ };
130
+ return { kind: 'violation', detail: `missing tags: ${missing.join(', ')}` };
123
131
  }
124
132
  const isLiteralTrue = (v) => v?.kind === 'literal' && v.value === true;
125
133
  /**
@@ -24,6 +24,9 @@ export interface IngressRule {
24
24
  export type TagsInfo = {
25
25
  readonly kind: 'resolved';
26
26
  readonly keys: string[];
27
+ } | {
28
+ readonly kind: 'partial';
29
+ readonly keys: string[];
27
30
  } | {
28
31
  readonly kind: 'unresolved';
29
32
  };
@@ -284,20 +284,63 @@ function egressFor(block, scope) {
284
284
  ...dynamicBlocks(block, 'egress', scope),
285
285
  ];
286
286
  }
287
+ // Object-literal keys (`ident = …`) in an HCL expression string. A single
288
+ // `=` (not `==`/`>=`/etc.) is HCL's attribute-assignment operator, so this
289
+ // reliably picks up flat map keys inside a `merge(...)` argument.
290
+ const OBJECT_KEY = /([A-Za-z_][A-Za-z0-9_-]*)\s*=(?!=)/g;
291
+ // `var.x` / `local.y` reference tokens mentioned in an expression.
292
+ const REF_TOKEN = /\b(var|local)\.([A-Za-z0-9_-]+)/g;
287
293
  /**
288
- * Tags: a literal map gives us the present keys; a `${...}` expression
289
- * (var/merge/local) is unresolved; an absent block is resolved-but-empty
290
- * (the literal AI-generated case no tags written means none present).
294
+ * Keys known to be present on a tags value, and whether that set is
295
+ * COMPLETE. Follows sole `var`/`local` references to their value, and
296
+ * unions the literal keys (and resolvable refs) inside a `merge(...)`.
297
+ * Returns `null` when nothing can be determined (an unresolvable reference
298
+ * or an opaque expression) — which becomes "could not evaluate".
291
299
  */
292
- function tagsOf(block) {
293
- const t = block?.tags;
294
- if (t === undefined)
295
- return { kind: 'resolved', keys: [] };
296
- if (typeof t === 'string' && isInterpolated(t))
300
+ function tagKeys(value, scope, depth = 8) {
301
+ if (value === undefined)
302
+ return { keys: [], complete: true };
303
+ if (value && typeof value === 'object' && !Array.isArray(value))
304
+ return { keys: Object.keys(value), complete: true };
305
+ if (typeof value !== 'string')
306
+ return null;
307
+ const ref = SOLE_REF.exec(value); // exactly one ${var.x} / ${local.y}
308
+ if (ref) {
309
+ if (depth <= 0)
310
+ return null;
311
+ const key = `${ref[1]}.${ref[2]}`;
312
+ return scope.has(key) ? tagKeys(scope.get(key), scope, depth - 1) : null;
313
+ }
314
+ if (/\bmerge\s*\(/.test(value)) {
315
+ // merge() only ever ADDS keys, so presence is monotonic. Collect the
316
+ // inline literal keys plus keys from any resolvable ref args; mark the
317
+ // set incomplete (a var/unresolved arg may contribute more).
318
+ const keys = new Set(value.match(OBJECT_KEY)?.map((m) => m.replace(/\s*=$/, '')) ?? []);
319
+ for (const [, kind, name] of value.matchAll(REF_TOKEN)) {
320
+ const scopeKey = `${kind}.${name}`;
321
+ if (depth > 0 && scope.has(scopeKey)) {
322
+ const sub = tagKeys(scope.get(scopeKey), scope, depth - 1);
323
+ if (sub)
324
+ for (const k of sub.keys)
325
+ keys.add(k);
326
+ }
327
+ }
328
+ return { keys: [...keys], complete: false };
329
+ }
330
+ return null; // some other unresolvable expression
331
+ }
332
+ /**
333
+ * Tags: a literal map (or a `var`/`local` reference resolved to one) gives
334
+ * a complete key set; `merge(<literal>, var.tags)` gives a PARTIAL set
335
+ * (known-present keys, may be more); anything else is unresolved.
336
+ */
337
+ function tagsOf(block, scope) {
338
+ const r = tagKeys(block?.tags, scope);
339
+ if (r === null)
297
340
  return { kind: 'unresolved' };
298
- if (t && typeof t === 'object' && !Array.isArray(t))
299
- return { kind: 'resolved', keys: Object.keys(t) };
300
- return { kind: 'unresolved' };
341
+ return r.complete
342
+ ? { kind: 'resolved', keys: r.keys }
343
+ : { kind: 'partial', keys: r.keys };
301
344
  }
302
345
  /** Resolved value of the `environment` tag (for rule scoping), if present. */
303
346
  function environmentOf(block, scope) {
@@ -445,7 +488,7 @@ function normalize(parsed, file, rawText, scope = new Map(), environmentOverride
445
488
  line: findLine(rawText, type, name),
446
489
  ingress: ingressFor(type, block, scope),
447
490
  egress: egressFor(block, scope),
448
- tags: tagsOf(block),
491
+ tags: tagsOf(block, scope),
449
492
  attributes: extracted.attributes,
450
493
  lists: extracted.lists,
451
494
  blocks: extracted.blocks,
@@ -63,7 +63,15 @@ function renderTerminal(report, opts = {}) {
63
63
  }
64
64
  lines.push('');
65
65
  if (report.violations.length === 0) {
66
- lines.push(`${paint('✓ passed', ANSI.green)} (${report.passed} checks, ${paint(`${cne} could not be evaluated`, ANSI.magenta)})`);
66
+ if (cne === 0) {
67
+ // Reserve the unqualified green check for a truly complete pass.
68
+ lines.push(`${paint('✓ passed', ANSI.green)} (${report.passed} checks)`);
69
+ }
70
+ else {
71
+ // No violations, but some checks couldn't be evaluated — those are
72
+ // gaps to review, not successes, so don't show a clean green ✓.
73
+ lines.push(`${paint('⚠ no violations', ANSI.magenta)}, but ${paint(`${cne} could not be evaluated`, ANSI.magenta)} — review the section above (${report.passed} passed)`);
74
+ }
67
75
  }
68
76
  else {
69
77
  const count = `${report.violations.length} violation(s)`;
@@ -18,7 +18,7 @@ export type Condition = {
18
18
  readonly from: Cidr[];
19
19
  } | {
20
20
  readonly kind: 'mustHaveTags';
21
- readonly tags: Tag[];
21
+ readonly tags: string[];
22
22
  } | {
23
23
  readonly kind: 'mustBeTrue';
24
24
  readonly attrs: AnyAttribute[];
@@ -112,7 +112,19 @@ export declare class RuleBuilder {
112
112
  environment(env: Environment): this;
113
113
  denyIngress(...ports: Port[]): this;
114
114
  denyEgress(...ports: Port[]): this;
115
- mustHaveTags(...tags: Tag[]): this;
115
+ /**
116
+ * Required tag KEYS. Accepts the built-in `Tag` enum AND org-specific
117
+ * keys — because tag taxonomies are org-defined, unlike cloud-fixed
118
+ * resource types/ports. Always back org keys with your OWN `enum` (never
119
+ * bare strings) so typos stay compile errors:
120
+ *
121
+ * enum OrgTag { ApmId = 'apm_id', CmdbAppId = 'cmdb_app_id' }
122
+ * rule().resource(...).mustHaveTags(OrgTag.ApmId, Tag.Environment)
123
+ *
124
+ * The `string & {}` keeps `Tag` autocomplete while allowing your enum's
125
+ * (string-valued) members through.
126
+ */
127
+ mustHaveTags(...tags: (Tag | (string & {}))[]): this;
116
128
  mustBeTrue(...attrs: AnyAttribute[]): this;
117
129
  /** AnyAttribute must be explicitly false; absent counts as a violation
118
130
  * (use for attributes whose insecure AWS default is `true`). */
package/dist/spec/rule.js CHANGED
@@ -44,6 +44,18 @@ class RuleBuilder {
44
44
  });
45
45
  return this;
46
46
  }
47
+ /**
48
+ * Required tag KEYS. Accepts the built-in `Tag` enum AND org-specific
49
+ * keys — because tag taxonomies are org-defined, unlike cloud-fixed
50
+ * resource types/ports. Always back org keys with your OWN `enum` (never
51
+ * bare strings) so typos stay compile errors:
52
+ *
53
+ * enum OrgTag { ApmId = 'apm_id', CmdbAppId = 'cmdb_app_id' }
54
+ * rule().resource(...).mustHaveTags(OrgTag.ApmId, Tag.Environment)
55
+ *
56
+ * The `string & {}` keeps `Tag` autocomplete while allowing your enum's
57
+ * (string-valued) members through.
58
+ */
47
59
  mustHaveTags(...tags) {
48
60
  this._conditions.push({ kind: 'mustHaveTags', tags });
49
61
  return this;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotzen/dotzen",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Prose as Code — governance for AI-generated Terraform (AWS, Azure, GCP).",
5
5
  "license": "MIT",
6
6
  "keywords": [