@dotzen/dotzen 0.1.1 → 0.1.2

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.
@@ -306,8 +306,55 @@ function egressFor(block, scope) {
306
306
  // `=` (not `==`/`>=`/etc.) is HCL's attribute-assignment operator, so this
307
307
  // reliably picks up flat map keys inside a `merge(...)` argument.
308
308
  const OBJECT_KEY = /([A-Za-z_][A-Za-z0-9_-]*)\s*=(?!=)/g;
309
- // `var.x` / `local.y` reference tokens mentioned in an expression.
310
- const REF_TOKEN = /\b(var|local)\.([A-Za-z0-9_-]+)/g;
309
+ // A whole argument that is exactly one `var.x` / `local.y` ref (optionally
310
+ // `${…}`-wrapped). Only such TOP-LEVEL merge args contribute tag keys — refs
311
+ // inside an object literal's *values* (e.g. `Ou = var.ou`) must not count.
312
+ const ARG_REF = /^\$?\{?\s*(var|local)\.([A-Za-z0-9_-]+)\s*\}?$/;
313
+ /** The substring inside the outermost `merge( … )`, or null if unbalanced. */
314
+ function mergeInner(value) {
315
+ const m = /^merge\s*\(/.exec(value);
316
+ if (!m)
317
+ return null;
318
+ let depth = 0;
319
+ const start = m[0].length;
320
+ for (let i = start; i < value.length; i++) {
321
+ const c = value[i];
322
+ if (c === '(')
323
+ depth++;
324
+ else if (c === ')') {
325
+ if (depth === 0)
326
+ return value.slice(start, i);
327
+ depth--;
328
+ }
329
+ }
330
+ return null;
331
+ }
332
+ /** Split an argument list on top-level commas, respecting (), {}, [], quotes. */
333
+ function splitTopLevelArgs(inner) {
334
+ const args = [];
335
+ let depth = 0;
336
+ let start = 0;
337
+ let quote = null;
338
+ for (let i = 0; i < inner.length; i++) {
339
+ const c = inner[i];
340
+ if (quote) {
341
+ if (c === quote && inner[i - 1] !== '\\')
342
+ quote = null;
343
+ }
344
+ else if (c === '"' || c === "'")
345
+ quote = c;
346
+ else if (c === '(' || c === '{' || c === '[')
347
+ depth++;
348
+ else if (c === ')' || c === '}' || c === ']')
349
+ depth--;
350
+ else if (c === ',' && depth === 0) {
351
+ args.push(inner.slice(start, i));
352
+ start = i + 1;
353
+ }
354
+ }
355
+ args.push(inner.slice(start));
356
+ return args.map((a) => a.trim()).filter((a) => a.length > 0);
357
+ }
311
358
  /**
312
359
  * Keys known to be present on a tags value, and whether that set is
313
360
  * COMPLETE. Follows sole `var`/`local` references to their value, and
@@ -329,21 +376,50 @@ function tagKeys(value, scope, depth = 8) {
329
376
  const key = `${ref[1]}.${ref[2]}`;
330
377
  return scope.has(key) ? tagKeys(scope.get(key), scope, depth - 1) : null;
331
378
  }
332
- if (/\bmerge\s*\(/.test(value)) {
333
- // merge() only ever ADDS keys, so presence is monotonic. Collect the
334
- // inline literal keys plus keys from any resolvable ref args; mark the
335
- // set incomplete (a var/unresolved arg may contribute more).
336
- const keys = new Set(value.match(OBJECT_KEY)?.map((m) => m.replace(/\s*=$/, '')) ?? []);
337
- for (const [, kind, name] of value.matchAll(REF_TOKEN)) {
338
- const scopeKey = `${kind}.${name}`;
339
- if (depth > 0 && scope.has(scopeKey)) {
340
- const sub = tagKeys(scope.get(scopeKey), scope, depth - 1);
341
- if (sub)
379
+ // Only treat `merge(...)` as the TOP-LEVEL call (strip a `${…}` wrapper
380
+ // first). merge nested inside another function is opaque could-not-eval.
381
+ const stripped = value.replace(/^\$\{/, '').replace(/\}$/, '').trim();
382
+ if (/^merge\s*\(/.test(stripped)) {
383
+ const inner = mergeInner(stripped);
384
+ if (inner === null)
385
+ return null;
386
+ // merge() only ADDS keys, so the result is COMPLETE iff every top-level
387
+ // argument is fully knowable: an object literal, or a ref that resolves
388
+ // to a complete map. An unresolvable ref or an opaque expression (e.g. a
389
+ // function call) means more keys could appear → PARTIAL, so a missing
390
+ // required tag degrades to could-not-evaluate rather than a false claim.
391
+ const keys = new Set();
392
+ let complete = true;
393
+ for (const arg of splitTopLevelArgs(inner)) {
394
+ const refMatch = ARG_REF.exec(arg);
395
+ if (arg.startsWith('{')) {
396
+ // Object literal: keys are the `ident =` pairs (tag maps are flat);
397
+ // the values — which may themselves mention refs — are irrelevant.
398
+ for (const m of arg.match(OBJECT_KEY) ?? [])
399
+ keys.add(m.replace(/\s*=$/, ''));
400
+ }
401
+ else if (refMatch) {
402
+ const scopeKey = `${refMatch[1]}.${refMatch[2]}`;
403
+ const sub = depth > 0 && scope.has(scopeKey)
404
+ ? tagKeys(scope.get(scopeKey), scope, depth - 1)
405
+ : null;
406
+ if (sub === null) {
407
+ complete = false; // unresolvable ref — could add unknown keys
408
+ }
409
+ else {
410
+ // Proven-present keys count even from a partial sub (merge only
411
+ // adds); a partial sub still means the whole set is incomplete.
342
412
  for (const k of sub.keys)
343
413
  keys.add(k);
414
+ if (!sub.complete)
415
+ complete = false;
416
+ }
417
+ }
418
+ else {
419
+ complete = false; // opaque arg (function call, etc.) may add keys
344
420
  }
345
421
  }
346
- return { keys: [...keys], complete: false };
422
+ return { keys: [...keys], complete };
347
423
  }
348
424
  return null; // some other unresolvable expression
349
425
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotzen/dotzen",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Prose as Code — governance for AI-generated Terraform (AWS, Azure, GCP).",
5
5
  "license": "MIT",
6
6
  "keywords": [