@intentius/chant-lexicon-terraform 0.58.0 → 0.59.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.
Files changed (92) hide show
  1. package/README.md +9 -0
  2. package/dist/composites/terraform-adopt-op.d.ts +7 -2
  3. package/dist/composites/terraform-adopt-op.d.ts.map +1 -1
  4. package/dist/composites/terraform-apply-op.d.ts +8 -2
  5. package/dist/composites/terraform-apply-op.d.ts.map +1 -1
  6. package/dist/composites/terraform-watch-op.d.ts +30 -7
  7. package/dist/composites/terraform-watch-op.d.ts.map +1 -1
  8. package/dist/hcl/parse.d.ts +14 -0
  9. package/dist/hcl/parse.d.ts.map +1 -1
  10. package/dist/integrity.json +7 -5
  11. package/dist/lint/audit-catalog.d.ts +2 -2
  12. package/dist/lint/audit-catalog.d.ts.map +1 -1
  13. package/dist/lint/audit-lineage.d.ts +39 -12
  14. package/dist/lint/audit-lineage.d.ts.map +1 -1
  15. package/dist/lint/post-synth/index.d.ts.map +1 -1
  16. package/dist/lint/post-synth/tf001.d.ts +40 -6
  17. package/dist/lint/post-synth/tf001.d.ts.map +1 -1
  18. package/dist/lint/post-synth/tf015.d.ts +11 -2
  19. package/dist/lint/post-synth/tf015.d.ts.map +1 -1
  20. package/dist/lint/post-synth/tf026.d.ts +7 -0
  21. package/dist/lint/post-synth/tf026.d.ts.map +1 -1
  22. package/dist/lint/post-synth/tf027.d.ts +30 -0
  23. package/dist/lint/post-synth/tf027.d.ts.map +1 -0
  24. package/dist/lint/post-synth/tf028.d.ts +33 -0
  25. package/dist/lint/post-synth/tf028.d.ts.map +1 -0
  26. package/dist/lsp/option-keys.d.ts.map +1 -1
  27. package/dist/manifest.json +1 -1
  28. package/dist/okf/index.md +2 -0
  29. package/dist/okf/rules/TF027.md +11 -0
  30. package/dist/okf/rules/TF028.md +11 -0
  31. package/dist/op/activities/terraform.d.ts +32 -11
  32. package/dist/op/activities/terraform.d.ts.map +1 -1
  33. package/dist/op/builders.d.ts +7 -5
  34. package/dist/op/builders.d.ts.map +1 -1
  35. package/dist/op/resolve-root-mode.d.ts +29 -15
  36. package/dist/op/resolve-root-mode.d.ts.map +1 -1
  37. package/dist/plugin.d.ts.map +1 -1
  38. package/dist/rules/tf001.ts +70 -14
  39. package/dist/rules/tf015.ts +11 -2
  40. package/dist/rules/tf026.ts +6 -2
  41. package/dist/rules/tf027.ts +74 -0
  42. package/dist/rules/tf028.ts +96 -0
  43. package/dist/validate.d.ts.map +1 -1
  44. package/package.json +2 -2
  45. package/src/__fixtures__/ACCEPTANCE.md +181 -0
  46. package/src/__fixtures__/remote-backend/main.tf +34 -0
  47. package/src/__fixtures__/with-backend/main.tf +7 -0
  48. package/src/codegen/lint-rules-page.test.ts +18 -1
  49. package/src/composites/terraform-adopt-op.acceptance.test.ts +46 -19
  50. package/src/composites/terraform-adopt-op.test.ts +9 -4
  51. package/src/composites/terraform-adopt-op.ts +8 -3
  52. package/src/composites/terraform-apply-op.acceptance.test.ts +14 -9
  53. package/src/composites/terraform-apply-op.test.ts +13 -5
  54. package/src/composites/terraform-apply-op.ts +23 -7
  55. package/src/composites/terraform-watch-op.test.ts +85 -4
  56. package/src/composites/terraform-watch-op.ts +59 -8
  57. package/src/hcl/parse.ts +20 -0
  58. package/src/lint/audit-catalog.ts +26 -2
  59. package/src/lint/audit-lineage.ts +79 -14
  60. package/src/lint/audit.test.ts +171 -3
  61. package/src/lint/post-synth/fixtures/TF001/negative-cloud.tf +28 -0
  62. package/src/lint/post-synth/fixtures/TF001/negative.tf +4 -2
  63. package/src/lint/post-synth/fixtures/TF001/positive-local.tf +31 -0
  64. package/src/lint/post-synth/fixtures/TF014/negative/main.tf +2 -2
  65. package/src/lint/post-synth/fixtures/TF014/positive/main.tf +2 -2
  66. package/src/lint/post-synth/fixtures/TF020/scoped/main.tf +2 -2
  67. package/src/lint/post-synth/fixtures/TF027/live-default/main.tf +9 -0
  68. package/src/lint/post-synth/fixtures/TF027/live-untagged-delete/main.tf +13 -0
  69. package/src/lint/post-synth/fixtures/TF027/live-untagged-delete-scoped/main.tf +17 -0
  70. package/src/lint/post-synth/fixtures/TF027/live-untagged-report/main.tf +13 -0
  71. package/src/lint/post-synth/fixtures/TF028/live/main.tf +9 -0
  72. package/src/lint/post-synth/fixtures/TF028/stock/main.tf +10 -0
  73. package/src/lint/post-synth/index.ts +4 -0
  74. package/src/lint/post-synth/live-refusals-config-format.test.ts +143 -0
  75. package/src/lint/post-synth/module-rules.test.ts +1 -1
  76. package/src/lint/post-synth/post-synth.test.ts +64 -6
  77. package/src/lint/post-synth/tf001.ts +70 -14
  78. package/src/lint/post-synth/tf015.ts +11 -2
  79. package/src/lint/post-synth/tf026.ts +6 -2
  80. package/src/lint/post-synth/tf027.test.ts +62 -0
  81. package/src/lint/post-synth/tf027.ts +74 -0
  82. package/src/lint/post-synth/tf028.test.ts +92 -0
  83. package/src/lint/post-synth/tf028.ts +96 -0
  84. package/src/lsp/option-keys.ts +3 -2
  85. package/src/op/activities/choudoufu.acceptance.test.ts +7 -23
  86. package/src/op/activities/choudoufu.test.ts +49 -17
  87. package/src/op/activities/terraform.ts +67 -29
  88. package/src/op/builders.ts +7 -5
  89. package/src/op/resolve-root-mode.ts +29 -15
  90. package/src/plugin.test.ts +38 -0
  91. package/src/plugin.ts +122 -31
  92. package/src/validate.ts +10 -1
@@ -164,6 +164,18 @@ describe("choudoufuLivePlanCommand (#2103)", () => {
164
164
  );
165
165
  });
166
166
 
167
+ // choudoufu refuses `-estate` beside a `live` block or an `estate.chdf.hcl`
168
+ // sidecar, and since v0.14.0 it does not need one there (choudoufu #894, PR
169
+ // 915). Omitting the option is how a declared root is run.
170
+ test("no estate emits no -estate, for a root that names its own (choudoufu #894)", () => {
171
+ expect(choudoufuLivePlanCommand({ binary: "choudoufu", json: true })).toBe(
172
+ "choudoufu live-plan -detailed-exitcode -json",
173
+ );
174
+ expect(choudoufuLivePlanCommand({ binary: "choudoufu", json: false, noColor: true })).toBe(
175
+ "choudoufu live-plan -detailed-exitcode -no-color",
176
+ );
177
+ });
178
+
167
179
  test("-adoption-only and -no-color ride the human form (#2105)", () => {
168
180
  expect(
169
181
  choudoufuLivePlanCommand({ binary: "choudoufu", estate: "prod", json: false, adoptionOnly: true, noColor: true }),
@@ -265,10 +277,10 @@ describe("isOlderVersion / parseChoudoufuVersion (#2103)", () => {
265
277
  // ── The version check, wired into every activity via resolveRoot ───────────
266
278
 
267
279
  describe("choudoufu version check (#2103)", () => {
268
- test("refuses a binary older than the floor, which the approval artifact moved to v0.13.0", async () => {
280
+ test("refuses a binary older than the floor, which the -json document moved to v0.14.0", async () => {
269
281
  const dir = liveProject();
270
- replies.push({ match: "version", reply: { stdout: "choudoufu v0.12.0 (based on OpenTofu v1.13.0)\n", stderr: "" } });
271
- await expect(choudoufuLiveCheck({ root: "estate", cwd: dir })).rejects.toThrow(/older than.*v0\.13\.0/);
282
+ replies.push({ match: "version", reply: { stdout: "choudoufu v0.13.0 (based on OpenTofu v1.13.0)\n", stderr: "" } });
283
+ await expect(choudoufuLiveCheck({ root: "estate", cwd: dir })).rejects.toThrow(/older than.*v0\.14\.0/);
272
284
  });
273
285
 
274
286
  test("passes at exactly the minimum version", async () => {
@@ -398,7 +410,7 @@ describe("choudoufuLivePlan (#2103)", () => {
398
410
  test("exit 0: no drift, the document is captured and written, the human plan comes from a second call", async () => {
399
411
  const dir = liveProject();
400
412
  replies.push({ match: "live-plan -detailed-exitcode -json", reply: { stdout: DOC, stderr: "" } });
401
- replies.push({ match: "live-plan -detailed-exitcode -no-color -estate", reply: { stdout: "No changes.\n", stderr: "" } });
413
+ replies.push({ match: "live-plan -detailed-exitcode -no-color", reply: { stdout: "No changes.\n", stderr: "" } });
402
414
 
403
415
  const result = await choudoufuLivePlan({ root: "estate", cwd: dir });
404
416
  expect(result.drift).toBe(false);
@@ -415,9 +427,12 @@ describe("choudoufuLivePlan (#2103)", () => {
415
427
  const written = readFileSync(join(result.dir, DEFAULT_LIVE_PLAN_DOCUMENT_FILE), "utf-8");
416
428
  expect(JSON.parse(written)).toEqual(JSON.parse(DOC));
417
429
 
430
+ // The root names its own estate, so neither call carries `-estate`:
431
+ // choudoufu refuses the flag beside a declaration, and since v0.14.0 it
432
+ // reads the declared name itself (choudoufu #894, PR 915).
418
433
  expect(commandsRun()).toEqual([
419
- "choudoufu live-plan -detailed-exitcode -json -estate=prod-networking",
420
- "choudoufu live-plan -detailed-exitcode -no-color -estate=prod-networking",
434
+ "choudoufu live-plan -detailed-exitcode -json",
435
+ "choudoufu live-plan -detailed-exitcode -no-color",
421
436
  ]);
422
437
  });
423
438
 
@@ -425,7 +440,7 @@ describe("choudoufuLivePlan (#2103)", () => {
425
440
  const dir = liveProject();
426
441
  replies.push({ match: "live-plan -detailed-exitcode -json", reply: execError(2, "", DOC) });
427
442
  replies.push({
428
- match: "live-plan -detailed-exitcode -no-color -estate",
443
+ match: "live-plan -detailed-exitcode -no-color",
429
444
  reply: { stdout: "Plan: 2 to add, 0 to change, 1 to destroy.\n", stderr: "" },
430
445
  });
431
446
 
@@ -448,14 +463,25 @@ describe("choudoufuLivePlan (#2103)", () => {
448
463
  );
449
464
  });
450
465
 
451
- test("an explicit estate overrides auto-detection", async () => {
452
- const dir = liveProject();
466
+ test("a root declaring no estate carries the caller's as -estate", async () => {
467
+ const dir = liveProject({ withLiveBlock: false });
453
468
  replies.push({ match: "live-plan -detailed-exitcode -json", reply: { stdout: DOC, stderr: "" } });
454
- replies.push({ match: "live-plan -detailed-exitcode -no-color -estate", reply: { stdout: "", stderr: "" } });
469
+ replies.push({ match: "live-plan -detailed-exitcode -no-color", reply: { stdout: "", stderr: "" } });
455
470
  await choudoufuLivePlan({ root: "estate", cwd: dir, estate: "other-estate" });
456
471
  expect(commandsRun()[0]).toContain("-estate=other-estate");
457
472
  });
458
473
 
474
+ // The declaration wins on the wire whatever the caller passes, because
475
+ // choudoufu refuses `-estate` beside one. The result still reports the
476
+ // estate the document names.
477
+ test("a root that declares its own estate emits no -estate, even when one is passed", async () => {
478
+ const dir = liveProject();
479
+ replies.push({ match: "live-plan -detailed-exitcode -json", reply: { stdout: DOC, stderr: "" } });
480
+ replies.push({ match: "live-plan -detailed-exitcode -no-color", reply: { stdout: "", stderr: "" } });
481
+ await choudoufuLivePlan({ root: "estate", cwd: dir, estate: "other-estate" });
482
+ expect(commandsRun().every((c) => !c.includes("-estate"))).toBe(true);
483
+ });
484
+
459
485
  test("no estate anywhere: refused, and the plan itself never runs", async () => {
460
486
  const dir = liveProject({ withLiveBlock: false });
461
487
  await expect(choudoufuLivePlan({ root: "estate", cwd: dir })).rejects.toThrow(/no estate to run against/);
@@ -494,7 +520,7 @@ describe("choudoufuLivePlan adoption ledger (#2105)", () => {
494
520
  const dir = liveProject();
495
521
  replies.push({ match: "live-plan -detailed-exitcode -json", reply: { stdout: ADOPTABLE, stderr: "" } });
496
522
  replies.push({
497
- match: "live-plan -detailed-exitcode -no-color -estate",
523
+ match: "live-plan -detailed-exitcode -no-color",
498
524
  reply: { stdout: "Plan: 1 to add.\n", stderr: "" },
499
525
  });
500
526
 
@@ -511,7 +537,7 @@ describe("choudoufuLivePlan adoption ledger (#2105)", () => {
511
537
  const dir = liveProject();
512
538
  replies.push({ match: "live-plan -detailed-exitcode -json", reply: { stdout: ADOPTABLE, stderr: "" } });
513
539
  replies.push({
514
- match: "live-plan -detailed-exitcode -no-color -estate",
540
+ match: "live-plan -detailed-exitcode -no-color",
515
541
  reply: { stdout: "Plan: 1 to add.\n", stderr: "" },
516
542
  });
517
543
 
@@ -531,8 +557,8 @@ describe("choudoufuLivePlan adoption ledger (#2105)", () => {
531
557
 
532
558
  const result = await choudoufuLivePlan({ root: "estate", cwd: dir, adoptionOnly: true });
533
559
  expect(commandsRun()).toEqual([
534
- "choudoufu live-plan -detailed-exitcode -json -estate=prod-networking",
535
- "choudoufu live-plan -detailed-exitcode -adoption-only -no-color -estate=prod-networking",
560
+ "choudoufu live-plan -detailed-exitcode -json",
561
+ "choudoufu live-plan -detailed-exitcode -adoption-only -no-color",
536
562
  ]);
537
563
  expect(result.adoptions[0].command).toBe(
538
564
  "aws ec2 create-tags --resources 'vpc-0abc' --tags 'Key=tofu-estate,Value=prod-networking'",
@@ -542,7 +568,7 @@ describe("choudoufuLivePlan adoption ledger (#2105)", () => {
542
568
  test("without adoptionOnly a candidate carries the marker values and no command", async () => {
543
569
  const dir = liveProject();
544
570
  replies.push({ match: "live-plan -detailed-exitcode -json", reply: { stdout: ADOPTABLE, stderr: "" } });
545
- replies.push({ match: "live-plan -detailed-exitcode -no-color -estate", reply: { stdout: "", stderr: "" } });
571
+ replies.push({ match: "live-plan -detailed-exitcode -no-color", reply: { stdout: "", stderr: "" } });
546
572
 
547
573
  const result = await choudoufuLivePlan({ root: "estate", cwd: dir });
548
574
  expect(result.adoptions[0].command).toBeUndefined();
@@ -676,8 +702,14 @@ describe("choudoufuLiveCheck (#2103)", () => {
676
702
  });
677
703
  });
678
704
 
679
- describe("liveDocumentFrom (choudoufu #894)", () => {
680
- test("skips refresh progress lines that precede the document", async () => {
705
+ // Defensive, and no longer describing a live defect: choudoufu v0.14.0 keeps
706
+ // `-json` stdout to the document, so the slice is the identity on every
707
+ // supported binary. These cases pin the behaviour that makes keeping it cheap:
708
+ // a stray writer ahead of the document costs a slice rather than a parse
709
+ // failure, and a stdout with no document at all still reaches `JSON.parse`
710
+ // with its real text.
711
+ describe("liveDocumentFrom (defensive, choudoufu #894 fixed in v0.14.0)", () => {
712
+ test("skips any line that precedes the document, if a writer ever puts one there", async () => {
681
713
  const { liveDocumentFrom } = await import("./terraform");
682
714
  const stdout = 'aws_vpc.main: Refreshing state... [id=vpc-1]\naws_subnet.a: Refreshing state...\n{\n "bound": []\n}\n';
683
715
  expect(JSON.parse(liveDocumentFrom(stdout))).toEqual({ bound: [] });
@@ -77,10 +77,15 @@ export const DEFAULT_LIVE_PLAN_DOCUMENT_FILE = "chant.live-plan.json";
77
77
  * ([choudoufu #878](https://github.com/INTENTIUS/choudoufu/issues/878), PR
78
78
  * 889): under a live block `plan -out=FILE` is accepted and `apply FILE`
79
79
  * re-plans the live system and refuses on a mismatch with exit status 3.
80
- * {@link terraformApply} depends on that refusal existing, so the floor is
81
- * the release that shipped it rather than the one before.
80
+ * v0.14.0 brought the `-json` document on a configuration that names its own
81
+ * estate ([choudoufu #894](https://github.com/INTENTIUS/choudoufu/issues/894),
82
+ * PR 915), which is the shape every chant live root has: before it,
83
+ * {@link choudoufuLivePlan} could not produce the document for a real root at
84
+ * all. {@link terraformApply} depends on the exit-3 refusal and
85
+ * `describeResources()` and both document-reading Ops depend on the document,
86
+ * so the floor is the release that shipped the later of the two.
82
87
  */
83
- export const MIN_CHOUDOUFU_VERSION = "0.13.0";
88
+ export const MIN_CHOUDOUFU_VERSION = "0.14.0";
84
89
 
85
90
  /**
86
91
  * choudoufu's own refusal summary for `-out` on the `live-plan -estate`
@@ -480,10 +485,18 @@ export function terraformApplyCommand(opts: { binary: string; planFile: string }
480
485
  }
481
486
 
482
487
  /**
483
- * `live-plan -detailed-exitcode -json -estate=<estate>`. `-json` is what
488
+ * `live-plan -detailed-exitcode [-json] [-estate=<estate>]`. `-json` is what
484
489
  * prints GitHub issue #788's document instead of the plan; the human render
485
490
  * needs a second invocation without it (see {@link choudoufuLivePlan}).
486
491
  *
492
+ * `estate` is omitted exactly when the configuration names its own estate.
493
+ * choudoufu refuses `-estate` beside a `live` block or an `estate.chdf.hcl`
494
+ * sidecar ("Estate named by both the live block and -estate"), and since
495
+ * v0.14.0 it does not need one: the run reads the declared name itself and
496
+ * prints the same document ([choudoufu #894](https://github.com/INTENTIUS/choudoufu/issues/894),
497
+ * PR 915). The flag is still how a root that declares nothing names an
498
+ * estate for the run.
499
+ *
487
500
  * `adoptionOnly` makes that second invocation print the adoption ledger and
488
501
  * nothing else (GitHub issue #587). It is refused here alongside `json`
489
502
  * because choudoufu refuses the pair itself, and a builder that emitted a
@@ -492,7 +505,7 @@ export function terraformApplyCommand(opts: { binary: string; planFile: string }
492
505
  */
493
506
  export function choudoufuLivePlanCommand(opts: {
494
507
  binary: string;
495
- estate: string;
508
+ estate?: string;
496
509
  json: boolean;
497
510
  adoptionOnly?: boolean;
498
511
  noColor?: boolean;
@@ -508,7 +521,7 @@ export function choudoufuLivePlanCommand(opts: {
508
521
  if (opts.json) parts.push("-json");
509
522
  if (opts.adoptionOnly) parts.push("-adoption-only");
510
523
  if (opts.noColor) parts.push("-no-color");
511
- parts.push(`-estate=${quoteArg(opts.estate)}`);
524
+ if (opts.estate !== undefined) parts.push(`-estate=${quoteArg(opts.estate)}`);
512
525
  return parts.join(" ");
513
526
  }
514
527
 
@@ -618,7 +631,7 @@ export function isOlderVersion(version: string, min: string): boolean {
618
631
 
619
632
  /**
620
633
  * Extract the release version from `choudoufu version`'s human-readable
621
- * output: `"choudoufu v0.12.0 (based on OpenTofu v1.13.0)\non darwin_arm64"`.
634
+ * output: `"choudoufu v0.13.0 (based on OpenTofu v1.13.0)\non darwin_arm64"`.
622
635
  * `choudoufu version -json`'s `terraform_version` field is the wrong number
623
636
  * here: it names the upstream OpenTofu base this build forked from, not the
624
637
  * choudoufu release itself (`internal/command/views/version.go`), so this
@@ -961,11 +974,19 @@ function resolveEstate(args: { estate?: string }, resolved: ResolvedRoot, activi
961
974
  */
962
975
  /**
963
976
  * The JSON document within a `live-plan -json` stdout: everything from the
964
- * first line that is a bare `{` to the end. Stock keeps stdout to the
965
- * document; choudoufu's `-estate` path precedes it with refresh progress
966
- * lines (choudoufu #894). Returns the input unchanged when no such line
967
- * exists, so a clean stdout parses as before and a broken one fails in
968
- * `JSON.parse` with the real text in the error.
977
+ * first line that is a bare `{` to the end. Returns the input unchanged when
978
+ * no such line exists.
979
+ *
980
+ * Defensive, and kept deliberately. choudoufu v0.14.0 keeps `-json` stdout to
981
+ * the document on every form of live root, so on a supported binary this
982
+ * returns its input unchanged and the slice never fires: it was written for
983
+ * the `-estate` path's refresh progress lines
984
+ * ([choudoufu #894](https://github.com/INTENTIUS/choudoufu/issues/894), fixed
985
+ * in PR 915, which routes every human-prose renderer of a `-json` run through
986
+ * `views.View.StdoutOnStderr()`). What it buys is that a future writer put
987
+ * back on stdout ahead of the document costs a stale slice rather than a
988
+ * `JSON.parse` failure, and a stdout carrying no document at all still fails
989
+ * in `JSON.parse` with the real text in the error.
969
990
  */
970
991
  export function liveDocumentFrom(stdout: string): string {
971
992
  const lines = stdout.split("\n");
@@ -982,10 +1003,14 @@ export async function choudoufuLivePlan(
982
1003
  const estate = resolveEstate(args, resolved, "choudoufuLivePlan");
983
1004
  const env = terraformEnvironment(resolved.root);
984
1005
  const documentPath = args.documentPath ?? DEFAULT_LIVE_PLAN_DOCUMENT_FILE;
1006
+ // A root that declares its own estate is run without `-estate`: choudoufu
1007
+ // refuses the flag beside a declaration and, since v0.14.0, settles the name
1008
+ // from the declaration itself. See {@link choudoufuLivePlanCommand}.
1009
+ const estateFlag: { estate?: string } = resolved.estate === undefined ? { estate } : {};
985
1010
 
986
1011
  let drift: boolean;
987
1012
  let jsonStdout: string;
988
- const planCmd = choudoufuLivePlanCommand({ binary, estate, json: true });
1013
+ const planCmd = choudoufuLivePlanCommand({ binary, ...estateFlag, json: true });
989
1014
  try {
990
1015
  const { stdout, stderr } = await run(planCmd, dir, env, signal);
991
1016
  report(stdout, stderr);
@@ -1005,8 +1030,8 @@ export async function choudoufuLivePlan(
1005
1030
  jsonStdout = failure.stdout ?? "";
1006
1031
  }
1007
1032
 
1008
- // On the `-estate` path choudoufu prints refresh progress to stdout ahead of
1009
- // the document (choudoufu #894), so parse from the first line that opens it.
1033
+ // Belt and braces: v0.14.0 keeps `-json` stdout to the document, so this is
1034
+ // the identity on a supported binary. See {@link liveDocumentFrom}.
1010
1035
  const document = liveDocumentFrom(jsonStdout);
1011
1036
  const json: unknown = JSON.parse(document);
1012
1037
  // #788's document carries no render of the human plan itself
@@ -1015,19 +1040,32 @@ export async function choudoufuLivePlan(
1015
1040
  // (#2105) changes which report that second read prints, never whether there
1016
1041
  // is one: choudoufu refuses the flag alongside `-json`, so the two reports
1017
1042
  // were always going to be two runs.
1018
- const textRun = await run(
1019
- choudoufuLivePlanCommand({
1020
- binary,
1021
- estate,
1022
- json: false,
1023
- noColor: true,
1024
- ...(args.adoptionOnly ? { adoptionOnly: true } : {}),
1025
- }),
1026
- dir,
1027
- env,
1028
- signal,
1029
- );
1030
- const text = textRun.stdout;
1043
+ const textCmd = choudoufuLivePlanCommand({
1044
+ binary,
1045
+ ...estateFlag,
1046
+ json: false,
1047
+ noColor: true,
1048
+ ...(args.adoptionOnly ? { adoptionOnly: true } : {}),
1049
+ });
1050
+ // The human render carries `-detailed-exitcode` too, so a plan with changes
1051
+ // comes back as exit 2 on this call exactly as it does on the `-json` one.
1052
+ // Both are success, and the drift answer is already settled above, so exit 2
1053
+ // here yields its stdout rather than throwing; anything else is a failure
1054
+ // with choudoufu's own output attached.
1055
+ let text: string;
1056
+ try {
1057
+ text = (await run(textCmd, dir, env, signal)).stdout;
1058
+ } catch (err) {
1059
+ const failure = err as ExecFailure;
1060
+ if (failure.code !== 2) {
1061
+ const detail = (failure.stderr ?? "").trim() || (failure.stdout ?? "").trim();
1062
+ throw new Error(
1063
+ `${binary} live-plan (human render) failed in ${dir} (exit ${String(failure.code)})` +
1064
+ `${detail ? `\n${detail}` : ""}`,
1065
+ );
1066
+ }
1067
+ text = failure.stdout ?? "";
1068
+ }
1031
1069
 
1032
1070
  writeFileSync(join(dir, documentPath), document);
1033
1071
 
@@ -1050,7 +1088,7 @@ export async function choudoufuLivePlan(
1050
1088
  documentPath,
1051
1089
  estate,
1052
1090
  ...countLivePlanUnowned(json),
1053
- ...parseChoudoufuPlanSummary(textRun.stdout),
1091
+ ...parseChoudoufuPlanSummary(text),
1054
1092
  };
1055
1093
  }
1056
1094
 
@@ -84,11 +84,13 @@ export const terraformApply = (
84
84
  };
85
85
 
86
86
  /**
87
- * `choudoufu live-plan -detailed-exitcode -json -estate=<estate>` in the
88
- * named root, plus the human render. The fully typed twin of the
89
- * `choudoufuLivePlan` activity (#2103). `opts` is {@link ChoudoufuLivePlanArgs}
90
- * itself, minus the positional `root`; `estate` is optional, auto-detected
91
- * from the root's `live` block or `estate.chdf.hcl` sidecar when omitted.
87
+ * `choudoufu live-plan -detailed-exitcode -json` in the named root, plus the
88
+ * human render. The fully typed twin of the `choudoufuLivePlan` activity
89
+ * (#2103). `opts` is {@link ChoudoufuLivePlanArgs} itself, minus the
90
+ * positional `root`; `estate` is optional, auto-detected from the root's
91
+ * `live` block or `estate.chdf.hcl` sidecar when omitted, and a root that
92
+ * declares one is run with no `-estate` at all, which is the only form
93
+ * choudoufu admits there (choudoufu #894, fixed in v0.14.0).
92
94
  * Defaults to the `longInfra` profile: like `terraformPlan`, this reads the
93
95
  * live system in full (the estate-wide sweep).
94
96
  *
@@ -1,31 +1,45 @@
1
1
  /**
2
2
  * Best-effort, synchronous read of whether a named `terraform.roots` entry is
3
3
  * live: `terraform.binary` is `"choudoufu"` and the root's directory declares
4
- * an estate — a `live { }` block or an `estate.chdf.hcl` sidecar (#2103).
4
+ * an estate, either a `live { }` block or an `estate.chdf.hcl` sidecar (#2103).
5
5
  *
6
- * Two callers decide something at build time, before any activity runs, and
7
- * neither can afford the async work `../op/activities/terraform.ts`'s own
6
+ * Three callers decide something at build time, before any activity runs, and
7
+ * none can afford the async work `../op/activities/terraform.ts`'s own
8
8
  * `resolveRoot` does (`loadChantConfigUpward`, a full `chant.config.ts`/`.json`
9
9
  * walk):
10
10
  *
11
- * - `TerraformApplyOp` (`../composites/terraform-apply-op.ts`) decides which
12
- * phase shape to build — a stock plan-file pairing or a live re-plan-at-
13
- * apply shape — before any step is emitted.
11
+ * - `TerraformApplyOp` (`../composites/terraform-apply-op.ts`) reads the
12
+ * mode to word the Gate phase's approval description, and to refuse a
13
+ * root whose `policy` block sets `undeclared_untagged = "delete"`. The
14
+ * mode no longer picks a phase shape: #2157 made the live and stock Apply
15
+ * steps one step, so both roots build the same phases.
16
+ * - `TerraformWatchOp` (`../composites/terraform-watch-op.ts`) cross-checks
17
+ * its hand-set `live` flag against the root's real mode and refuses a
18
+ * mismatch (#2216).
14
19
  * - TF101 (`../lint/rules/plan-before-apply.ts`) decides whether a
15
20
  * `terraformApply` call it is statically inspecting is exempt from the
16
21
  * plan-file pairing it otherwise enforces.
17
22
  *
18
- * Both run in the build process (`chant build`/`chant lint`) rather than in
19
- * an activity at run time, so a synchronous read is available, but only the
20
- * cheap half of it: `findProjectConfig` (`@intentius/chant/project-root`) is a plain, already-
21
- * synchronous upward filesystem walk, but a `chant.config.ts` is project-
22
- * authored code, and evaluating it synchronously outside chant's own
23
- * config-sandbox machinery is more than either caller's best-effort question
23
+ * All three run in the build process (`chant build`/`chant lint`) rather than
24
+ * in an activity at run time, so a synchronous read is available, but only the
25
+ * cheap half of it: `findProjectConfig` (`@intentius/chant/project-root`) is a
26
+ * plain, already-synchronous upward filesystem walk, but a `chant.config.ts`
27
+ * is project-authored code, and evaluating it synchronously outside chant's
28
+ * own config-sandbox machinery is more than any caller's best-effort question
24
29
  * is worth. So this reads `chant.config.json` only; a project on a `.ts`
25
30
  * config, or with no config discoverable at all, or naming no matching root,
26
- * resolves to `undefined` — "unknown" reads as "stock" to both callers, which
27
- * is the conservative direction: TF101 keeps firing, and the composite keeps
28
- * building the plan-file-carrying shape.
31
+ * resolves to `undefined`, and "unknown" reads as "stock" to every caller.
32
+ *
33
+ * That default is conservative for the two callers that only ever add a
34
+ * requirement by knowing more: TF101 keeps enforcing the plan-file pairing,
35
+ * and the watch Op keeps accepting the flag it was given. It is the
36
+ * permissive direction for the apply Op's policy refusal and for the watch
37
+ * Op's mismatch, which is why neither refusal is left to this function alone:
38
+ * TF027 and TF028 (`../lint/post-synth/`) ask the same questions of the
39
+ * parsed HCL the build already stamped `mode` onto, whatever the config file
40
+ * is written in, and those checks are the guarantee (#2216). Every project in
41
+ * this repository is on a `chant.config.ts`, so a refusal that resolves the
42
+ * mode through this function alone never fires for anyone.
29
43
  */
30
44
 
31
45
  import { existsSync, readFileSync } from "node:fs";
@@ -337,3 +337,41 @@ describe("parseTerraformRootContent", () => {
337
337
  expect([...entities.keys()]).toEqual(["app/locals", "app/locals~2"]);
338
338
  });
339
339
  });
340
+
341
+ describe("detectTemplate", () => {
342
+ const detect = (data: unknown) => terraformPlugin.detectTemplate?.(data) ?? false;
343
+
344
+ it("recognises .tf text by its top-level block headers", () => {
345
+ expect(detect('terraform {\n required_version = ">= 1.5.0"\n}')).toBe(true);
346
+ expect(detect('resource "null_resource" "first" {}')).toBe(true);
347
+ expect(detect('data "aws_ami" "ubuntu" {\n most_recent = true\n}')).toBe(true);
348
+ expect(detect('provider "aws" {\n region = "us-east-1"\n}')).toBe(true);
349
+ expect(detect('module "vpc" {\n source = "./modules/vpc"\n}')).toBe(true);
350
+ expect(detect('variable "region" {\n type = string\n}')).toBe(true);
351
+ expect(detect('output "id" {\n value = null_resource.first.id\n}')).toBe(true);
352
+ });
353
+
354
+ it("recognises the .tf.json syntax by its whole key set", () => {
355
+ expect(detect({ resource: { null_resource: { first: {} } } })).toBe(true);
356
+ expect(detect({ terraform: { required_version: ">= 1.5.0" }, provider: { aws: {} } })).toBe(true);
357
+ });
358
+
359
+ it("refuses another lexicon's document, and anything with no block at all", () => {
360
+ // k8s / fountain / gcp, which share the apiVersion+kind shape.
361
+ expect(detect({ apiVersion: "v1", kind: "ConfigMap", data: {} })).toBe(false);
362
+ // CloudFormation, whose `Resources` is not one of Terraform's block names.
363
+ expect(detect({ AWSTemplateFormatVersion: "2010-09-09", Resources: {} })).toBe(false);
364
+ // Compose.
365
+ expect(detect({ services: { web: { image: "nginx" } } })).toBe(false);
366
+ expect(detect("# just a comment\n")).toBe(false);
367
+ expect(detect("")).toBe(false);
368
+ expect(detect({})).toBe(false);
369
+ expect(detect(null)).toBe(false);
370
+ expect(detect([{ resource: {} }])).toBe(false);
371
+ expect(detect(42)).toBe(false);
372
+ });
373
+
374
+ it("does not match a block header that is only mentioned mid-line", () => {
375
+ expect(detect('# see resource "null_resource" "first" {} in main.tf')).toBe(false);
376
+ });
377
+ });
package/src/plugin.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { LexiconPlugin } from "@intentius/chant/lexicon";
1
+ import type { AuditEntitiesInput, LexiconPlugin } from "@intentius/chant/lexicon";
2
2
  import type { CompletionContext, HoverContext } from "@intentius/chant/lsp/types";
3
3
  import type { Declarable } from "@intentius/chant/declarable";
4
4
  import { createSkillsLoader } from "@intentius/chant/lexicon-plugin-helpers";
@@ -11,7 +11,8 @@ import { completions } from "./lsp/completions";
11
11
  import { hover } from "./lsp/hover";
12
12
  import { terraformConfigSchema, type TerraformConfig } from "./config";
13
13
  import { renderTerraformRoots } from "./hcl/roots";
14
- import { parseTerraformRootContent, RESOURCE_TYPE } from "./hcl/parse";
14
+ import { auditRootName, parseTerraformRootContent, RESOURCE_TYPE } from "./hcl/parse";
15
+ import { descendModules } from "./hcl/descend";
15
16
  import { TERRAFORM_STATE_OWNERSHIP_KEYS } from "./state-ownership";
16
17
 
17
18
  const loadSkills = createSkillsLoader(import.meta.url, [
@@ -23,6 +24,43 @@ const loadSkills = createSkillsLoader(import.meta.url, [
23
24
  },
24
25
  ]);
25
26
 
27
+ /**
28
+ * The only top-level names Terraform's JSON syntax admits
29
+ * (developer.hashicorp.com/terraform/language/syntax/json). Used as a
30
+ * whole-document test in `detectTemplate`: a `.tf.json` file has these keys
31
+ * and nothing else.
32
+ */
33
+ const TF_JSON_BLOCK_TYPES = new Set([
34
+ "terraform",
35
+ "provider",
36
+ "variable",
37
+ "output",
38
+ "locals",
39
+ "module",
40
+ "resource",
41
+ "data",
42
+ "moved",
43
+ "import",
44
+ "check",
45
+ "removed",
46
+ ]);
47
+
48
+ /**
49
+ * Whether raw text is HCL a Terraform root or module is written in. One
50
+ * top-level block header is enough, and every one of these headers is
51
+ * Terraform's own: `terraform {`, and the labelled `resource`/`data`
52
+ * (two labels), `provider`/`module`/`variable`/`output` (one label). `locals`
53
+ * is left out on purpose, since it is the one block a non-Terraform HCL
54
+ * dialect is also likely to carry.
55
+ */
56
+ function looksLikeHcl(text: string): boolean {
57
+ return (
58
+ /^[ \t]*terraform[ \t]*\{/m.test(text) ||
59
+ /^[ \t]*(resource|data)[ \t]+"[^"]+"[ \t]+"[^"]+"[ \t]*\{/m.test(text) ||
60
+ /^[ \t]*(provider|module|variable|output)[ \t]+"[^"]+"[ \t]*\{/m.test(text)
61
+ );
62
+ }
63
+
26
64
  /**
27
65
  * terraform lexicon plugin.
28
66
  *
@@ -166,46 +204,99 @@ export const terraformPlugin: LexiconPlugin = {
166
204
  },
167
205
 
168
206
  /**
169
- * Parse-to-graph for `chant audit` (#1567, #2085). `content` is the
207
+ * Parse-to-graph for `chant audit` (#1567, #2085, #2217). `content` is the
170
208
  * `# file: <name>`-joined bundle discovery builds for one discovered root
171
- * module (`classifyTerraform`, `packages/core/src/audit/core.ts`); the root
172
- * name itself isn't threaded through this hook's single-argument contract,
173
- * so a fixed placeholder ("audit-root") stands in for it. TF001 only uses
174
- * the root name to group and de-duplicate diagnostics within one parse, so
175
- * this is enough for the same graph-reading check that fires on `chant
176
- * build` to fire here too. Never throws: malformed HCL yields an empty map.
209
+ * module (`classifyTerraform`, `packages/core/src/audit/discover.ts`), and
210
+ * `input` says where that bundle came from.
211
+ *
212
+ * The scope is named after the input's path (`auditRootName`), so two roots
213
+ * in one repository are two roots here as well: they no longer collide in
214
+ * the merged entity map the all-files pass reads, which is what used to
215
+ * produce a finding keyed `<root>/<address>#2` against the file
216
+ * `(cross-file)` that neither directory deserved.
217
+ *
218
+ * When the audit walked a local filesystem, `input.dir` is the root
219
+ * module's directory and the parse descends its local `module` calls
220
+ * exactly as `buildRoots()` does, through the same `descendModules`
221
+ * (`../hcl/descend.ts`): a child module's entities are keyed
222
+ * `<root>/module.<name>/<address>` and carry the caller chain, so TF014 and
223
+ * TF015 fire here with the same `Callers:` line they print on a build, and
224
+ * the root-only rules (TF001 to TF003) still see only the root's own
225
+ * blocks. `input.baseDir` is the audited directory, which bounds the
226
+ * descent the way the project root bounds it on a build. Discovery drops a
227
+ * directory another one calls as a local module, so no module is audited
228
+ * twice.
229
+ *
230
+ * With no `input.dir` (a fetched repository; a caller parsing a bare
231
+ * string) the bundle is parsed alone, and the two child-module rules have
232
+ * no child to report on, since nothing named one.
233
+ *
234
+ * The descent's own refusals (a registry source, a source outside the
235
+ * audited tree, a cycle) are warnings on a build; this hook returns
236
+ * entities only, so on the audit path they are dropped rather than printed.
177
237
  *
178
- * Module descent (#2112) reaches this path through the same
179
- * `blocksToEntities`, but finds nothing to descend into: this hook is
180
- * handed one directory's joined text and no directory to read a local
181
- * module's files from, and audit discovery has already classified every
182
- * nested `.tf` directory as an audit input of its own (`classifyTerraform`,
183
- * `packages/core/src/audit/discover.ts`). So a child module IS audited on
184
- * this path, as its own root rather than as a child scope, which means the
185
- * two rules that ask "is this block inside a child module?" (TF014, TF015)
186
- * report on `chant build` and not in `chant audit`, and TF020 answers per
187
- * directory either way. The rule pages say so.
238
+ * Never throws: malformed HCL yields an empty map.
188
239
  */
189
- async auditEntities(content: string): Promise<Map<string, Declarable>> {
240
+ async auditEntities(content: string, input?: AuditEntitiesInput): Promise<Map<string, Declarable>> {
190
241
  try {
191
- return await parseTerraformRootContent(content, "audit-root");
242
+ const root = auditRootName(input?.path);
243
+ const entities = await parseTerraformRootContent(content, root);
244
+ if (input?.dir === undefined) return entities;
245
+ const { entities: children } = await descendModules(entities, {
246
+ dir: input.dir,
247
+ root,
248
+ projectRoot: input.baseDir ?? input.dir,
249
+ });
250
+ for (const [key, entity] of children) entities.set(key, entity);
251
+ return entities;
192
252
  } catch {
193
253
  return new Map();
194
254
  }
195
255
  },
196
256
 
257
+ /**
258
+ * No `mcpTools()` and no `mcpResources()`, deliberately (#2220). Both were
259
+ * declared returning `[]` behind a TODO, which passed the two tier-2
260
+ * "registers <method>" checks on a member that contributed nothing. Neither
261
+ * of the two shapes every other lexicon uses has anything to serve here:
262
+ * `createDiffTool` diffs one build's serializer output against the last,
263
+ * and this serializer writes nothing (`src/serializer.ts` is a deliberate
264
+ * no-op, the `.tf` files are the artifact); `createCatalogResource` serves
265
+ * the generated resource registry, and this lexicon generates none
266
+ * (`src/codegen/generate.ts` writes `{}` on purpose). So the members are
267
+ * removed rather than faked, and `chant dev check-lexicon` reports two
268
+ * tier-2 gaps that are real.
269
+ */
197
270
  skills: loadSkills,
198
271
 
199
- mcpTools() {
200
- return []; // TODO: Implement MCP tools
201
- },
202
-
203
- mcpResources() {
204
- return []; // TODO: Implement MCP resources
205
- },
206
-
207
- detectTemplate(_data: unknown) {
208
- return false; // TODO: Detect if a template belongs to this lexicon
272
+ /**
273
+ * `.tf` (and Terraform's own JSON syntax, `.tf.json`) recognised by content
274
+ * rather than by which directory a file sits in (#2220).
275
+ *
276
+ * Two input shapes, because the two callers hand over different things.
277
+ * `chant import` (`packages/core/src/cli/commands/import.ts`) `JSON.parse`s
278
+ * the file first, so it arrives as an object; the audit's content detection
279
+ * (`packages/core/src/audit/discover.ts`) hands azure its raw string, and a
280
+ * `.tf` file is not JSON at all, so the raw string form is the one that
281
+ * matters for HCL. Anything else reads as false.
282
+ *
283
+ * The object form is exact rather than heuristic: Terraform's JSON syntax
284
+ * admits only the top-level names in {@link TF_JSON_BLOCK_TYPES}, so a
285
+ * document is `.tf.json` when every key is one of them and at least one is
286
+ * present. That refuses a Kubernetes manifest, a CloudFormation template and
287
+ * a Compose file without having to name any of them.
288
+ *
289
+ * Discovery still classifies Terraform by the directory bundle
290
+ * (`classifyTerraform`), because a root module is a directory of files that
291
+ * has to be parsed together and one `.tf` on its own is not an audit input.
292
+ * This makes the per-file question answerable for the callers that ask it
293
+ * one file at a time.
294
+ */
295
+ detectTemplate(data: unknown) {
296
+ if (typeof data === "string") return looksLikeHcl(data);
297
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return false;
298
+ const keys = Object.keys(data as Record<string, unknown>);
299
+ return keys.length > 0 && keys.every((k) => TF_JSON_BLOCK_TYPES.has(k));
209
300
  },
210
301
 
211
302
  completionProvider(ctx: CompletionContext) {