@intentius/chant 0.78.0 → 0.80.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 (195) hide show
  1. package/dist/audit/core.d.ts.map +1 -1
  2. package/dist/audit/discover.d.ts +38 -0
  3. package/dist/audit/discover.d.ts.map +1 -1
  4. package/dist/audit/terraform-state.d.ts +14 -0
  5. package/dist/audit/terraform-state.d.ts.map +1 -1
  6. package/dist/cli/command-group.d.ts +6 -0
  7. package/dist/cli/command-group.d.ts.map +1 -1
  8. package/dist/cli/commands/audit.d.ts +27 -2
  9. package/dist/cli/commands/audit.d.ts.map +1 -1
  10. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
  11. package/dist/cli/commands/doctor.d.ts.map +1 -1
  12. package/dist/cli/commands/import-agents.d.ts.map +1 -1
  13. package/dist/cli/commands/init.d.ts +6 -0
  14. package/dist/cli/commands/init.d.ts.map +1 -1
  15. package/dist/cli/commands/lint.d.ts.map +1 -1
  16. package/dist/cli/commands/onboard.d.ts.map +1 -1
  17. package/dist/cli/commands/update.d.ts.map +1 -1
  18. package/dist/cli/conflict-check.d.ts +6 -0
  19. package/dist/cli/conflict-check.d.ts.map +1 -1
  20. package/dist/cli/handlers/dev.d.ts.map +1 -1
  21. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  22. package/dist/cli/handlers/misc.d.ts.map +1 -1
  23. package/dist/cli/handlers/operator.d.ts +17 -0
  24. package/dist/cli/handlers/operator.d.ts.map +1 -1
  25. package/dist/cli/handlers/promote.d.ts +17 -0
  26. package/dist/cli/handlers/promote.d.ts.map +1 -0
  27. package/dist/cli/handlers/run-generate.d.ts +39 -0
  28. package/dist/cli/handlers/run-generate.d.ts.map +1 -0
  29. package/dist/cli/handlers/run.d.ts.map +1 -1
  30. package/dist/cli/main.d.ts.map +1 -1
  31. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  32. package/dist/cli/plugins.d.ts +9 -0
  33. package/dist/cli/plugins.d.ts.map +1 -1
  34. package/dist/cli/registry.d.ts +18 -1
  35. package/dist/cli/registry.d.ts.map +1 -1
  36. package/dist/components/capability-plugin-loader.d.ts.map +1 -1
  37. package/dist/components/cli-support.d.ts.map +1 -1
  38. package/dist/components/discover.d.ts.map +1 -1
  39. package/dist/components/driver.d.ts.map +1 -1
  40. package/dist/components/fan-out-support.d.ts.map +1 -1
  41. package/dist/components/pilots/alb-ecs.pilot.d.ts +7 -1
  42. package/dist/components/pilots/alb-ecs.pilot.d.ts.map +1 -1
  43. package/dist/components/presets/ecs-fargate.d.ts +10 -4
  44. package/dist/components/presets/ecs-fargate.d.ts.map +1 -1
  45. package/dist/components/promote.d.ts +211 -0
  46. package/dist/components/promote.d.ts.map +1 -0
  47. package/dist/components/unbound-gate-approval.d.ts +24 -0
  48. package/dist/components/unbound-gate-approval.d.ts.map +1 -0
  49. package/dist/config.d.ts +82 -5
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/discovery/convergence.d.ts +97 -0
  52. package/dist/discovery/convergence.d.ts.map +1 -0
  53. package/dist/discovery/files.d.ts +41 -2
  54. package/dist/discovery/files.d.ts.map +1 -1
  55. package/dist/discovery/fold-import.d.ts +15 -0
  56. package/dist/discovery/fold-import.d.ts.map +1 -1
  57. package/dist/graph-ir.d.ts +13 -0
  58. package/dist/graph-ir.d.ts.map +1 -1
  59. package/dist/lexicon-module.d.ts +65 -0
  60. package/dist/lexicon-module.d.ts.map +1 -0
  61. package/dist/lexicon.d.ts +24 -0
  62. package/dist/lexicon.d.ts.map +1 -1
  63. package/dist/lifecycle/build-ledger-store.d.ts.map +1 -1
  64. package/dist/lifecycle/gate-ledger.d.ts +22 -0
  65. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  66. package/dist/lifecycle/legacy-digest.d.ts +26 -0
  67. package/dist/lifecycle/legacy-digest.d.ts.map +1 -0
  68. package/dist/lifecycle/release-ledger.d.ts +25 -0
  69. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  70. package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts +2 -3
  71. package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts.map +1 -1
  72. package/dist/lint/rules/op/index.d.ts +2 -1
  73. package/dist/lint/rules/op/index.d.ts.map +1 -1
  74. package/dist/lint/rules/op/ops015-gate-approval.d.ts +14 -0
  75. package/dist/lint/rules/op/ops015-gate-approval.d.ts.map +1 -0
  76. package/dist/op/activity-contract-registry.d.ts.map +1 -1
  77. package/dist/op/activity-registry.d.ts.map +1 -1
  78. package/dist/op/builders.d.ts +4 -0
  79. package/dist/op/builders.d.ts.map +1 -1
  80. package/dist/op/discover.d.ts.map +1 -1
  81. package/dist/op/gate-approval.d.ts +142 -0
  82. package/dist/op/gate-approval.d.ts.map +1 -0
  83. package/dist/op/gate.d.ts +54 -0
  84. package/dist/op/gate.d.ts.map +1 -1
  85. package/dist/op/generate-pipeline.d.ts.map +1 -1
  86. package/dist/op/index.d.ts +4 -1
  87. package/dist/op/index.d.ts.map +1 -1
  88. package/dist/op/local-executor.d.ts +4 -0
  89. package/dist/op/local-executor.d.ts.map +1 -1
  90. package/dist/op/op-ir.d.ts +2 -0
  91. package/dist/op/op-ir.d.ts.map +1 -1
  92. package/dist/op/runtimes/local.d.ts.map +1 -1
  93. package/dist/op/types.d.ts +7 -0
  94. package/dist/op/types.d.ts.map +1 -1
  95. package/dist/workspace/record-source.d.ts +22 -0
  96. package/dist/workspace/record-source.d.ts.map +1 -0
  97. package/dist/workspace/records-cli.d.ts +57 -0
  98. package/dist/workspace/records-cli.d.ts.map +1 -0
  99. package/dist/workspace/records.d.ts +118 -0
  100. package/dist/workspace/records.d.ts.map +1 -0
  101. package/package.json +3 -3
  102. package/src/audit/core.ts +13 -2
  103. package/src/audit/discover.ts +90 -12
  104. package/src/audit/terraform-state.test.ts +86 -1
  105. package/src/audit/terraform-state.ts +26 -0
  106. package/src/cli/command-group.ts +7 -0
  107. package/src/cli/commands/audit-walk-warnings.test.ts +156 -0
  108. package/src/cli/commands/audit.ts +93 -19
  109. package/src/cli/commands/check-lexicon-examples.ts +2 -1
  110. package/src/cli/commands/doctor.ts +3 -0
  111. package/src/cli/commands/import-agents.ts +11 -4
  112. package/src/cli/commands/init.ts +57 -25
  113. package/src/cli/commands/lint.test.ts +58 -1
  114. package/src/cli/commands/lint.ts +48 -18
  115. package/src/cli/commands/onboard.ts +15 -0
  116. package/src/cli/commands/update.ts +5 -1
  117. package/src/cli/conflict-check.test.ts +18 -1
  118. package/src/cli/conflict-check.ts +13 -0
  119. package/src/cli/discovery-skip.test.ts +168 -0
  120. package/src/cli/handlers/build.test.ts +11 -0
  121. package/src/cli/handlers/build.ts +1 -1
  122. package/src/cli/handlers/dev.ts +3 -0
  123. package/src/cli/handlers/graph.test.ts +20 -0
  124. package/src/cli/handlers/graph.ts +4 -2
  125. package/src/cli/handlers/lifecycle.ts +2 -1
  126. package/src/cli/handlers/misc.ts +16 -0
  127. package/src/cli/handlers/operator.test.ts +85 -0
  128. package/src/cli/handlers/operator.ts +118 -2
  129. package/src/cli/handlers/promote.test.ts +261 -0
  130. package/src/cli/handlers/promote.ts +325 -0
  131. package/src/cli/handlers/run-generate.test.ts +160 -0
  132. package/src/cli/handlers/run-generate.ts +133 -0
  133. package/src/cli/handlers/run.ts +13 -1
  134. package/src/cli/main.test.ts +33 -1
  135. package/src/cli/main.ts +78 -16
  136. package/src/cli/mcp/op-tools.ts +2 -1
  137. package/src/cli/path-lexicon-messages.test.ts +152 -0
  138. package/src/cli/plugins.ts +44 -6
  139. package/src/cli/registry.ts +18 -1
  140. package/src/components/__fixtures__/alb-ecs-service.json +0 -8
  141. package/src/components/capability-plugin-loader.ts +3 -1
  142. package/src/components/cli-support.ts +3 -2
  143. package/src/components/discover.ts +26 -2
  144. package/src/components/driver.test.ts +12 -11
  145. package/src/components/driver.ts +2 -0
  146. package/src/components/fan-out-support.ts +2 -1
  147. package/src/components/pilots/README.md +1 -1
  148. package/src/components/pilots/alb-ecs.pilot.ts +7 -7
  149. package/src/components/presets/ecs-fargate.ts +10 -5
  150. package/src/components/promote.test.ts +371 -0
  151. package/src/components/promote.ts +537 -0
  152. package/src/components/unbound-gate-approval.test.ts +82 -0
  153. package/src/components/unbound-gate-approval.ts +51 -0
  154. package/src/config.test.ts +40 -0
  155. package/src/config.ts +128 -11
  156. package/src/discovery/convergence.test.ts +231 -0
  157. package/src/discovery/convergence.ts +399 -0
  158. package/src/discovery/files.test.ts +127 -1
  159. package/src/discovery/files.ts +97 -9
  160. package/src/discovery/fold-import.ts +83 -9
  161. package/src/discovery/sandbox/path-lexicon-parity.test.ts +162 -0
  162. package/src/graph-ir.ts +14 -0
  163. package/src/lexicon-module.test.ts +203 -0
  164. package/src/lexicon-module.ts +117 -0
  165. package/src/lexicon.ts +26 -0
  166. package/src/lifecycle/build-ledger-store.ts +11 -1
  167. package/src/lifecycle/gate-ledger.ts +22 -0
  168. package/src/lifecycle/legacy-digest.test.ts +114 -0
  169. package/src/lifecycle/legacy-digest.ts +62 -0
  170. package/src/lifecycle/release-ledger.ts +34 -0
  171. package/src/lint/rules/comp/comp003-mutating-no-rollback.ts +2 -3
  172. package/src/lint/rules/op/index.ts +4 -2
  173. package/src/lint/rules/op/ops015-gate-approval.test.ts +67 -0
  174. package/src/lint/rules/op/ops015-gate-approval.ts +57 -0
  175. package/src/op/activity-contract-registry.ts +18 -4
  176. package/src/op/activity-registry.ts +26 -0
  177. package/src/op/builders.ts +10 -1
  178. package/src/op/discover-convergence.test.ts +70 -0
  179. package/src/op/discover.ts +4 -0
  180. package/src/op/gate-approval.test.ts +258 -0
  181. package/src/op/gate-approval.ts +260 -0
  182. package/src/op/gate.ts +148 -10
  183. package/src/op/generate-pipeline.ts +2 -1
  184. package/src/op/index.ts +9 -1
  185. package/src/op/local-executor.test.ts +66 -0
  186. package/src/op/local-executor.ts +44 -1
  187. package/src/op/op-ir.ts +4 -0
  188. package/src/op/runtimes/local.ts +2 -1
  189. package/src/op/types.ts +7 -0
  190. package/src/workspace/record-source.ts +117 -0
  191. package/src/workspace/records-cli.ts +126 -0
  192. package/src/workspace/records-contract.test.ts +88 -0
  193. package/src/workspace/records.schema.json +120 -0
  194. package/src/workspace/records.test.ts +243 -0
  195. package/src/workspace/records.ts +383 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Records read through a record kind (#2524 D4), first-test slice (#2546).
3
+ *
4
+ * A record kind is data. It says where its records live, which JSON Schema
5
+ * they follow and which of their states are closed. This module reads every
6
+ * record a kind locates, parses its front matter as the JSON subset of YAML,
7
+ * validates it against the kind's schema and derives supersession from the
8
+ * records' own `supersedes` links. It never writes a record.
9
+ *
10
+ * A record that fails any of that is still returned, with reason codes, and the
11
+ * read succeeds. Only a failure to read the kind, its schema or the revision is
12
+ * an error. Seals, attestation and the workspace declaration come later (#2546,
13
+ * #2534); nothing here needs a `chant.workspace.json`.
14
+ *
15
+ * Everything under `workspace/` loads only when a `chant workspace` command
16
+ * runs. The level-0 goldens (#2526) fail if a level-0 command loads it.
17
+ */
18
+ import { z } from "zod";
19
+ import type { RecordSource } from "./record-source.js";
20
+ /**
21
+ * Why one record is not valid. The list is closed: a reader may switch on it,
22
+ * and a new code is a contract change (#2536).
23
+ */
24
+ export declare const RECORD_REASON_CODES: readonly ["record-unparseable", "record-schema-invalid", "record-id-duplicate", "record-supersedes-unknown", "record-supersedes-conflict"];
25
+ export type RecordReasonCode = (typeof RECORD_REASON_CODES)[number];
26
+ /**
27
+ * Why the read as a whole failed. Also closed. The command exits 1 with one of
28
+ * these and returns no records.
29
+ */
30
+ export declare const READ_ERROR_CODES: readonly ["kind-unreadable", "kind-invalid", "schema-unreadable", "schema-id-mismatch", "schema-invalid", "location-missing", "not-a-git-repository", "revision-unknown"];
31
+ export type ReadErrorCode = (typeof READ_ERROR_CODES)[number];
32
+ export declare class RecordReadError extends Error {
33
+ readonly code: ReadErrorCode;
34
+ constructor(code: ReadErrorCode, message: string);
35
+ }
36
+ /** The data a kind file exports as `recordKind`. */
37
+ export declare const recordKindSchema: z.ZodObject<{
38
+ name: z.ZodString;
39
+ location: z.ZodObject<{
40
+ dir: z.ZodString;
41
+ match: z.ZodString;
42
+ }, z.core.$strict>;
43
+ format: z.ZodLiteral<"markdown-front-matter">;
44
+ schema: z.ZodObject<{
45
+ id: z.ZodString;
46
+ path: z.ZodString;
47
+ }, z.core.$strict>;
48
+ idField: z.ZodString;
49
+ stateField: z.ZodString;
50
+ states: z.ZodArray<z.ZodString>;
51
+ closedStates: z.ZodArray<z.ZodString>;
52
+ supersedes: z.ZodObject<{
53
+ field: z.ZodString;
54
+ key: z.ZodString;
55
+ }, z.core.$strict>;
56
+ }, z.core.$strict>;
57
+ export type RecordKind = z.infer<typeof recordKindSchema>;
58
+ /** A kind as loaded: its data, where it came from, and its schema. */
59
+ export interface LoadedRecordKind {
60
+ kind: RecordKind;
61
+ /** Absolute path of the kind file. */
62
+ file: string;
63
+ /** Absolute path of the records directory in the working tree. */
64
+ dir: string;
65
+ schema: Record<string, unknown>;
66
+ }
67
+ /** Load a kind file and the schema it names. `path` is resolved against `cwd`. */
68
+ export declare function loadRecordKind(path: string, cwd?: string): Promise<LoadedRecordKind>;
69
+ export type FrontMatter = {
70
+ ok: true;
71
+ value: Record<string, unknown>;
72
+ } | {
73
+ ok: false;
74
+ message: string;
75
+ };
76
+ /**
77
+ * The front matter of a Markdown file, limited to what YAML and JSON share
78
+ * (#2524 D4). Line endings are normalised first. Aliases, and any value JSON
79
+ * cannot hold, are refused, so the same text always reads as the same JSON.
80
+ */
81
+ export declare function parseFrontMatter(text: string): FrontMatter;
82
+ export interface RecordReason {
83
+ code: RecordReasonCode;
84
+ message: string;
85
+ }
86
+ export interface RecordEntry {
87
+ /** The record's id, or null when it could not be read. */
88
+ id: string | null;
89
+ /** Path from the repository root (or the working directory outside git), with `/` separators. */
90
+ path: string;
91
+ /** The record's state as written, or null when it could not be read. */
92
+ state: string | null;
93
+ valid: boolean;
94
+ reasons: RecordReason[];
95
+ /** The id of the closed record whose `supersedes` link replaces this one, or null. */
96
+ supersededBy: string | null;
97
+ /** The front matter as JSON, or null when it could not be parsed. */
98
+ data: Record<string, unknown> | null;
99
+ }
100
+ export interface ReadRecordsOptions {
101
+ /** Where record paths are reported from, and what `source` reads relative to. */
102
+ root: string;
103
+ source: RecordSource;
104
+ /** Leave out records a closed record supersedes. */
105
+ current?: boolean;
106
+ }
107
+ export interface ReadRecordsResult {
108
+ records: RecordEntry[];
109
+ summary: {
110
+ total: number;
111
+ valid: number;
112
+ invalid: number;
113
+ superseded: number;
114
+ };
115
+ }
116
+ /** Read every record `loaded` locates, through `options.source`. */
117
+ export declare function readRecords(loaded: LoadedRecordKind, options: ReadRecordsOptions): Promise<ReadRecordsResult>;
118
+ //# sourceMappingURL=records.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"records.d.ts","sourceRoot":"","sources":["../../src/workspace/records.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAKH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAIpD;;;GAGG;AACH,eAAO,MAAM,mBAAmB,4IAWtB,CAAC;AACX,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,mBAAmB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEpE;;;GAGG;AACH,eAAO,MAAM,gBAAgB,2KAiBnB,CAAC;AACX,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,gBAAgB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE9D,qBAAa,eAAgB,SAAQ,KAAK;IAEtC,QAAQ,CAAC,IAAI,EAAE,aAAa;gBAAnB,IAAI,EAAE,aAAa,EAC5B,OAAO,EAAE,MAAM;CAKlB;AAMD,oDAAoD;AACpD,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;kBAoCzB,CAAC;AAEL,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE1D,sEAAsE;AACtE,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,UAAU,CAAC;IACjB,sCAAsC;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,kEAAkE;IAClE,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC;AAmBD,kFAAkF;AAClF,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,GAAE,MAAsB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAiCzG;AAID,MAAM,MAAM,WAAW,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAExG;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAgB1D;AA0BD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,gBAAgB,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,0DAA0D;IAC1D,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,iGAAiG;IACjG,IAAI,EAAE,MAAM,CAAC;IACb,wEAAwE;IACxE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,KAAK,EAAE,OAAO,CAAC;IACf,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,sFAAsF;IACtF,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACtC;AAED,MAAM,WAAW,kBAAkB;IACjC,iFAAiF;IACjF,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,YAAY,CAAC;IACrB,oDAAoD;IACpD,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,OAAO,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;CAChF;AAsBD,oEAAoE;AACpE,wBAAsB,WAAW,CAAC,MAAM,EAAE,gBAAgB,EAAE,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAgFnH"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.78.0",
3
+ "version": "0.80.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",
@@ -91,6 +91,7 @@
91
91
  },
92
92
  "dependencies": {
93
93
  "@dagrejs/dagre": "^3.0.0",
94
+ "ajv": "^8.20.0",
94
95
  "esbuild": "^0.28.1",
95
96
  "fflate": "^0.8.2",
96
97
  "js-yaml": "^4.3.1",
@@ -100,8 +101,7 @@
100
101
  "zod": "^4.3.6"
101
102
  },
102
103
  "devDependencies": {
103
- "@cdktn/hcl2json": "^0.24.0",
104
- "ajv": "^8.20.0"
104
+ "@cdktn/hcl2json": "^0.24.0"
105
105
  },
106
106
  "overrides": {
107
107
  "esbuild": "^0.28.1"
package/src/audit/core.ts CHANGED
@@ -27,6 +27,7 @@ import { applyInlineSuppressions, type SuppressionMetaFinding } from "../lint/su
27
27
  import type { SerializerResult } from "../serializer";
28
28
  import type { AuditEntitiesInput, LexiconPlugin } from "../lexicon";
29
29
  import type { Declarable } from "../declarable";
30
+ import { lexiconModulePath, lexiconPackagesToInstall, lexiconSourceLabel } from "../lexicon-module";
30
31
 
31
32
  /**
32
33
  * The lexicon whose post-synth checks run against an audited file. Any lexicon
@@ -155,9 +156,19 @@ async function load(names: string[]): Promise<LexiconPlugin[]> {
155
156
  const { loadPlugins } = await import("../cli/plugins");
156
157
  return await loadPlugins(names);
157
158
  } catch (err) {
158
- const pkgs = names.map((n) => `@intentius/chant-lexicon-${n}`).join(" ");
159
+ const detail = err instanceof Error ? err.message : String(err);
160
+ // chant#2578 — a lexicon declared by path in chant.config.ts has no
161
+ // package: name its module and print no install line for it.
162
+ const pkgs = lexiconPackagesToInstall(names);
163
+ const paths = names.filter((n) => lexiconModulePath(n) !== undefined).map((n) => lexiconSourceLabel(n));
164
+ if (pkgs.length === 0) {
165
+ throw new MissingLexiconError(
166
+ `Could not load the lexicon module ${paths.join(", ")} needed to audit ${names.join("/")} workflows.\n(${detail})`,
167
+ );
168
+ }
169
+ const fromPath = paths.length > 0 ? ` The lexicon module ${paths.join(", ")} is declared by path and has nothing to install.` : "";
159
170
  throw new MissingLexiconError(
160
- `Missing lexicon package needed to audit ${names.join("/")} workflows. Install it with: npm i ${pkgs}\n(${err instanceof Error ? err.message : String(err)})`,
171
+ `Missing lexicon package needed to audit ${names.join("/")} workflows. Install it with: npm i ${pkgs.join(" ")}${fromPath}\n(${detail})`,
161
172
  );
162
173
  }
163
174
  }
@@ -40,7 +40,7 @@ import { parseYAML } from "../yaml";
40
40
  import type { LexiconPlugin } from "../lexicon";
41
41
  import type { AuditInput, AuditLexicon } from "./core";
42
42
  import { isNginxConfigPath } from "./nginx";
43
- import { gitignoreCoversTerraformState, isTerraformStatePath } from "./terraform-state";
43
+ import { gitignoreCoversTerraformState, isTerraformStatePath, nestedGitignoreCovering } from "./terraform-state";
44
44
 
45
45
  /** Lexicons the auditor knows how to detect and run checks for. */
46
46
  export const AUDIT_LEXICONS = ["github", "gitlab", "forgejo", "k8s", "docker", "aws", "azure", "gcp", "helm", "fountain", "terraform"] as const;
@@ -74,34 +74,61 @@ const WALK_SKIP = new Set(["node_modules", ".git", "dist"]);
74
74
  const TERRAFORM_WORK_DIR = ".terraform";
75
75
  /** Dot-directories the walk descends into anyway (CI lives here). */
76
76
  const WALK_DOT_DIRS = new Set([".github", ".forgejo"]);
77
- const MAX_WALK_FILES = 1000;
77
+ /**
78
+ * How many files the local walk takes before it stops. Every file it reaches
79
+ * counts, candidate or not, so a tree with a large `docs/` or `assets/` can hit
80
+ * it long before a thousand auditable files. `--max-files` raises it (#2528).
81
+ */
82
+ export const DEFAULT_MAX_WALK_FILES = 1000;
78
83
  /** Skip absurdly large files before parsing — mirrors the fetch layer's caps. */
79
84
  const MAX_FILE_BYTES = 2 * 1024 * 1024;
80
85
 
81
- /** Recursively collect file paths under a root, skipping noise dirs. */
82
- function walkFiles(dir: string, out: string[]): void {
83
- if (out.length >= MAX_WALK_FILES) return;
86
+ /** The walk's limit, and whether it left anything behind because of it. */
87
+ interface WalkState {
88
+ limit: number;
89
+ truncated: boolean;
90
+ }
91
+
92
+ /**
93
+ * Recursively collect file paths under a root, skipping noise dirs.
94
+ *
95
+ * Stops at `state.limit` paths. Once full it keeps looking only until it meets
96
+ * one more path it would have taken, which is what sets `truncated`: a tree of
97
+ * exactly `limit` files, or one whose remaining entries are all skipped
98
+ * directories, was not truncated. The paths it returns are the same first
99
+ * `limit` either way.
100
+ */
101
+ function walkFiles(dir: string, out: string[], state: WalkState): void {
102
+ if (state.truncated) return;
84
103
  let entries;
85
104
  try {
86
105
  entries = readdirSync(dir, { withFileTypes: true });
87
106
  } catch {
88
107
  return;
89
108
  }
109
+ const take = (path: string): boolean => {
110
+ if (out.length >= state.limit) {
111
+ state.truncated = true;
112
+ return false;
113
+ }
114
+ out.push(path);
115
+ return true;
116
+ };
90
117
  for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
91
- if (out.length >= MAX_WALK_FILES) return;
118
+ if (state.truncated) return;
92
119
  if (e.name.startsWith(".") && e.isDirectory() && !WALK_DOT_DIRS.has(e.name)) {
93
120
  // `.terraform/` is never descended into: it is machine-generated,
94
121
  // routinely hundreds of megabytes of provider binaries, and nothing in
95
122
  // it is worth reading. Its presence is itself the finding (TF023), so
96
123
  // the directory path alone is recorded and `collectCandidates` hands it
97
124
  // on with no content.
98
- if (e.name === TERRAFORM_WORK_DIR) out.push(join(dir, e.name));
125
+ if (e.name === TERRAFORM_WORK_DIR && !take(join(dir, e.name))) return;
99
126
  continue;
100
127
  }
101
128
  if (WALK_SKIP.has(e.name)) continue;
102
129
  const full = join(dir, e.name);
103
- if (e.isDirectory()) walkFiles(full, out);
104
- else out.push(full);
130
+ if (e.isDirectory()) walkFiles(full, out, state);
131
+ else if (!take(full)) return;
105
132
  }
106
133
  }
107
134
 
@@ -594,10 +621,49 @@ export function discoverByDetection(root: string, plugins: DetectPlugin[]): Audi
594
621
  return classifyFiles(collectCandidates(root), plugins, { baseDir: root });
595
622
  }
596
623
 
624
+ /** Options for the local walk. */
625
+ export interface CandidateWalkOptions {
626
+ /** Files the walk takes before it stops, counting every file. Defaults to {@link DEFAULT_MAX_WALK_FILES}. */
627
+ maxFiles?: number;
628
+ }
629
+
630
+ /**
631
+ * A TF023 path the root `.gitignore` does not cover but a `.gitignore` in a
632
+ * directory between it and the scan root does (#2528). TF023 reports it today;
633
+ * from the next release, which reads every such `.gitignore`, it will not.
634
+ */
635
+ export interface NestedGitignoreChange {
636
+ /** The TF023 path, relative to the scan root. */
637
+ path: string;
638
+ /** The nested `.gitignore` that ignores it, relative to the scan root. */
639
+ gitignore: string;
640
+ }
641
+
642
+ /** What the local walk read, plus what it knows about its own limits. */
643
+ export interface CandidateWalk {
644
+ files: RepoFile[];
645
+ /** The limit the walk ran with. */
646
+ maxFiles: number;
647
+ /** True when the walk stopped at `maxFiles` with files left unread. */
648
+ truncated: boolean;
649
+ /** TF023 paths whose finding changes once nested `.gitignore` files are read. */
650
+ nestedGitignore: NestedGitignoreChange[];
651
+ }
652
+
597
653
  /** The walk half of `discoverByDetection`: candidate files read into memory, paths relative to the root. */
598
654
  export function collectCandidates(root: string): RepoFile[] {
655
+ return walkCandidates(root).files;
656
+ }
657
+
658
+ /**
659
+ * `collectCandidates` with the facts the audit CLI warns about: whether the
660
+ * walk was truncated, and which TF023 paths a nested `.gitignore` would drop.
661
+ * Neither changes `files`; the CLI prints them on stderr (#2528).
662
+ */
663
+ export function walkCandidates(root: string, opts: CandidateWalkOptions = {}): CandidateWalk {
599
664
  const all: string[] = [];
600
- walkFiles(root, all);
665
+ const state: WalkState = { limit: opts.maxFiles ?? DEFAULT_MAX_WALK_FILES, truncated: false };
666
+ walkFiles(root, all, state);
601
667
  // TF023 reads paths, not content: a `.tfstate` can be tens of megabytes of
602
668
  // machine-generated JSON, and the finding is that it is in the repository at
603
669
  // all. The root `.gitignore` is what separates "committed" from "merely on
@@ -606,11 +672,23 @@ export function collectCandidates(root: string): RepoFile[] {
606
672
  // local audit. A fetched repository has no such ambiguity (its file list is
607
673
  // the tracked files) and is not filtered.
608
674
  const gitignore = readSafe(join(root, ".gitignore")) ?? "";
675
+ // Only the root one decides, for now. The nested ones are read to say which
676
+ // findings the next release, which honours them, will stop reporting.
677
+ const nestedBodies = new Map<string, string | undefined>();
678
+ const nestedGitignoreAt = (dir: string): string | undefined => {
679
+ if (!nestedBodies.has(dir)) nestedBodies.set(dir, readSafe(join(root, dir, ".gitignore")));
680
+ return nestedBodies.get(dir);
681
+ };
609
682
  const files: RepoFile[] = [];
683
+ const nestedGitignore: NestedGitignoreChange[] = [];
610
684
  for (const full of all) {
611
685
  const path = relative(root, full);
612
686
  if (isTerraformStatePath(path)) {
613
- if (!gitignoreCoversTerraformState(gitignore, path)) files.push({ path, content: "" });
687
+ if (!gitignoreCoversTerraformState(gitignore, path)) {
688
+ files.push({ path, content: "" });
689
+ const dir = nestedGitignoreCovering(path, nestedGitignoreAt);
690
+ if (dir !== undefined) nestedGitignore.push({ path, gitignore: `${dir}/.gitignore` });
691
+ }
614
692
  continue;
615
693
  }
616
694
  // `.tf` is read locally so `classifyTerraform` can bundle real content.
@@ -624,5 +702,5 @@ export function collectCandidates(root: string): RepoFile[] {
624
702
  const content = readSafe(full);
625
703
  if (content !== undefined) files.push({ path, content });
626
704
  }
627
- return files;
705
+ return { files, maxFiles: state.limit, truncated: state.truncated, nestedGitignore };
628
706
  }
@@ -8,9 +8,10 @@ import {
8
8
  isTerraformStateFile,
9
9
  isTerraformStatePath,
10
10
  isTerraformWorkDir,
11
+ nestedGitignoreCovering,
11
12
  type ScannableFile,
12
13
  } from "./terraform-state";
13
- import { collectCandidates } from "./discover";
14
+ import { collectCandidates, walkCandidates } from "./discover";
14
15
  import { RULE_CATALOG, RULE_CATEGORY } from "./catalog";
15
16
 
16
17
  const file = (path: string): ScannableFile => ({ path, content: "" });
@@ -142,3 +143,87 @@ describe("discovery hands TF023 its paths, and nothing else changes", () => {
142
143
  rmSync(dir, { recursive: true, force: true });
143
144
  });
144
145
  });
146
+
147
+ describe("nestedGitignoreCovering (#2528)", () => {
148
+ const reader = (bodies: Record<string, string>) => (dir: string) => bodies[dir];
149
+
150
+ test("finds a .gitignore between the path and the root, matched relative to its own directory", () => {
151
+ expect(nestedGitignoreCovering("infra/prod/terraform.tfstate", reader({ infra: "*.tfstate\n" }))).toBe("infra");
152
+ expect(nestedGitignoreCovering("infra/.terraform", reader({ infra: ".terraform/\n" }))).toBe("infra");
153
+ });
154
+
155
+ test("prefers the nearest one, as git does", () => {
156
+ const bodies = reader({ infra: "*.tfstate\n", "infra/prod": "*.tfstate\n" });
157
+ expect(nestedGitignoreCovering("infra/prod/terraform.tfstate", bodies)).toBe("infra/prod");
158
+ });
159
+
160
+ test("never reads the root's own .gitignore, and ignores one that does not cover the path", () => {
161
+ const bodies = reader({ "": "*.tfstate\n", infra: "*.log\n", other: "*.tfstate\n" });
162
+ expect(nestedGitignoreCovering("infra/terraform.tfstate", bodies)).toBeUndefined();
163
+ expect(nestedGitignoreCovering("terraform.tfstate", bodies)).toBeUndefined();
164
+ });
165
+ });
166
+
167
+ describe("walkCandidates reports what the next release changes, and changes nothing now (#2528)", () => {
168
+ function writeFiles(dir: string, n: number): void {
169
+ mkdirSync(join(dir, "docs"), { recursive: true });
170
+ for (let i = 0; i < n; i++) writeFileSync(join(dir, "docs", `${String(i).padStart(3, "0")}.md`), "x");
171
+ }
172
+
173
+ test("a walk that reaches its limit with files left says it was truncated, and keeps the same first files", () => {
174
+ const dir = tmpRepo();
175
+ writeFiles(dir, 5);
176
+ writeFileSync(join(dir, "terraform.tfstate"), "{}");
177
+ const cut = walkCandidates(dir, { maxFiles: 5 });
178
+ expect(cut.truncated).toBe(true);
179
+ expect(cut.maxFiles).toBe(5);
180
+ // `docs/` sorts first, so the state file is the one left behind.
181
+ expect(cut.files).toEqual([]);
182
+ const whole = walkCandidates(dir);
183
+ expect(whole.truncated).toBe(false);
184
+ expect(whole.maxFiles).toBe(1000);
185
+ expect(whole.files.map((f) => f.path)).toEqual(["terraform.tfstate"]);
186
+ rmSync(dir, { recursive: true, force: true });
187
+ });
188
+
189
+ test("a tree of exactly the limit, or one whose remainder is skipped, is not truncated", () => {
190
+ const dir = tmpRepo();
191
+ writeFiles(dir, 5);
192
+ mkdirSync(join(dir, "node_modules", "x"), { recursive: true });
193
+ writeFileSync(join(dir, "node_modules", "x", "index.js"), "");
194
+ mkdirSync(join(dir, "empty"), { recursive: true });
195
+ expect(walkCandidates(dir, { maxFiles: 5 }).truncated).toBe(false);
196
+ rmSync(dir, { recursive: true, force: true });
197
+ });
198
+
199
+ test("a state file a nested .gitignore ignores is still a candidate, and is named for the warning", () => {
200
+ const dir = tmpRepo();
201
+ mkdirSync(join(dir, "infra", ".terraform"), { recursive: true });
202
+ writeFileSync(join(dir, "infra", ".gitignore"), "*.tfstate\n.terraform/\n");
203
+ writeFileSync(join(dir, "infra", "terraform.tfstate"), "{}");
204
+ const walk = walkCandidates(dir);
205
+ expect(walk.files.map((f) => f.path).sort()).toEqual(["infra/.terraform", "infra/terraform.tfstate"]);
206
+ expect(auditTerraformState(walk.files)).toHaveLength(2);
207
+ expect(walk.nestedGitignore).toEqual([
208
+ { path: "infra/.terraform", gitignore: "infra/.gitignore" },
209
+ { path: "infra/terraform.tfstate", gitignore: "infra/.gitignore" },
210
+ ]);
211
+ rmSync(dir, { recursive: true, force: true });
212
+ });
213
+
214
+ test("nothing is named when the root .gitignore already drops the path, or no nested one covers it", () => {
215
+ const dir = tmpRepo();
216
+ mkdirSync(join(dir, "a"), { recursive: true });
217
+ mkdirSync(join(dir, "b"), { recursive: true });
218
+ writeFileSync(join(dir, ".gitignore"), "*.tfstate.backup\n");
219
+ writeFileSync(join(dir, "a", ".gitignore"), "*.tfstate.backup\n*.log\n");
220
+ writeFileSync(join(dir, "a", "terraform.tfstate"), "{}");
221
+ writeFileSync(join(dir, "a", "terraform.tfstate.backup"), "{}");
222
+ // A sibling's .gitignore is not between `a/` and the root.
223
+ writeFileSync(join(dir, "b", ".gitignore"), "*.tfstate\n");
224
+ const walk = walkCandidates(dir);
225
+ expect(walk.files.map((f) => f.path)).toEqual(["a/terraform.tfstate"]);
226
+ expect(walk.nestedGitignore).toEqual([]);
227
+ rmSync(dir, { recursive: true, force: true });
228
+ });
229
+ });
@@ -22,6 +22,8 @@
22
22
  * walk that decides which paths reach here is `discover.ts`'s
23
23
  * `collectCandidates`, which is also where a locally ignored path is dropped:
24
24
  * see `isTerraformStatePath` there and `gitignoreCoversTerraformState` below.
25
+ * Only the root `.gitignore` drops a path today; `nestedGitignoreCovering`
26
+ * finds the ones a nested `.gitignore` would drop from the next release.
25
27
  */
26
28
 
27
29
  import type { AuditFinding } from "./core";
@@ -83,6 +85,30 @@ export function gitignoreCoversTerraformState(gitignore: string, path: string):
83
85
  return false;
84
86
  }
85
87
 
88
+ /**
89
+ * The nearest directory strictly between a TF023 path and the scan root whose
90
+ * `.gitignore` covers that path, or undefined. Each `.gitignore` is matched
91
+ * against the path relative to its own directory, the way git scopes it, with
92
+ * the same narrow matcher as the root one. `readGitignore` takes a directory
93
+ * relative to the scan root and returns its `.gitignore` body, if any.
94
+ *
95
+ * The walk does not act on this yet: today only the root `.gitignore` decides
96
+ * TF023, and the audit CLI uses this to warn about the findings that go away
97
+ * once every `.gitignore` is read (#2528).
98
+ */
99
+ export function nestedGitignoreCovering(
100
+ path: string,
101
+ readGitignore: (dir: string) => string | undefined,
102
+ ): string | undefined {
103
+ const parts = path.split("/");
104
+ for (let depth = parts.length - 1; depth > 0; depth--) {
105
+ const dir = parts.slice(0, depth).join("/");
106
+ const body = readGitignore(dir);
107
+ if (body !== undefined && gitignoreCoversTerraformState(body, parts.slice(depth).join("/"))) return dir;
108
+ }
109
+ return undefined;
110
+ }
111
+
86
112
  /**
87
113
  * Report TF023 for every committed state file and `.terraform/` directory in
88
114
  * the file list. One finding per path, and one for the `.terraform` directory
@@ -81,11 +81,18 @@ export interface CommandGroup {
81
81
  * (./conflict-check.ts) treats a collision as a hard, loud failure at
82
82
  * plugin-load time rather than a silently-ignored command group. Hand
83
83
  * maintained alongside the registry; update both together.
84
+ *
85
+ * `workspace` is reserved before core has the command (#2529, #2524 D12), so
86
+ * no lexicon can claim the word first and later be shadowed by it. Only a
87
+ * command-group name is reserved: a lexicon's own config field or flag
88
+ * called `workspace` (the terraform lexicon's root config has one) is
89
+ * untouched.
84
90
  */
85
91
  export const RESERVED_COMMAND_NAMES: ReadonlySet<string> = new Set([
86
92
  "build", "lint", "list", "describe", "import", "audit", "migrate", "carve",
87
93
  "init", "update", "doctor", "dev", "run", "graph", "vendor", "lifecycle",
88
94
  "lc", "components", "emulator", "serve", "operator", "approve",
95
+ "workspace",
89
96
  ]);
90
97
 
91
98
  /**
@@ -0,0 +1,156 @@
1
+ import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { auditCommand, printAuditResult, type AuditCommandOptions } from "./audit";
6
+ import { loadAuditPlugins } from "../../audit/discover";
7
+
8
+ /**
9
+ * #2528's warning release. `chant audit` warns on stderr about the two changes
10
+ * the next release makes (#2525's exception list), and the report itself stays
11
+ * byte-identical: every test here that runs with a warning also runs the same
12
+ * tree without its cause and compares the JSON.
13
+ */
14
+
15
+ const NOW = "2026-09-23T00:00:00.000Z";
16
+ const dirs: string[] = [];
17
+
18
+ function tmpRepo(): string {
19
+ const dir = mkdtempSync(join(tmpdir(), "chant-2528-"));
20
+ dirs.push(dir);
21
+ return dir;
22
+ }
23
+
24
+ afterEach(() => {
25
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
26
+ vi.restoreAllMocks();
27
+ });
28
+
29
+ let plugins: Awaited<ReturnType<typeof loadAuditPlugins>>;
30
+
31
+ // Loading every audit lexicon is the slow part; once, outside any one test's timeout.
32
+ beforeAll(async () => {
33
+ plugins = await loadAuditPlugins();
34
+ }, 120_000);
35
+
36
+ function audit(path: string, extra: Partial<AuditCommandOptions> = {}) {
37
+ return auditCommand({ path, format: "json", now: NOW, toolVersion: "0.0.0", plugins, ...extra });
38
+ }
39
+
40
+ /** Print a result the way the CLI does, and split what reached each stream. */
41
+ function printed(result: Awaited<ReturnType<typeof auditCommand>>): { stdout: string; stderr: string } {
42
+ const out: string[] = [];
43
+ const err: string[] = [];
44
+ vi.spyOn(console, "log").mockImplementation((s: string) => void out.push(s));
45
+ vi.spyOn(console, "error").mockImplementation((s: string) => void err.push(s));
46
+ printAuditResult(result);
47
+ vi.restoreAllMocks();
48
+ return { stdout: out.join("\n"), stderr: err.join("\n") };
49
+ }
50
+
51
+ describe("a truncated walk is warned about, not yet reported (#2528)", () => {
52
+ function treeWithTail(): string {
53
+ const dir = tmpRepo();
54
+ mkdirSync(join(dir, "a"), { recursive: true });
55
+ for (let i = 0; i < 5; i++) writeFileSync(join(dir, "a", `${i}.md`), "x");
56
+ writeFileSync(join(dir, "a", "terraform.tfstate"), "{}");
57
+ // Past the limit: a state file TF023 would report if the walk reached it.
58
+ mkdirSync(join(dir, "z"), { recursive: true });
59
+ writeFileSync(join(dir, "z", "terraform.tfstate"), "{}");
60
+ return dir;
61
+ }
62
+
63
+ test("the warning names the limit and --max-files, on stderr only", async () => {
64
+ const dir = treeWithTail();
65
+ const result = await audit(dir, { maxFiles: 6 });
66
+ expect(result.warnings).toHaveLength(1);
67
+ const { stdout, stderr } = printed(result);
68
+ expect(stdout).toBe(result.output);
69
+ expect(stderr).toContain(`the scan of ${dir} stopped at 6 files, so part of the tree was not audited.`);
70
+ expect(stderr).toContain("Raise it with --max-files <n>.");
71
+ expect(stderr).toContain("From the next release, text and JSON output will also report a truncated scan.");
72
+ expect(result.findings.map((f) => f.file)).toEqual(["a/terraform.tfstate"]);
73
+ });
74
+
75
+ test("the JSON and text reports are what the same tree gives without the files past the limit", async () => {
76
+ const dir = treeWithTail();
77
+ const truncatedJson = await audit(dir, { maxFiles: 6 });
78
+ const truncatedText = await audit(dir, { maxFiles: 6, format: "stylish" });
79
+ rmSync(join(dir, "z"), { recursive: true, force: true });
80
+ const wholeJson = await audit(dir, { maxFiles: 6 });
81
+ const wholeText = await audit(dir, { maxFiles: 6, format: "stylish" });
82
+ expect(wholeJson.warnings).toBeUndefined();
83
+ expect(truncatedJson.output).toBe(wholeJson.output);
84
+ expect(truncatedText.output).toBe(wholeText.output);
85
+ expect(printed(truncatedJson).stdout).toBe(printed(wholeJson).stdout);
86
+ });
87
+
88
+ test("--max-files raises the limit, and the default walk does not warn on a small tree", async () => {
89
+ const dir = treeWithTail();
90
+ const raised = await audit(dir, { maxFiles: 7 });
91
+ expect(raised.warnings).toBeUndefined();
92
+ expect(raised.findings.map((f) => f.file).sort()).toEqual(["a/terraform.tfstate", "z/terraform.tfstate"]);
93
+ expect((await audit(dir)).warnings).toBeUndefined();
94
+ });
95
+ });
96
+
97
+ describe("TF023 warns when a nested .gitignore would change its finding (#2528)", () => {
98
+ function nestedTree(nested: string | undefined): string {
99
+ const dir = tmpRepo();
100
+ mkdirSync(join(dir, "infra"), { recursive: true });
101
+ writeFileSync(join(dir, ".gitignore"), "node_modules/\n");
102
+ writeFileSync(join(dir, "infra", "main.tf"), 'resource "null_resource" "a" {}\n');
103
+ writeFileSync(join(dir, "infra", "terraform.tfstate"), "{}");
104
+ if (nested !== undefined) writeFileSync(join(dir, "infra", ".gitignore"), nested);
105
+ return dir;
106
+ }
107
+
108
+ test("fires for the file a nested .gitignore ignores, naming both, and TF023 still reports it", async () => {
109
+ const dir = nestedTree("*.tfstate\n");
110
+ const result = await audit(dir);
111
+ expect(result.findings.filter((f) => f.checkId === "TF023").map((f) => f.file)).toEqual(["infra/terraform.tfstate"]);
112
+ expect(result.warnings).toEqual([
113
+ {
114
+ file: "infra/terraform.tfstate",
115
+ message: "TF023 reports this path because chant audit reads only the root .gitignore, but infra/.gitignore ignores it.",
116
+ hint: "From the next release chant audit reads every .gitignore between a file and the scan root, and TF023 will no longer report this path.",
117
+ },
118
+ ]);
119
+ const { stdout, stderr } = printed(result);
120
+ expect(stdout).toBe(result.output);
121
+ expect(stderr).toContain("infra/terraform.tfstate");
122
+ expect(stderr).toContain("infra/.gitignore ignores it");
123
+ });
124
+
125
+ test("does not fire when the nested .gitignore does not cover the file", async () => {
126
+ const result = await audit(nestedTree("*.log\n"));
127
+ expect(result.findings.some((f) => f.checkId === "TF023")).toBe(true);
128
+ expect(result.warnings).toBeUndefined();
129
+ });
130
+
131
+ test("does not fire when the root .gitignore already drops the file", async () => {
132
+ const dir = nestedTree("*.tfstate\n");
133
+ writeFileSync(join(dir, ".gitignore"), "*.tfstate\n");
134
+ const result = await audit(dir);
135
+ expect(result.findings.some((f) => f.checkId === "TF023")).toBe(false);
136
+ expect(result.warnings).toBeUndefined();
137
+ });
138
+
139
+ test("stdout JSON and the text report are byte-identical with and without the nested .gitignore", async () => {
140
+ const dir = nestedTree("*.tfstate\n");
141
+ const withJson = await audit(dir);
142
+ const withText = await audit(dir, { format: "stylish" });
143
+ const withSarif = await audit(dir, { format: "sarif" });
144
+ rmSync(join(dir, "infra", ".gitignore"));
145
+ const withoutJson = await audit(dir);
146
+ const withoutText = await audit(dir, { format: "stylish" });
147
+ const withoutSarif = await audit(dir, { format: "sarif" });
148
+ expect(withJson.warnings).toHaveLength(1);
149
+ expect(withoutJson.warnings).toBeUndefined();
150
+ expect(withJson.output).toBe(withoutJson.output);
151
+ expect(withText.output).toBe(withoutText.output);
152
+ expect(withSarif.output).toBe(withoutSarif.output);
153
+ expect(printed(withJson).stdout).toBe(printed(withoutJson).stdout);
154
+ JSON.parse(printed(withJson).stdout);
155
+ });
156
+ });