@intentius/chant 0.19.1 → 0.21.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 (164) hide show
  1. package/dist/build-params.d.ts +108 -0
  2. package/dist/build-params.d.ts.map +1 -0
  3. package/dist/build.d.ts +79 -1
  4. package/dist/build.d.ts.map +1 -1
  5. package/dist/cli/commands/build.d.ts +31 -0
  6. package/dist/cli/commands/build.d.ts.map +1 -1
  7. package/dist/cli/commands/check-lexicon-examples.d.ts +41 -0
  8. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -0
  9. package/dist/cli/commands/check-lexicon-intrinsics.d.ts +71 -0
  10. package/dist/cli/commands/check-lexicon-intrinsics.d.ts.map +1 -0
  11. package/dist/cli/commands/check-lexicon.d.ts +1 -1
  12. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  13. package/dist/cli/commands/lint.d.ts +27 -0
  14. package/dist/cli/commands/lint.d.ts.map +1 -1
  15. package/dist/cli/handlers/build.d.ts.map +1 -1
  16. package/dist/cli/handlers/components.d.ts.map +1 -1
  17. package/dist/cli/handlers/lint.d.ts.map +1 -1
  18. package/dist/cli/handlers/run-client.d.ts +1 -1
  19. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  20. package/dist/cli/handlers/run.d.ts.map +1 -1
  21. package/dist/cli/main.d.ts.map +1 -1
  22. package/dist/cli/plugins.d.ts +16 -0
  23. package/dist/cli/plugins.d.ts.map +1 -1
  24. package/dist/cli/registry.d.ts +10 -0
  25. package/dist/cli/registry.d.ts.map +1 -1
  26. package/dist/codegen/docs-sections.d.ts.map +1 -1
  27. package/dist/codegen/docs-types.d.ts +2 -0
  28. package/dist/codegen/docs-types.d.ts.map +1 -1
  29. package/dist/components/cli-support.d.ts +12 -5
  30. package/dist/components/cli-support.d.ts.map +1 -1
  31. package/dist/components/discover.d.ts +62 -7
  32. package/dist/components/discover.d.ts.map +1 -1
  33. package/dist/components/sandbox/driver.d.ts +12 -0
  34. package/dist/components/sandbox/driver.d.ts.map +1 -0
  35. package/dist/components/sandbox/run.d.ts +42 -0
  36. package/dist/components/sandbox/run.d.ts.map +1 -0
  37. package/dist/composite.d.ts +5 -0
  38. package/dist/composite.d.ts.map +1 -1
  39. package/dist/config.d.ts +71 -0
  40. package/dist/config.d.ts.map +1 -1
  41. package/dist/declarable.d.ts +16 -0
  42. package/dist/declarable.d.ts.map +1 -1
  43. package/dist/discovery/collect.d.ts.map +1 -1
  44. package/dist/discovery/entity-wire-codec.d.ts +166 -0
  45. package/dist/discovery/entity-wire-codec.d.ts.map +1 -0
  46. package/dist/discovery/entity-wire.d.ts +50 -0
  47. package/dist/discovery/entity-wire.d.ts.map +1 -0
  48. package/dist/discovery/fold-import.d.ts +239 -0
  49. package/dist/discovery/fold-import.d.ts.map +1 -0
  50. package/dist/discovery/index.d.ts +74 -1
  51. package/dist/discovery/index.d.ts.map +1 -1
  52. package/dist/discovery/sandbox/bundle.d.ts +18 -0
  53. package/dist/discovery/sandbox/bundle.d.ts.map +1 -0
  54. package/dist/discovery/sandbox/child-errors.d.ts +15 -0
  55. package/dist/discovery/sandbox/child-errors.d.ts.map +1 -0
  56. package/dist/discovery/sandbox/driver.d.ts +13 -0
  57. package/dist/discovery/sandbox/driver.d.ts.map +1 -0
  58. package/dist/discovery/sandbox/run.d.ts +69 -0
  59. package/dist/discovery/sandbox/run.d.ts.map +1 -0
  60. package/dist/errors.d.ts +9 -1
  61. package/dist/errors.d.ts.map +1 -1
  62. package/dist/fold/fold.d.ts +299 -0
  63. package/dist/fold/fold.d.ts.map +1 -0
  64. package/dist/fold/foldable-helpers.d.ts +121 -0
  65. package/dist/fold/foldable-helpers.d.ts.map +1 -0
  66. package/dist/fold/subset.d.ts +134 -0
  67. package/dist/fold/subset.d.ts.map +1 -0
  68. package/dist/index.d.ts +2 -0
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/lexicon-output.d.ts +7 -2
  71. package/dist/lexicon-output.d.ts.map +1 -1
  72. package/dist/lexicon-schema.d.ts +4 -2
  73. package/dist/lexicon-schema.d.ts.map +1 -1
  74. package/dist/lexicon.d.ts +131 -1
  75. package/dist/lexicon.d.ts.map +1 -1
  76. package/dist/lifecycle/release-ledger.d.ts +11 -0
  77. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  78. package/dist/lint/component-checks.d.ts +7 -1
  79. package/dist/lint/component-checks.d.ts.map +1 -1
  80. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  81. package/dist/lint/rules/evl003-dynamic-property-access.d.ts.map +1 -1
  82. package/dist/params.d.ts +60 -0
  83. package/dist/params.d.ts.map +1 -0
  84. package/dist/provenance.d.ts +21 -0
  85. package/dist/provenance.d.ts.map +1 -1
  86. package/dist/runtime.d.ts +10 -1
  87. package/dist/runtime.d.ts.map +1 -1
  88. package/package.json +2 -1
  89. package/src/build-params.test.ts +144 -0
  90. package/src/build-params.ts +207 -0
  91. package/src/build.test.ts +38 -0
  92. package/src/build.ts +144 -7
  93. package/src/cli/commands/build.test.ts +220 -2
  94. package/src/cli/commands/build.ts +111 -3
  95. package/src/cli/commands/check-lexicon-examples.test.ts +92 -0
  96. package/src/cli/commands/check-lexicon-examples.ts +103 -0
  97. package/src/cli/commands/check-lexicon-intrinsics.test.ts +179 -0
  98. package/src/cli/commands/check-lexicon-intrinsics.ts +348 -0
  99. package/src/cli/commands/check-lexicon.test.ts +34 -0
  100. package/src/cli/commands/check-lexicon.ts +137 -1
  101. package/src/cli/commands/lint.ts +31 -3
  102. package/src/cli/commands/onboard.ts +1 -1
  103. package/src/cli/component-security-boundary.test.ts +170 -0
  104. package/src/cli/handlers/build.ts +24 -3
  105. package/src/cli/handlers/components.ts +9 -2
  106. package/src/cli/handlers/dev.ts +1 -1
  107. package/src/cli/handlers/graph.ts +7 -5
  108. package/src/cli/handlers/lifecycle.ts +2 -2
  109. package/src/cli/handlers/lint.ts +2 -0
  110. package/src/cli/handlers/misc.ts +2 -2
  111. package/src/cli/handlers/run-client.ts +1 -1
  112. package/src/cli/handlers/run.ts +20 -5
  113. package/src/cli/main.test.ts +22 -0
  114. package/src/cli/main.ts +39 -0
  115. package/src/cli/plugins.ts +20 -4
  116. package/src/cli/registry.ts +10 -0
  117. package/src/cli/security-boundary.test.ts +135 -0
  118. package/src/codegen/docs-sections.test.ts +67 -0
  119. package/src/codegen/docs-sections.ts +7 -3
  120. package/src/codegen/docs-types.ts +2 -0
  121. package/src/components/cli-support.ts +22 -10
  122. package/src/components/discover.ts +127 -25
  123. package/src/components/sandbox/driver.ts +114 -0
  124. package/src/components/sandbox/run.test.ts +185 -0
  125. package/src/components/sandbox/run.ts +177 -0
  126. package/src/composite.test.ts +21 -0
  127. package/src/composite.ts +20 -1
  128. package/src/config.ts +81 -0
  129. package/src/declarable.ts +20 -0
  130. package/src/discovery/collect.ts +17 -3
  131. package/src/discovery/entity-wire-codec.ts +487 -0
  132. package/src/discovery/entity-wire.test.ts +240 -0
  133. package/src/discovery/entity-wire.ts +67 -0
  134. package/src/discovery/fold-import.test.ts +1598 -0
  135. package/src/discovery/fold-import.ts +1998 -0
  136. package/src/discovery/index.test.ts +191 -1
  137. package/src/discovery/index.ts +242 -1
  138. package/src/discovery/sandbox/bundle.ts +218 -0
  139. package/src/discovery/sandbox/child-errors.ts +65 -0
  140. package/src/discovery/sandbox/driver.ts +147 -0
  141. package/src/discovery/sandbox/run.test.ts +179 -0
  142. package/src/discovery/sandbox/run.ts +196 -0
  143. package/src/errors.ts +9 -1
  144. package/src/fold/fold.test.ts +812 -0
  145. package/src/fold/fold.ts +805 -0
  146. package/src/fold/foldable-helpers.ts +171 -0
  147. package/src/fold/subset-doc-parity.test.ts +210 -0
  148. package/src/fold/subset.test.ts +352 -0
  149. package/src/fold/subset.ts +383 -0
  150. package/src/index.ts +2 -0
  151. package/src/lexicon-output.ts +7 -2
  152. package/src/lexicon-schema.test.ts +57 -0
  153. package/src/lexicon-schema.ts +8 -1
  154. package/src/lexicon.ts +132 -1
  155. package/src/lifecycle/git.test.ts +10 -5
  156. package/src/lifecycle/release-ledger.test.ts +28 -0
  157. package/src/lifecycle/release-ledger.ts +11 -0
  158. package/src/lint/component-checks.ts +8 -1
  159. package/src/lint/rules/evl001-non-literal-expression.ts +26 -110
  160. package/src/lint/rules/evl003-dynamic-property-access.ts +11 -2
  161. package/src/params.test.ts +22 -0
  162. package/src/params.ts +66 -0
  163. package/src/provenance.ts +22 -0
  164. package/src/runtime.ts +11 -2
@@ -1,13 +1,14 @@
1
1
  import { build } from "../../build";
2
- import { loadChantConfig, resolveOwnershipMarker } from "../../config";
2
+ import { loadChantConfig, resolveOwnershipMarker, resolveFoldEnabled, resolveSandboxEnabled } from "../../config";
3
+ import { resolveBuildParams } from "../../build-params";
3
4
  import type { Serializer, SerializerResult } from "../../serializer";
4
5
  import type { LexiconPlugin } from "../../lexicon";
5
6
  import { runPostSynthChecks } from "../../lint/post-synth";
6
7
  import { loadPolicyChecks } from "../../lint/policy";
7
8
  import { sortedJsonReplacer } from "../../utils";
8
9
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
9
- import { writeFileSync, mkdirSync } from "fs";
10
- import { resolve, dirname, join } from "path";
10
+ import { writeFileSync, mkdirSync, readFileSync } from "fs";
11
+ import { resolve, dirname, join, relative } from "path";
11
12
  import { watchDirectory, formatTimestamp, formatChangedFiles } from "../watch";
12
13
 
13
14
  /**
@@ -32,6 +33,38 @@ export interface BuildOptions {
32
33
  * policy can branch on environment.
33
34
  */
34
35
  env?: string;
36
+ /**
37
+ * chant #1022 (epic #1019) — opt-in: fold source modules statically
38
+ * instead of importing/running them (`chant build --fold`). Falls back to
39
+ * run per-file for anything the folder can't represent. Merged with the
40
+ * project's `chant.config.ts` `build.fold` via {@link resolveFoldEnabled}
41
+ * — this flag, when true, always wins for the invocation.
42
+ */
43
+ fold?: boolean;
44
+
45
+ /**
46
+ * chant #1045 Phase 2 — opt-in: run-fallback source files (or, without
47
+ * `fold`, every file) execute together, isolated, in one sandboxed child
48
+ * process (`chant build --sandbox`). Merged with the project's
49
+ * `chant.config.ts` `build.sandbox` via {@link resolveSandboxEnabled} —
50
+ * this flag, when true, always wins for the invocation.
51
+ */
52
+ sandbox?: boolean;
53
+
54
+ /**
55
+ * chant #1064 — `--param name=value` flags (repeatable), parsed to a flat
56
+ * `{ name: value }` record of raw (unvalidated) strings. Highest
57
+ * precedence in {@link resolveBuildParams}'s resolution against the
58
+ * project's declared `chant.config.ts` `buildParams`.
59
+ */
60
+ params?: Record<string, string>;
61
+
62
+ /**
63
+ * chant #1064 — `--params-file <path>`: a JSON file of `{ "name": value }`
64
+ * build-time parameter values, read and parsed here. Second precedence,
65
+ * after {@link params}.
66
+ */
67
+ paramsFile?: string;
35
68
  }
36
69
 
37
70
  /**
@@ -81,6 +114,8 @@ export interface BuildResult {
81
114
  errors: string[];
82
115
  /** Warning messages */
83
116
  warnings: string[];
117
+ /** This build's resolved build-time parameters (#1064) — see `BuildResult.buildParams` (../../build.ts). Empty when the project declares/supplies none. */
118
+ buildParams?: import("../../provenance").BuildParamProvenance[];
84
119
  }
85
120
 
86
121
  /**
@@ -110,12 +145,84 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
110
145
  ? await loadPolicyChecks(config.lint.policies, configDir)
111
146
  : [];
112
147
 
148
+ // #1022 — opt-in fold path: the CLI flag wins over `chant.config.ts`'s
149
+ // `build.fold`, which wins over the (unchanged) default of running every
150
+ // module.
151
+ const fold = resolveFoldEnabled(config, options.fold);
152
+
153
+ // #1045 Phase 2 — opt-in sandboxed execution of run-fallback files (or,
154
+ // without --fold, every file). Same CLI-flag-wins-over-config precedence
155
+ // as fold, resolved independently.
156
+ const sandbox = resolveSandboxEnabled(config, options.sandbox);
157
+
158
+ // #1064 — resolve declared build-time parameters (chant.config.ts's
159
+ // buildParams) against this invocation's --param/--params-file/declared
160
+ // env mapping, BEFORE calling build() — a resolution failure (an unknown
161
+ // name, a missing required value, a type/enum mismatch) is reported as a
162
+ // chant build error naming the parameter, never a thrown error from inside
163
+ // user source (which is what loomster's hand-rolled `tierFromEnv()`-style
164
+ // validators did before migrating to this mechanism).
165
+ let paramsFileContent: Record<string, unknown> | undefined;
166
+ if (options.paramsFile) {
167
+ try {
168
+ paramsFileContent = JSON.parse(readFileSync(resolve(options.paramsFile), "utf-8"));
169
+ } catch (err) {
170
+ errors.push(
171
+ formatError({
172
+ message: `Failed to read/parse --params-file "${options.paramsFile}": ${err instanceof Error ? err.message : String(err)}`,
173
+ }),
174
+ );
175
+ }
176
+ }
177
+ const paramsResolution = resolveBuildParams(config.buildParams, {
178
+ cli: options.params,
179
+ fromFile: paramsFileContent,
180
+ env: process.env,
181
+ });
182
+ for (const message of paramsResolution.errors) {
183
+ errors.push(formatError({ message }));
184
+ }
185
+ if (errors.length > 0) {
186
+ return { success: false, resourceCount: 0, fileCount: 0, errors, warnings };
187
+ }
188
+ // #1064 — build-provenance visibility: report every resolved build-time
189
+ // parameter (name, value, and which source won it) the same
190
+ // unconditional-log-not-gated-on---verbose way #1022's fold decisions are
191
+ // reported just below, so a build's environment-varying inputs are as
192
+ // visible as its fold-vs-run choices.
193
+ for (const p of paramsResolution.provenance) {
194
+ console.error(formatInfo(`[param] ${p.name} = ${JSON.stringify(p.value)} (${p.source})`));
195
+ }
196
+
197
+ // #1039 — thread each loaded plugin's registered intrinsics (e.g. AWS's
198
+ // `Sub`) through to the fold path, so a file using a registered intrinsic
199
+ // tagged template folds instead of unconditionally falling back to run.
200
+ // `intrinsics` is an optional plugin extension (not every lexicon defines
201
+ // any), hence the guard.
202
+ const intrinsics = options.plugins?.flatMap((plugin) => plugin.intrinsics?.() ?? []) ?? [];
203
+
113
204
  // Run the build
114
205
  const result = await build(infraPath, options.serializers, undefined, {
115
206
  ownership,
116
207
  config: config as unknown as Record<string, unknown>,
208
+ fold,
209
+ sandbox,
210
+ intrinsics,
211
+ buildParams: paramsResolution.provenance,
117
212
  });
118
213
 
214
+ // #1022 — report per-file fold vs run so it's visible what still runs.
215
+ if (fold) {
216
+ for (const decision of result.foldDecisions) {
217
+ const rel = relative(infraPath, decision.file) || decision.file;
218
+ const detail =
219
+ decision.mode === "fold"
220
+ ? `${decision.resourceCount ?? 0} resource(s), no module execution`
221
+ : (decision.reason ?? "fell back to run");
222
+ console.error(formatInfo(`[fold:${decision.mode}] ${rel} — ${detail}`));
223
+ }
224
+ }
225
+
119
226
  // Format errors
120
227
  for (const error of result.errors) {
121
228
  const formatted = formatError({
@@ -359,6 +466,7 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
359
466
  fileCount,
360
467
  errors,
361
468
  warnings,
469
+ buildParams: paramsResolution.provenance,
362
470
  };
363
471
  }
364
472
 
@@ -0,0 +1,92 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { checkExamplesBuild } from "./check-lexicon-examples";
6
+
7
+ /**
8
+ * chant #1067 — proof that "every shipped example builds" actually builds
9
+ * one, rather than counting example directories the way every prior
10
+ * check-lexicon.ts check did. The fixture reproduces the exact defect
11
+ * found in `lexicons/aws/examples/core-concepts`: two files in the same
12
+ * example independently exporting a top-level binding of the same name,
13
+ * which is fine on its own but a "Duplicate export name" discovery error
14
+ * the moment the whole `src/` directory is built as one project — which is
15
+ * exactly what each example's own `npm run build` does.
16
+ *
17
+ * These fixtures import the real, already-installed `@intentius/chant-lexicon-aws`
18
+ * package (workspace-linked), so no lexicon plugin needs to be faked.
19
+ */
20
+
21
+ function writeLexiconDirWithExample(
22
+ lexiconDir: string,
23
+ exampleName: string,
24
+ files: Record<string, string>,
25
+ ): void {
26
+ const srcDir = join(lexiconDir, "examples", exampleName, "src");
27
+ mkdirSync(srcDir, { recursive: true });
28
+ for (const [name, content] of Object.entries(files)) {
29
+ writeFileSync(join(srcDir, name), content);
30
+ }
31
+ }
32
+
33
+ describe("checkExamplesBuild", () => {
34
+ let dir: string;
35
+
36
+ beforeAll(() => {
37
+ dir = mkdtempSync(join(tmpdir(), "chant-check-lexicon-examples-"));
38
+ });
39
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
40
+
41
+ test("a clean example builds", async () => {
42
+ writeLexiconDirWithExample(dir, "clean", {
43
+ "bucket.ts": `import { Bucket } from "@intentius/chant-lexicon-aws";
44
+
45
+ export const appBucket = new Bucket({
46
+ BucketName: "my-app-bucket",
47
+ });
48
+ `,
49
+ });
50
+
51
+ const results = await checkExamplesBuild(dir);
52
+ const clean = results.find((r) => r.example === "clean");
53
+ expect(clean).toMatchObject({ ok: true });
54
+ });
55
+
56
+ test("two files independently exporting the same top-level name fails to build (the core-concepts defect)", async () => {
57
+ writeLexiconDirWithExample(dir, "broken", {
58
+ "a.ts": `import { Bucket } from "@intentius/chant-lexicon-aws";
59
+
60
+ export const dataBucket = new Bucket({
61
+ BucketName: "a-bucket",
62
+ });
63
+ `,
64
+ "b.ts": `import { Bucket } from "@intentius/chant-lexicon-aws";
65
+
66
+ export const dataBucket = new Bucket({
67
+ BucketName: "b-bucket",
68
+ });
69
+ `,
70
+ });
71
+
72
+ const results = await checkExamplesBuild(dir);
73
+ const broken = results.find((r) => r.example === "broken");
74
+ expect(broken?.ok).toBe(false);
75
+ expect(broken?.detail).toMatch(/Duplicate export name "dataBucket" found/);
76
+ });
77
+
78
+ test("an empty src/ directory is skipped, not reported as a failure", async () => {
79
+ writeLexiconDirWithExample(dir, "empty", {});
80
+ const results = await checkExamplesBuild(dir);
81
+ expect(results.find((r) => r.example === "empty")).toBeUndefined();
82
+ });
83
+
84
+ test("returns [] when there is no examples/ directory at all", async () => {
85
+ const noExamplesDir = mkdtempSync(join(tmpdir(), "chant-check-lexicon-examples-none-"));
86
+ try {
87
+ expect(await checkExamplesBuild(noExamplesDir)).toEqual([]);
88
+ } finally {
89
+ rmSync(noExamplesDir, { recursive: true, force: true });
90
+ }
91
+ });
92
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * "Every shipped example builds" (chant #1067).
3
+ *
4
+ * `check-lexicon.ts`'s existing example checks only count directories
5
+ * ("At least 1 example", "At least 3 examples", "At least 5 examples with
6
+ * tests") — nothing ever tries to build one. `lexicons/aws/examples/core-concepts`
7
+ * shipped with a discovery-time error (two files independently exporting a
8
+ * top-level `dataBucket`) and `chant dev check-lexicon lexicons/aws` still
9
+ * reported "All tier-1 checks passed," because none of its 29 checks touch
10
+ * build output. This module closes that gap directly: for every non-empty
11
+ * `examples/<name>/src/` directory, run the same discover-and-serialize
12
+ * pipeline `chant build` runs and report whether it produced output with no
13
+ * structural error.
14
+ *
15
+ * Scope, deliberately: "builds" here means discovery + serialization
16
+ * succeed (no `DiscoveryError`/`BuildError`, real output produced) — the
17
+ * same thing a `Duplicate export name` failure blocks. It does not
18
+ * additionally require the output to pass every post-synth/lint check
19
+ * (WAW0xx and friends). Those are a separate, already-gated contract
20
+ * (`chant lint`, the post-synth pipeline) with their own severity model and
21
+ * their own CI step; several existing AWS examples (docs-snippets,
22
+ * lambda-api, lambda-s3, shared-alb) currently fail one or more post-synth
23
+ * checks for reasons unrelated to this issue (e.g. WAW042, a TLS-only
24
+ * bucket policy check added after those examples were written). Folding
25
+ * that axis into "does it build" would fail this new check for all of them
26
+ * on its very first run, for defects this issue never set out to fix.
27
+ * Tracked separately; not silently absorbed here.
28
+ */
29
+
30
+ import { existsSync, readdirSync } from "fs";
31
+ import { join } from "path";
32
+ import { build } from "../../build";
33
+ import { findInfraFiles } from "../../discovery/files";
34
+ import { detectLexicons } from "../../detectLexicon";
35
+ import { loadPlugins } from "../plugins";
36
+
37
+ export interface ExampleBuildResult {
38
+ example: string;
39
+ ok: boolean;
40
+ detail?: string;
41
+ }
42
+
43
+ /**
44
+ * Build every non-empty example under `<lexiconDir>/examples/*\/src` and
45
+ * report per-example pass/fail. Returns `[]` when the lexicon has no
46
+ * `examples/` directory at all (a separate tier-1 check already covers
47
+ * that).
48
+ */
49
+ export async function checkExamplesBuild(lexiconDir: string): Promise<ExampleBuildResult[]> {
50
+ const examplesDir = join(lexiconDir, "examples");
51
+ if (!existsSync(examplesDir)) return [];
52
+
53
+ const results: ExampleBuildResult[] = [];
54
+ const entries = readdirSync(examplesDir, { withFileTypes: true }).filter((e) => e.isDirectory());
55
+
56
+ for (const entry of entries) {
57
+ const srcDir = join(examplesDir, entry.name, "src");
58
+ if (!existsSync(srcDir)) continue;
59
+
60
+ const contents = readdirSync(srcDir);
61
+ if (contents.length === 0 || (contents.length === 1 && contents[0] === ".gitkeep")) continue;
62
+
63
+ try {
64
+ const files = await findInfraFiles(srcDir);
65
+ if (files.length === 0) continue;
66
+
67
+ const lexiconNames = await detectLexicons(files);
68
+ if (lexiconNames.length === 0) {
69
+ results.push({
70
+ example: entry.name,
71
+ ok: false,
72
+ detail: "no lexicon imports detected in src/ — cannot determine which plugin(s) to build with",
73
+ });
74
+ continue;
75
+ }
76
+
77
+ const plugins = await loadPlugins(lexiconNames);
78
+ const result = await build(srcDir, plugins.map((p) => p.serializer));
79
+
80
+ const structuralErrors = result.errors.map((e) => e.message);
81
+ const producedOutput = result.outputs.size > 0;
82
+ const ok = structuralErrors.length === 0 && producedOutput;
83
+
84
+ results.push({
85
+ example: entry.name,
86
+ ok,
87
+ detail: ok
88
+ ? undefined
89
+ : structuralErrors.length > 0
90
+ ? structuralErrors.join("; ")
91
+ : "discovered source but produced no output",
92
+ });
93
+ } catch (err) {
94
+ results.push({
95
+ example: entry.name,
96
+ ok: false,
97
+ detail: err instanceof Error ? err.message : String(err),
98
+ });
99
+ }
100
+ }
101
+
102
+ return results;
103
+ }
@@ -0,0 +1,179 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { auditIntrinsics } from "./check-lexicon-intrinsics";
6
+
7
+ /**
8
+ * chant #1067 — proof that the intrinsic-foldability audit actually
9
+ * validates, rather than just counting. Each case here is a small,
10
+ * synthetic lexicon (plugin.ts + index.ts + intrinsics.ts) so the
11
+ * fixtures encode the exact failure shapes #1039 shipped: a genuine
12
+ * tagged template registered with the wrong `isTag`, and a plain call
13
+ * registered with the wrong `isTag`, in both directions — plus a
14
+ * registration naming something the package never exports.
15
+ */
16
+
17
+ function writeFixture(dir: string): void {
18
+ mkdirSync(join(dir, "src"), { recursive: true });
19
+
20
+ writeFileSync(
21
+ join(dir, "src/plugin.ts"),
22
+ `export const testPlugin = {
23
+ name: "test",
24
+ intrinsics() {
25
+ return [
26
+ { name: "Tag1", description: "a genuine tagged template", isTag: true },
27
+ { name: "Plain1", description: "a genuine plain call", isTag: false },
28
+ { name: "Mismatch1", description: "claims tag but is a plain call", isTag: true },
29
+ { name: "Mismatch2", description: "claims plain but is a tagged template", isTag: false },
30
+ { name: "Missing1", description: "registered but never exported", isTag: false },
31
+ { name: "Plain2", description: "a plain call opted into call-form folding", isTag: false, foldsAsCall: true },
32
+ { name: "Tag2", description: "a tagged template wrongly opted into call-form folding", isTag: true, foldsAsCall: true },
33
+ ];
34
+ },
35
+ };
36
+ `,
37
+ );
38
+
39
+ writeFileSync(
40
+ join(dir, "src/index.ts"),
41
+ `export { Tag1, Plain1, Mismatch1, Mismatch2, Plain2, Tag2 } from "./intrinsics";
42
+ `,
43
+ );
44
+
45
+ writeFileSync(
46
+ join(dir, "src/intrinsics.ts"),
47
+ `export function Tag1(strings: TemplateStringsArray, ...values: unknown[]): string {
48
+ return strings.join("");
49
+ }
50
+
51
+ export function Plain1(x: string): string {
52
+ return x;
53
+ }
54
+
55
+ // Registered as isTag: true above — this is the #1039 "aws Sub" shape,
56
+ // inverted: a plain call wrongly claimed as a tag.
57
+ export function Mismatch1(x: string): string {
58
+ return x;
59
+ }
60
+
61
+ // Registered as isTag: false above — this is the #1039 "gitlab reference()"
62
+ // shape: a genuine tagged template wrongly claimed as a plain call.
63
+ export function Mismatch2(strings: TemplateStringsArray, ...values: unknown[]): string {
64
+ return strings.join("");
65
+ }
66
+
67
+ export function Plain2(x: string): string {
68
+ return x;
69
+ }
70
+
71
+ // chant #1044 — registered with foldsAsCall: true above, but authored as a
72
+ // tagged template, so there is no plain-call form to opt in.
73
+ export function Tag2(strings: TemplateStringsArray, ...values: unknown[]): string {
74
+ return strings.join("");
75
+ }
76
+ `,
77
+ );
78
+ }
79
+
80
+ describe("auditIntrinsics", () => {
81
+ let dir: string;
82
+
83
+ beforeAll(() => {
84
+ dir = mkdtempSync(join(tmpdir(), "chant-check-lexicon-intrinsics-"));
85
+ writeFixture(dir);
86
+ });
87
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
88
+
89
+ test("a genuine tagged template registered as isTag: true passes", () => {
90
+ const items = auditIntrinsics(dir);
91
+ const item = items.find((i) => i.name === "Tag1");
92
+ expect(item).toMatchObject({ exported: true, actualIsTag: true, ok: true });
93
+ });
94
+
95
+ test("a genuine plain call registered as isTag: false passes", () => {
96
+ const items = auditIntrinsics(dir);
97
+ const item = items.find((i) => i.name === "Plain1");
98
+ expect(item).toMatchObject({ exported: true, actualIsTag: false, ok: true });
99
+ });
100
+
101
+ test("a plain call wrongly registered as isTag: true fails (aws Sub shape, inverted)", () => {
102
+ const items = auditIntrinsics(dir);
103
+ const item = items.find((i) => i.name === "Mismatch1");
104
+ expect(item?.ok).toBe(false);
105
+ expect(item?.exported).toBe(true);
106
+ expect(item?.actualIsTag).toBe(false);
107
+ expect(item?.detail).toMatch(/plain call.*but registered with isTag: true/);
108
+ });
109
+
110
+ test("a tagged template wrongly registered as isTag: false fails (gitlab reference() shape)", () => {
111
+ const items = auditIntrinsics(dir);
112
+ const item = items.find((i) => i.name === "Mismatch2");
113
+ expect(item?.ok).toBe(false);
114
+ expect(item?.exported).toBe(true);
115
+ expect(item?.actualIsTag).toBe(true);
116
+ expect(item?.detail).toMatch(/authored as a tagged template.*isTag: false/);
117
+ });
118
+
119
+ test("a registered intrinsic that isn't exported fails, distinctly from a signature mismatch", () => {
120
+ const items = auditIntrinsics(dir);
121
+ const item = items.find((i) => i.name === "Missing1");
122
+ expect(item?.exported).toBe(false);
123
+ expect(item?.ok).toBe(false);
124
+ expect(item?.detail).toMatch(/not exported from src\/index\.ts/);
125
+ });
126
+
127
+ test("a plain call opted into call-form folding passes (chant #1044)", () => {
128
+ const items = auditIntrinsics(dir);
129
+ const item = items.find((i) => i.name === "Plain2");
130
+ expect(item).toMatchObject({ exported: true, actualIsTag: false, ok: true, declaredFoldsAsCall: true, callFormOk: true });
131
+ });
132
+
133
+ test("a tagged template registered with foldsAsCall: true fails — the opt-in is for plain calls only (chant #1044)", () => {
134
+ const items = auditIntrinsics(dir);
135
+ const item = items.find((i) => i.name === "Tag2");
136
+ // The isTag half is fine (it really is a tag); the call-form half is not.
137
+ expect(item?.ok).toBe(true);
138
+ expect(item?.callFormOk).toBe(false);
139
+ expect(item?.callFormDetail).toMatch(/authored as a tagged template.*foldsAsCall: true/);
140
+ });
141
+
142
+ test("an intrinsic with no foldsAsCall at all is simply not opted in — absent, not false (chant #1044)", () => {
143
+ const items = auditIntrinsics(dir);
144
+ const item = items.find((i) => i.name === "Plain1");
145
+ expect(item?.declaredFoldsAsCall).toBeUndefined();
146
+ expect(item?.callFormOk).toBe(true);
147
+ });
148
+
149
+ test("returns [] for a lexicon with no intrinsics() method", () => {
150
+ const emptyDir = mkdtempSync(join(tmpdir(), "chant-check-lexicon-intrinsics-empty-"));
151
+ try {
152
+ mkdirSync(join(emptyDir, "src"), { recursive: true });
153
+ writeFileSync(join(emptyDir, "src/plugin.ts"), `export const testPlugin = { name: "test" };\n`);
154
+ expect(auditIntrinsics(emptyDir)).toEqual([]);
155
+ } finally {
156
+ rmSync(emptyDir, { recursive: true, force: true });
157
+ }
158
+ });
159
+
160
+ test("returns [] for a lexicon whose intrinsics() legitimately registers none (gcp/k8s shape)", () => {
161
+ const emptyArrayDir = mkdtempSync(join(tmpdir(), "chant-check-lexicon-intrinsics-emptyarray-"));
162
+ try {
163
+ mkdirSync(join(emptyArrayDir, "src"), { recursive: true });
164
+ writeFileSync(
165
+ join(emptyArrayDir, "src/plugin.ts"),
166
+ `export const testPlugin = {
167
+ name: "test",
168
+ intrinsics() {
169
+ return [];
170
+ },
171
+ };
172
+ `,
173
+ );
174
+ expect(auditIntrinsics(emptyArrayDir)).toEqual([]);
175
+ } finally {
176
+ rmSync(emptyArrayDir, { recursive: true, force: true });
177
+ }
178
+ });
179
+ });