@intentius/chant 0.76.0 → 0.77.0

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.
@@ -9,5 +9,11 @@ import type { CommandContext } from "../registry.js";
9
9
  * `--format` projects the component DAG itself into that format (nodes =
10
10
  * components, wave groups, `dependsOn` edges) — the graph behold renders.
11
11
  */
12
+ /**
13
+ * One `chant graph` is one read of the account (chant #2498): the live
14
+ * observation and the `--traffic` prediction are two plugin methods that
15
+ * each read a live root, and inside this session a read one of them made
16
+ * is the read the other gets.
17
+ */
12
18
  export declare function runGraph(ctx: CommandContext): Promise<number>;
13
19
  //# sourceMappingURL=graph.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/graph.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAE,cAAc,EAAc,MAAM,aAAa,CAAC;AAkH9D;;;;;;;;;GASG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAwCnE"}
1
+ {"version":3,"file":"graph.d.ts","sourceRoot":"","sources":["../../../src/cli/handlers/graph.ts"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAAE,cAAc,EAAc,MAAM,aAAa,CAAC;AAkH9D;;;;;;;;;GASG;AACH;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAE7D"}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * One command, one read of the account (chant #2498).
3
+ *
4
+ * A `chant graph --live --traffic` against a live root reads the account
5
+ * twice: once for the graph's observation and once for the prediction, and
6
+ * each of those used to run `live-plan` twice for the document and its human
7
+ * render. Four `live-plan` runs for one answer, which on a drifted estate
8
+ * whose provider retries a deleted resource was five minutes against a
9
+ * consumer's three-minute budget.
10
+ *
11
+ * Nothing shared a read because nothing could: a plugin method takes options
12
+ * alone and cannot be handed the documents another method already holds. So
13
+ * the sharing is ambient instead. A command that wants its reads shared runs
14
+ * inside {@link withLiveReadSession}; a read that can be shared calls
15
+ * {@link memoLiveRead} with a key naming everything that would change its
16
+ * answer (root, directory, binary, flags, environment), and gets the pending
17
+ * or settled promise of an identical read made earlier in the same session.
18
+ *
19
+ * The scope is the session, never the process. A watch Op ticking on a
20
+ * schedule runs the same activity every tick and must see the account move,
21
+ * so outside a session {@link memoLiveRead} simply reads. A rejection is
22
+ * never memoised either: the next caller tries again, since a read that
23
+ * failed on credentials or a timeout says nothing about the next one.
24
+ *
25
+ * `AsyncLocalStorage` carries the session across every `await` under the
26
+ * wrapped call, which is how two plugin methods called one after the other
27
+ * by one handler share it without either knowing about the other.
28
+ */
29
+ /**
30
+ * Run `fn` with a fresh read session, so every {@link memoLiveRead} under
31
+ * it with the same key is one read. A call already inside a session joins
32
+ * it rather than opening a nested one: the outer command is the unit of
33
+ * "once".
34
+ */
35
+ export declare function withLiveReadSession<T>(fn: () => Promise<T>): Promise<T>;
36
+ /** Whether the caller is inside a {@link withLiveReadSession} scope. */
37
+ export declare function inLiveReadSession(): boolean;
38
+ /**
39
+ * The result of `read()`, shared with every earlier and later call in the
40
+ * same session that names the same `key`. Outside a session, `read()` and
41
+ * nothing else. A read that rejects is dropped from the session so the next
42
+ * call with its key reads again.
43
+ */
44
+ export declare function memoLiveRead<T>(key: string, read: () => Promise<T>): Promise<T>;
45
+ /**
46
+ * A key from the facts that decide a read's answer. Sorted keys, so two
47
+ * callers that spell the same read in a different order share it.
48
+ */
49
+ export declare function liveReadKey(activity: string, facts: Record<string, unknown>): string;
50
+ //# sourceMappingURL=live-read-session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"live-read-session.d.ts","sourceRoot":"","sources":["../src/live-read-session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAQH;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAGvE;AAED,wEAAwE;AACxE,wBAAgB,iBAAiB,IAAI,OAAO,CAE3C;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAW/E;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAIpF"}
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Pure Terraform dependency-graph builder for the carve-out advisor (#214 T1).
3
3
  *
4
- * Input is the JSON tree `@cdktf/hcl2json` produces (`Hcl2JsonTree`) plus the
4
+ * Input is the JSON tree `@cdktn/hcl2json` produces (`Hcl2JsonTree`) plus the
5
5
  * traversal accessors its expression AST found per interpolated string
6
6
  * (`ExpressionRefs`, resolved in `parse.ts` — #998). Tokenizing `${...}`
7
7
  * expression bodies is the AST's job — a quoted address inside an expression
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Thin wasm glue for the carve-out advisor (#214 T1): read a Terraform estate's
3
- * `.tf` files, run them through `@cdktf/hcl2json`, merge into one tree, and hand
3
+ * `.tf` files, run them through `@cdktn/hcl2json`, merge into one tree, and hand
4
4
  * off to the pure `buildGraph`.
5
5
  *
6
- * `@cdktf/hcl2json` is NOT a chant dependency — it carries a ~1.8 MB wasm blob
6
+ * `@cdktn/hcl2json` is NOT a chant dependency — it carries a ~1.8 MB wasm blob
7
7
  * and only carve-out users need it. It is lazy-loaded here and, if absent, the
8
8
  * advisor fails with a one-line install hint.
9
9
  */
@@ -19,6 +19,24 @@ export interface Hcl2Json {
19
19
  export declare class Hcl2JsonNotInstalled extends Error {
20
20
  constructor(cause: unknown);
21
21
  }
22
+ /**
23
+ * The environment variable that turns on parser-input recording (chant #2483).
24
+ *
25
+ * When set to a file path, every `parse(filename, source)` and every
26
+ * `getReferencesInExpression(filename, expression)` this process makes is
27
+ * appended to that file as one JSON line before the real call runs. That is
28
+ * how `scripts/check-hcl-parser-parity.ts` gets hold of the inline HCL the
29
+ * test suite parses, which no corpus walk on disk would find: run the suite
30
+ * with this set, then replay the file through two parsers and compare. Off
31
+ * unless set, and never changes a result.
32
+ */
33
+ export declare const HCL2JSON_RECORD_ENV = "CHANT_HCL2JSON_RECORD";
34
+ /** One recorded parser input, as {@link HCL2JSON_RECORD_ENV} writes it. */
35
+ export interface Hcl2JsonRecordLine {
36
+ kind: "parse" | "refs";
37
+ filename: string;
38
+ text: string;
39
+ }
22
40
  /**
23
41
  * Lazy-load the optional HCL parser. Throws `Hcl2JsonNotInstalled` with an
24
42
  * install hint when the package is missing, rather than a raw MODULE_NOT_FOUND.
@@ -1 +1 @@
1
- {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/terraform/parse.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAMH,OAAO,KAAK,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAErD,wDAAwD;AACxD,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;IAChE,yFAAyF;IACzF,yBAAyB,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;CACxG;AAED,qBAAa,oBAAqB,SAAQ,KAAK;gBACjC,KAAK,EAAE,OAAO;CAQ3B;AAED;;;GAGG;AACH,wBAAsB,YAAY,IAAI,OAAO,CAAC,QAAQ,CAAC,CAMtD;AAqDD,MAAM,WAAW,qBAAqB;IACpC,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,qBAA0B,GAAG,OAAO,CAAC,OAAO,CAAC,CAWvG"}
1
+ {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/terraform/parse.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAMH,OAAO,KAAK,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAErD,wDAAwD;AACxD,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;IAChE,yFAAyF;IACzF,yBAAyB,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;CACxG;AAED,qBAAa,oBAAqB,SAAQ,KAAK;gBACjC,KAAK,EAAE,OAAO;CAQ3B;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,0BAA0B,CAAC;AAE3D,2EAA2E;AAC3E,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,OAAO,GAAG,MAAM,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd;AAkBD;;;GAGG;AACH,wBAAsB,YAAY,IAAI,OAAO,CAAC,QAAQ,CAAC,CAStD;AAqDD,MAAM,WAAW,qBAAqB;IACpC,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,qBAA0B,GAAG,OAAO,CAAC,OAAO,CAAC,CAWvG"}
@@ -7,7 +7,7 @@
7
7
  * source (the emit/boundary/apply phases stay in #197, gated on demand).
8
8
  *
9
9
  * The graph layer here is intentionally pure: it consumes the JSON shape
10
- * `@cdktf/hcl2json` produces (see `parse.ts`), so the graph and scoring can
10
+ * `@cdktn/hcl2json` produces (see `parse.ts`), so the graph and scoring can
11
11
  * be tested without loading the wasm parser.
12
12
  */
13
13
  /** A node in the Terraform dependency graph — one resource or module block. */
@@ -82,7 +82,7 @@ export interface TfGraph {
82
82
  edges: TfEdge[];
83
83
  }
84
84
  /**
85
- * The subset of `@cdktf/hcl2json`'s `parse()` output the advisor reads. A
85
+ * The subset of `@cdktn/hcl2json`'s `parse()` output the advisor reads. A
86
86
  * merged tree across every `.tf` file in the estate. Values are left as the
87
87
  * raw hcl2json encoding (interpolations survive as `"${...}"` strings), which
88
88
  * is what edge extraction scans.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.76.0",
3
+ "version": "0.77.0",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -100,7 +100,7 @@
100
100
  "zod": "^4.3.6"
101
101
  },
102
102
  "devDependencies": {
103
- "@cdktf/hcl2json": "^0.21.0",
103
+ "@cdktn/hcl2json": "^0.24.0",
104
104
  "ajv": "^8.20.0"
105
105
  },
106
106
  "overrides": {
@@ -168,7 +168,7 @@ function dropRedundantDefaults(content: string): { patched: string; changed: boo
168
168
  * (`x = "${var.y}"` becomes `x = var.y`), the deprecated pre-0.12 style
169
169
  * tflint's `terraform_deprecated_interpolation` reports. Anchored on the
170
170
  * source text rather than the parsed body for the reason TF016's own module
171
- * gives: `@cdktf/hcl2json` renders a bare reference and a quoted interpolation
171
+ * gives: `@cdktn/hcl2json` renders a bare reference and a quoted interpolation
172
172
  * identically, so the quotes only exist here.
173
173
  */
174
174
  const INTERPOLATION_ONLY_RE = /^([ \t]*)([A-Za-z_][A-Za-z0-9_-]*)([ \t]*=[ \t]*)"\$\{([^"]+)\}"([ \t]*(?:#.*)?)$/;
@@ -307,7 +307,7 @@ export function unmappedDetail(
307
307
  ? `the lexicon that owns ${owner.prefixes.join(", ")}`
308
308
  : "the lexicon that owns this entity type";
309
309
  return (
310
- `${label} has no row in any contributed coverage table — it is a type from a substrate that is ` +
310
+ `${label} has no row in any contributed coverage table. It is a type from a substrate that is ` +
311
311
  "modelled, and is neither mapped to an engine kind nor declared unmapped, so nothing has an " +
312
312
  `opinion about it rather than a stated one. Add a row in ${where}.`
313
313
  );
@@ -10,6 +10,7 @@ import { buildDeclaredPerStack } from "../../graph-declared";
10
10
  import { mergeProjectOps } from "../../graph-ops";
11
11
  import { reconstructEdges, mergeCatalogs, containmentGroups, type ReferenceCatalog, type ContainmentPair } from "../../graph-refs";
12
12
  import { observeResources } from "../../lifecycle/observe";
13
+ import { withLiveReadSession } from "../../live-read-session";
13
14
  import { replaySnapshots, hasSnapshot } from "../../lifecycle/replay";
14
15
  import { loadChantConfig, environmentNames, matchesDeclaredEnvironment, loadChantConfigUpward, type ChantConfig } from "../../config";
15
16
  import { applyLiveEndpoint } from "../../live-endpoint";
@@ -146,7 +147,17 @@ async function mergeGraphOps(
146
147
  * `--format` projects the component DAG itself into that format (nodes =
147
148
  * components, wave groups, `dependsOn` edges) — the graph behold renders.
148
149
  */
149
- export async function runGraph(ctx: CommandContext): Promise<number> {
150
+ /**
151
+ * One `chant graph` is one read of the account (chant #2498): the live
152
+ * observation and the `--traffic` prediction are two plugin methods that
153
+ * each read a live root, and inside this session a read one of them made
154
+ * is the read the other gets.
155
+ */
156
+ export function runGraph(ctx: CommandContext): Promise<number> {
157
+ return withLiveReadSession(() => runGraphCommand(ctx));
158
+ }
159
+
160
+ async function runGraphCommand(ctx: CommandContext): Promise<number> {
150
161
  const viewFormats = ["ir", "mermaid", "dot", "layout"] as const;
151
162
  const isViewFormat = (viewFormats as readonly string[]).includes(ctx.args.format);
152
163
  // `--projection <lexicon>` (#989) only means anything for the component
package/src/cli/main.ts CHANGED
@@ -511,8 +511,8 @@ Commands:
511
511
  carve advise Read-only peelability advisor: rank which resources
512
512
  --from <dir> are cheap to carve into native chant
513
513
  (--json, --report <path>). Emits nothing, changes nothing.
514
- --from a Terraform dir needs @cdktf/hcl2json
515
- (npm install -D @cdktf/hcl2json); --from a CDK cloud
514
+ --from a Terraform dir needs @cdktn/hcl2json
515
+ (npm install -D @cdktn/hcl2json); --from a CDK cloud
516
516
  assembly (cdk.out) needs nothing and ranks constructs.
517
517
  carve emit Adopt a selected TF resource into chant source + report
518
518
  --from <tf-dir> its boundary. --state <tfstate> adopts offline
@@ -28,10 +28,10 @@ const require = createRequire(import.meta.url);
28
28
  * file that imports a lexicon package pulls `typescript` in transitively,
29
29
  * every time, not just for the rare file that uses the compiler itself.
30
30
  *
31
- * `@cdktf/hcl2json` is here for the same reason and by a longer road. It is
31
+ * `@cdktn/hcl2json` is here for the same reason and by a longer road. It is
32
32
  * an OPTIONAL dependency — `../../terraform/parse.ts` reaches it through
33
33
  * `await import(...)` and turns a missing module into an actionable "npm
34
- * install -D @cdktf/hcl2json" — but esbuild follows a dynamic import as
34
+ * install -D @cdktn/hcl2json" — but esbuild follows a dynamic import as
35
35
  * eagerly as a static one, and the package ships a Go `wasm_exec` shim whose
36
36
  * `performance` reference does not resolve under `platform: "node"`. So a
37
37
  * bundle that merely *reaches* the carve commands fails outright. Reaching
@@ -42,7 +42,7 @@ const require = createRequire(import.meta.url);
42
42
  * cannot fold — pulled the whole CLI, and carve with it, into its sandbox
43
43
  * bundle (chant #2129).
44
44
  */
45
- const EXTERNAL_PACKAGES = ["typescript", "@cdktf/hcl2json"];
45
+ const EXTERNAL_PACKAGES = ["typescript", "@cdktn/hcl2json"];
46
46
 
47
47
  /**
48
48
  * Resolve each of {@link EXTERNAL_PACKAGES} to its real, absolute path on
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The read session shares an identical read inside one scope and nowhere
3
+ * else (chant #2498).
4
+ */
5
+
6
+ import { describe, expect, it } from "vitest";
7
+ import { inLiveReadSession, liveReadKey, memoLiveRead, withLiveReadSession } from "./live-read-session";
8
+
9
+ function counter(): { read: () => Promise<number>; calls: number } {
10
+ const state = { calls: 0, read: async () => ++state.calls };
11
+ return state;
12
+ }
13
+
14
+ describe("memoLiveRead", () => {
15
+ it("reads every time outside a session", async () => {
16
+ const c = counter();
17
+ expect(inLiveReadSession()).toBe(false);
18
+ expect(await memoLiveRead("k", c.read)).toBe(1);
19
+ expect(await memoLiveRead("k", c.read)).toBe(2);
20
+ expect(c.calls).toBe(2);
21
+ });
22
+
23
+ it("reads once per key inside a session, including for callers that overlap in flight", async () => {
24
+ const c = counter();
25
+ await withLiveReadSession(async () => {
26
+ expect(inLiveReadSession()).toBe(true);
27
+ const [a, b] = await Promise.all([memoLiveRead("k", c.read), memoLiveRead("k", c.read)]);
28
+ expect(a).toBe(1);
29
+ expect(b).toBe(1);
30
+ expect(await memoLiveRead("k", c.read)).toBe(1);
31
+ expect(await memoLiveRead("other", c.read)).toBe(2);
32
+ });
33
+ expect(c.calls).toBe(2);
34
+ });
35
+
36
+ it("does not carry a read from one session into the next", async () => {
37
+ const c = counter();
38
+ await withLiveReadSession(() => memoLiveRead("k", c.read));
39
+ await withLiveReadSession(() => memoLiveRead("k", c.read));
40
+ expect(c.calls).toBe(2);
41
+ expect(inLiveReadSession()).toBe(false);
42
+ });
43
+
44
+ it("joins an enclosing session rather than opening a nested one", async () => {
45
+ const c = counter();
46
+ await withLiveReadSession(async () => {
47
+ await memoLiveRead("k", c.read);
48
+ await withLiveReadSession(() => memoLiveRead("k", c.read));
49
+ });
50
+ expect(c.calls).toBe(1);
51
+ });
52
+
53
+ it("does not memoise a rejection", async () => {
54
+ let attempts = 0;
55
+ const read = async (): Promise<string> => {
56
+ attempts++;
57
+ if (attempts === 1) throw new Error("credentials");
58
+ return "ok";
59
+ };
60
+ await withLiveReadSession(async () => {
61
+ await expect(memoLiveRead("k", read)).rejects.toThrow("credentials");
62
+ expect(await memoLiveRead("k", read)).toBe("ok");
63
+ expect(await memoLiveRead("k", read)).toBe("ok");
64
+ });
65
+ expect(attempts).toBe(2);
66
+ });
67
+ });
68
+
69
+ describe("liveReadKey", () => {
70
+ it("is the same key whichever order the facts are given in, and differs on any fact", () => {
71
+ expect(liveReadKey("live-plan", { root: "app", dir: "/x", adoptionOnly: false })).toBe(
72
+ liveReadKey("live-plan", { adoptionOnly: false, dir: "/x", root: "app" }),
73
+ );
74
+ expect(liveReadKey("live-plan", { root: "app", dir: "/x" })).not.toBe(liveReadKey("live-ls", { root: "app", dir: "/x" }));
75
+ expect(liveReadKey("live-plan", { root: "app", adoptionOnly: false })).not.toBe(
76
+ liveReadKey("live-plan", { root: "app", adoptionOnly: true }),
77
+ );
78
+ });
79
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * One command, one read of the account (chant #2498).
3
+ *
4
+ * A `chant graph --live --traffic` against a live root reads the account
5
+ * twice: once for the graph's observation and once for the prediction, and
6
+ * each of those used to run `live-plan` twice for the document and its human
7
+ * render. Four `live-plan` runs for one answer, which on a drifted estate
8
+ * whose provider retries a deleted resource was five minutes against a
9
+ * consumer's three-minute budget.
10
+ *
11
+ * Nothing shared a read because nothing could: a plugin method takes options
12
+ * alone and cannot be handed the documents another method already holds. So
13
+ * the sharing is ambient instead. A command that wants its reads shared runs
14
+ * inside {@link withLiveReadSession}; a read that can be shared calls
15
+ * {@link memoLiveRead} with a key naming everything that would change its
16
+ * answer (root, directory, binary, flags, environment), and gets the pending
17
+ * or settled promise of an identical read made earlier in the same session.
18
+ *
19
+ * The scope is the session, never the process. A watch Op ticking on a
20
+ * schedule runs the same activity every tick and must see the account move,
21
+ * so outside a session {@link memoLiveRead} simply reads. A rejection is
22
+ * never memoised either: the next caller tries again, since a read that
23
+ * failed on credentials or a timeout says nothing about the next one.
24
+ *
25
+ * `AsyncLocalStorage` carries the session across every `await` under the
26
+ * wrapped call, which is how two plugin methods called one after the other
27
+ * by one handler share it without either knowing about the other.
28
+ */
29
+
30
+ import { AsyncLocalStorage } from "node:async_hooks";
31
+
32
+ type Memo = Map<string, Promise<unknown>>;
33
+
34
+ const sessions = new AsyncLocalStorage<Memo>();
35
+
36
+ /**
37
+ * Run `fn` with a fresh read session, so every {@link memoLiveRead} under
38
+ * it with the same key is one read. A call already inside a session joins
39
+ * it rather than opening a nested one: the outer command is the unit of
40
+ * "once".
41
+ */
42
+ export function withLiveReadSession<T>(fn: () => Promise<T>): Promise<T> {
43
+ if (sessions.getStore()) return fn();
44
+ return sessions.run(new Map(), fn);
45
+ }
46
+
47
+ /** Whether the caller is inside a {@link withLiveReadSession} scope. */
48
+ export function inLiveReadSession(): boolean {
49
+ return sessions.getStore() !== undefined;
50
+ }
51
+
52
+ /**
53
+ * The result of `read()`, shared with every earlier and later call in the
54
+ * same session that names the same `key`. Outside a session, `read()` and
55
+ * nothing else. A read that rejects is dropped from the session so the next
56
+ * call with its key reads again.
57
+ */
58
+ export function memoLiveRead<T>(key: string, read: () => Promise<T>): Promise<T> {
59
+ const memo = sessions.getStore();
60
+ if (!memo) return read();
61
+ const shared = memo.get(key);
62
+ if (shared) return shared as Promise<T>;
63
+ const pending = read();
64
+ memo.set(key, pending);
65
+ pending.catch(() => {
66
+ if (memo.get(key) === pending) memo.delete(key);
67
+ });
68
+ return pending;
69
+ }
70
+
71
+ /**
72
+ * A key from the facts that decide a read's answer. Sorted keys, so two
73
+ * callers that spell the same read in a different order share it.
74
+ */
75
+ export function liveReadKey(activity: string, facts: Record<string, unknown>): string {
76
+ const ordered: Record<string, unknown> = {};
77
+ for (const name of Object.keys(facts).sort()) ordered[name] = facts[name];
78
+ return `${activity}:${JSON.stringify(ordered)}`;
79
+ }
@@ -41,7 +41,7 @@ const CORE = fileURLToPath(new URL("../../", import.meta.url));
41
41
  */
42
42
  const DELIBERATELY_UNDECLARED = new Map<string, string>([
43
43
  [
44
- "@cdktf/hcl2json",
44
+ "@cdktn/hcl2json",
45
45
  "carries a ~1.8 MB wasm blob and is only needed by `chant carve`; " +
46
46
  "`terraform/parse.ts` catches the failed import and prints the install line",
47
47
  ],
@@ -4,7 +4,7 @@ import { buildFixtureGraph } from "./__fixtures__/build-graph";
4
4
  import type { Hcl2JsonTree } from "./types";
5
5
 
6
6
  /**
7
- * The #197 worked example, in the JSON shape `@cdktf/hcl2json` emits: a bucket,
7
+ * The #197 worked example, in the JSON shape `@cdktn/hcl2json` emits: a bucket,
8
8
  * its versioning sub-resource, and a Lambda that reads the bucket's name + arn.
9
9
  */
10
10
  const workedExample: Hcl2JsonTree = {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Pure Terraform dependency-graph builder for the carve-out advisor (#214 T1).
3
3
  *
4
- * Input is the JSON tree `@cdktf/hcl2json` produces (`Hcl2JsonTree`) plus the
4
+ * Input is the JSON tree `@cdktn/hcl2json` produces (`Hcl2JsonTree`) plus the
5
5
  * traversal accessors its expression AST found per interpolated string
6
6
  * (`ExpressionRefs`, resolved in `parse.ts` — #998). Tokenizing `${...}`
7
7
  * expression bodies is the AST's job — a quoted address inside an expression
@@ -2,10 +2,11 @@ import { describe, test, expect, beforeAll } from "vitest";
2
2
  import { mkdtempSync, writeFileSync, rmSync } from "fs";
3
3
  import { tmpdir } from "os";
4
4
  import { join } from "path";
5
- import { parseTerraformDir, loadHcl2json, Hcl2JsonNotInstalled } from "./parse";
5
+ import { parseTerraformDir, loadHcl2json, Hcl2JsonNotInstalled, HCL2JSON_RECORD_ENV } from "./parse";
6
+ import { readFileSync } from "fs";
6
7
 
7
8
  /**
8
- * `@cdktf/hcl2json` is an optional (dev-only in this repo) dependency. These
9
+ * `@cdktn/hcl2json` is an optional (dev-only in this repo) dependency. These
9
10
  * tests exercise the real wasm parser when it resolves and skip cleanly when it
10
11
  * does not, so a consumer install without the parser never fails the suite.
11
12
  */
@@ -21,13 +22,44 @@ beforeAll(async () => {
21
22
 
22
23
  describe("loadHcl2json", () => {
23
24
  test("missing parser throws an install-hint error, not a raw MODULE_NOT_FOUND", () => {
24
- const err = new Hcl2JsonNotInstalled(new Error("Cannot find module '@cdktf/hcl2json'"));
25
- expect(err.message).toContain("npm install -D @cdktf/hcl2json");
25
+ const err = new Hcl2JsonNotInstalled(new Error("Cannot find module '@cdktn/hcl2json'"));
26
+ expect(err.message).toContain("npm install -D @cdktn/hcl2json");
26
27
  expect(err.message).toContain("HCL parser");
27
28
  expect(err.name).toBe("Hcl2JsonNotInstalled");
28
29
  });
29
30
  });
30
31
 
32
+ describe("parser-input recording (#2483)", () => {
33
+ test("with the record variable set, every parse and expression call is appended as one JSON line", async () => {
34
+ if (!parserAvailable) return;
35
+ const dir = mkdtempSync(join(tmpdir(), "chant-tf-record-"));
36
+ const record = join(dir, "record.jsonl");
37
+ process.env[HCL2JSON_RECORD_ENV] = record;
38
+ try {
39
+ const parser = await loadHcl2json();
40
+ const tree = await parser.parse("main.tf", `locals {\n a = var.x\n}\n`);
41
+ const refs = await parser.getReferencesInExpression("expression.tf", "${var.x}");
42
+ expect(tree).toEqual({ locals: [{ a: "${var.x}" }] });
43
+ expect(refs.map((r) => r.value)).toEqual(["var.x"]);
44
+ const lines = readFileSync(record, "utf-8").trim().split("\n").map((l) => JSON.parse(l));
45
+ expect(lines).toEqual([
46
+ { kind: "parse", filename: "main.tf", text: `locals {\n a = var.x\n}\n` },
47
+ { kind: "refs", filename: "expression.tf", text: "${var.x}" },
48
+ ]);
49
+ } finally {
50
+ delete process.env[HCL2JSON_RECORD_ENV];
51
+ rmSync(dir, { recursive: true, force: true });
52
+ }
53
+ });
54
+
55
+ test("without the variable, the parser is handed back as is", async () => {
56
+ if (!parserAvailable) return;
57
+ delete process.env[HCL2JSON_RECORD_ENV];
58
+ const parser = await loadHcl2json();
59
+ expect(typeof (parser as unknown as { convertFiles?: unknown }).convertFiles).toBe("function");
60
+ });
61
+ });
62
+
31
63
  describe.runIf(true)("parseTerraformDir (real wasm)", () => {
32
64
  test("parses a multi-file estate into the expected graph", async () => {
33
65
  if (!parserAvailable) return; // optional dep absent — skip
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * Thin wasm glue for the carve-out advisor (#214 T1): read a Terraform estate's
3
- * `.tf` files, run them through `@cdktf/hcl2json`, merge into one tree, and hand
3
+ * `.tf` files, run them through `@cdktn/hcl2json`, merge into one tree, and hand
4
4
  * off to the pure `buildGraph`.
5
5
  *
6
- * `@cdktf/hcl2json` is NOT a chant dependency — it carries a ~1.8 MB wasm blob
6
+ * `@cdktn/hcl2json` is NOT a chant dependency — it carries a ~1.8 MB wasm blob
7
7
  * and only carve-out users need it. It is lazy-loaded here and, if absent, the
8
8
  * advisor fails with a one-line install hint.
9
9
  */
10
10
 
11
- import { readdirSync, readFileSync } from "fs";
11
+ import { appendFileSync, readdirSync, readFileSync } from "fs";
12
12
  import { join } from "path";
13
13
  import { buildGraph, collectExpressions, type ExpressionRefs } from "./graph";
14
14
  import { readStateInstanceCounts, applyStateCounts } from "./state";
@@ -25,23 +25,62 @@ export class Hcl2JsonNotInstalled extends Error {
25
25
  constructor(cause: unknown) {
26
26
  super(
27
27
  "Terraform carve-out needs the HCL parser, which is not installed.\n" +
28
- " Install it once: npm install -D @cdktf/hcl2json\n" +
28
+ " Install it once: npm install -D @cdktn/hcl2json\n" +
29
29
  `(underlying error: ${cause instanceof Error ? cause.message : String(cause)})`,
30
30
  );
31
31
  this.name = "Hcl2JsonNotInstalled";
32
32
  }
33
33
  }
34
34
 
35
+ /**
36
+ * The environment variable that turns on parser-input recording (chant #2483).
37
+ *
38
+ * When set to a file path, every `parse(filename, source)` and every
39
+ * `getReferencesInExpression(filename, expression)` this process makes is
40
+ * appended to that file as one JSON line before the real call runs. That is
41
+ * how `scripts/check-hcl-parser-parity.ts` gets hold of the inline HCL the
42
+ * test suite parses, which no corpus walk on disk would find: run the suite
43
+ * with this set, then replay the file through two parsers and compare. Off
44
+ * unless set, and never changes a result.
45
+ */
46
+ export const HCL2JSON_RECORD_ENV = "CHANT_HCL2JSON_RECORD";
47
+
48
+ /** One recorded parser input, as {@link HCL2JSON_RECORD_ENV} writes it. */
49
+ export interface Hcl2JsonRecordLine {
50
+ kind: "parse" | "refs";
51
+ filename: string;
52
+ text: string;
53
+ }
54
+
55
+ function recording(parser: Hcl2Json, path: string): Hcl2Json {
56
+ const note = (line: Hcl2JsonRecordLine): void => {
57
+ appendFileSync(path, `${JSON.stringify(line)}\n`);
58
+ };
59
+ return {
60
+ parse: (filename, hcl) => {
61
+ note({ kind: "parse", filename, text: hcl });
62
+ return parser.parse(filename, hcl);
63
+ },
64
+ getReferencesInExpression: (filename, expression) => {
65
+ note({ kind: "refs", filename, text: expression });
66
+ return parser.getReferencesInExpression(filename, expression);
67
+ },
68
+ };
69
+ }
70
+
35
71
  /**
36
72
  * Lazy-load the optional HCL parser. Throws `Hcl2JsonNotInstalled` with an
37
73
  * install hint when the package is missing, rather than a raw MODULE_NOT_FOUND.
38
74
  */
39
75
  export async function loadHcl2json(): Promise<Hcl2Json> {
76
+ let parser: Hcl2Json;
40
77
  try {
41
- return (await import("@cdktf/hcl2json")) as Hcl2Json;
78
+ parser = (await import("@cdktn/hcl2json")) as Hcl2Json;
42
79
  } catch (err) {
43
80
  throw new Hcl2JsonNotInstalled(err);
44
81
  }
82
+ const record = process.env[HCL2JSON_RECORD_ENV];
83
+ return record ? recording(parser, record) : parser;
45
84
  }
46
85
 
47
86
  /**
@@ -7,7 +7,7 @@
7
7
  * source (the emit/boundary/apply phases stay in #197, gated on demand).
8
8
  *
9
9
  * The graph layer here is intentionally pure: it consumes the JSON shape
10
- * `@cdktf/hcl2json` produces (see `parse.ts`), so the graph and scoring can
10
+ * `@cdktn/hcl2json` produces (see `parse.ts`), so the graph and scoring can
11
11
  * be tested without loading the wasm parser.
12
12
  */
13
13
 
@@ -86,7 +86,7 @@ export interface TfGraph {
86
86
  }
87
87
 
88
88
  /**
89
- * The subset of `@cdktf/hcl2json`'s `parse()` output the advisor reads. A
89
+ * The subset of `@cdktn/hcl2json`'s `parse()` output the advisor reads. A
90
90
  * merged tree across every `.tf` file in the estate. Values are left as the
91
91
  * raw hcl2json encoding (interpolations survive as `"${...}"` strings), which
92
92
  * is what edge extraction scans.