@intentius/chant 0.21.0 → 0.23.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 (57) hide show
  1. package/dist/build.d.ts +7 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/build-params-cli.d.ts +55 -0
  4. package/dist/cli/build-params-cli.d.ts.map +1 -0
  5. package/dist/cli/commands/build.d.ts.map +1 -1
  6. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
  7. package/dist/cli/commands/lint.d.ts.map +1 -1
  8. package/dist/cli/handlers/build.d.ts.map +1 -1
  9. package/dist/cli/handlers/run.d.ts +14 -1
  10. package/dist/cli/handlers/run.d.ts.map +1 -1
  11. package/dist/cli/lsp/server.d.ts.map +1 -1
  12. package/dist/components/cli-support.d.ts +33 -2
  13. package/dist/components/cli-support.d.ts.map +1 -1
  14. package/dist/components/discover.d.ts +28 -0
  15. package/dist/components/discover.d.ts.map +1 -1
  16. package/dist/discovery/fold-import.d.ts +71 -9
  17. package/dist/discovery/fold-import.d.ts.map +1 -1
  18. package/dist/discovery/index.d.ts +27 -4
  19. package/dist/discovery/index.d.ts.map +1 -1
  20. package/dist/fold/fold.d.ts.map +1 -1
  21. package/dist/fold/subset.d.ts +39 -2
  22. package/dist/fold/subset.d.ts.map +1 -1
  23. package/dist/lint/engine.d.ts +11 -1
  24. package/dist/lint/engine.d.ts.map +1 -1
  25. package/dist/lint/rule.d.ts +14 -0
  26. package/dist/lint/rule.d.ts.map +1 -1
  27. package/dist/serializer-walker.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/src/build.ts +9 -0
  30. package/src/cli/build-params-cli.test.ts +139 -0
  31. package/src/cli/build-params-cli.ts +107 -0
  32. package/src/cli/commands/build.ts +25 -36
  33. package/src/cli/commands/check-lexicon-examples.ts +16 -1
  34. package/src/cli/commands/lint.test.ts +74 -0
  35. package/src/cli/commands/lint.ts +33 -9
  36. package/src/cli/handlers/build.test.ts +147 -0
  37. package/src/cli/handlers/build.ts +23 -8
  38. package/src/cli/handlers/run.test.ts +160 -5
  39. package/src/cli/handlers/run.ts +46 -8
  40. package/src/cli/lsp/server.ts +7 -2
  41. package/src/components/cli-support.test.ts +221 -3
  42. package/src/components/cli-support.ts +37 -6
  43. package/src/components/discover.test.ts +63 -1
  44. package/src/components/discover.ts +42 -0
  45. package/src/discovery/fold-import.test.ts +328 -1
  46. package/src/discovery/fold-import.ts +414 -31
  47. package/src/discovery/index.test.ts +131 -0
  48. package/src/discovery/index.ts +53 -8
  49. package/src/discovery/sandbox/fold-boundary.test.ts +254 -0
  50. package/src/fold/fold.ts +6 -2
  51. package/src/fold/subset.test.ts +95 -15
  52. package/src/fold/subset.ts +46 -5
  53. package/src/lint/engine.ts +12 -0
  54. package/src/lint/rule.ts +14 -0
  55. package/src/lint/rules/evl001-non-literal-expression.test.ts +39 -0
  56. package/src/lint/rules/evl001-non-literal-expression.ts +1 -1
  57. package/src/serializer-walker.ts +14 -0
@@ -7,6 +7,7 @@ import { tryFoldFile, createFoldSession } from "./fold-import";
7
7
  import { isDeclarable } from "../declarable";
8
8
  import { isCompositeInstance } from "../composite";
9
9
  import { isAttrRefLike } from "../utils";
10
+ import { isIntrinsic } from "../intrinsic";
10
11
  import { params as sharedParams } from "../params";
11
12
  import type { AttrRef } from "../attrref";
12
13
  import type { IntrinsicDef } from "../lexicon";
@@ -165,7 +166,28 @@ describe("tryFoldFile", () => {
165
166
 
166
167
  expect(result.ok).toBe(false);
167
168
  if (result.ok) return;
168
- expect(result.reason).toContain("LocalStack");
169
+ // chant #1054 — locks in the ONE wording for "a call used as a value,
170
+ // not foldable" (../fold/subset.ts's `callExpressionMessage`), which
171
+ // `resolveCallExpression` now reuses instead of its own hand-written
172
+ // "call expression as a value" copy.
173
+ expect(result.reason).toBe('"stack" is not foldable: function call as a value is not foldable: LocalStack(...)');
174
+ });
175
+
176
+ test("falls back when a top-level export's callee isn't a plain identifier (chant #1054: same wording as any other call-as-a-value rejection)", async () => {
177
+ const file = join(testDir, "main.ts");
178
+ await writeFile(
179
+ file,
180
+ `
181
+ const builder = { build: (props: { name: string }) => ({ name: props.name }) };
182
+ export const stack = builder.build({ name: "x" });
183
+ `,
184
+ );
185
+
186
+ const result = await tryFoldFile(file);
187
+
188
+ expect(result.ok).toBe(false);
189
+ if (result.ok) return;
190
+ expect(result.reason).toBe('"stack" is not foldable: function call as a value is not foldable: builder.build(...)');
169
191
  });
170
192
 
171
193
  test("falls back when a prop value is not foldable", async () => {
@@ -286,6 +308,77 @@ describe("tryFoldFile", () => {
286
308
  DependsOn: "otherResource",
287
309
  });
288
310
  });
311
+
312
+ // chant #1054 — a destructured composite export's fallback reason used to
313
+ // embed `decl.node.getText()`: the ENTIRE call expression, which for a
314
+ // real multi-prop composite call is many lines, burying the real error
315
+ // after all of it. These lock in the fix: the reason names the binding(s)
316
+ // and the callee briefly instead, and stays on one line.
317
+ test("a destructured composite export whose source fails to resolve reports a brief, single-line reason naming the binding names and the callee", async () => {
318
+ await writeFile(
319
+ join(testDir, "composites.ts"),
320
+ `
321
+ import { createResource } from ${JSON.stringify(runtimePath)};
322
+ import { Composite } from ${JSON.stringify(compositePath)};
323
+ const Cluster = createResource("Test::Cluster", "gcp", {});
324
+ const NodePool = createResource("Test::NodePool", "gcp", {});
325
+ export const GkeCluster = Composite<{ name: string; location: string }>((props) => {
326
+ const cluster = new Cluster({ name: props.name });
327
+ const nodePool = new NodePool({ location: props.location });
328
+ return { cluster, nodePool };
329
+ }, "GkeCluster");
330
+ `,
331
+ );
332
+ const file = join(testDir, "cluster.ts");
333
+ await writeFile(
334
+ file,
335
+ `
336
+ import { GkeCluster } from "./composites";
337
+ export const { cluster, nodePool } = GkeCluster({
338
+ name: config.clusterName,
339
+ location: config.region,
340
+ });
341
+ `,
342
+ );
343
+
344
+ const result = await tryFoldFile(file);
345
+
346
+ expect(result.ok).toBe(false);
347
+ if (result.ok) return;
348
+ expect(result.reason).not.toContain("\n");
349
+ expect(result.reason).toContain('"cluster, nodePool" (destructured from GkeCluster(...))');
350
+ expect(result.reason).toContain("unresolved identifier: config");
351
+ // The whole multi-line source is gone — the reason never reproduces the
352
+ // call's own argument list verbatim.
353
+ expect(result.reason).not.toContain("clusterName");
354
+ });
355
+
356
+ test("a destructured export whose source resolves but isn't an object still reports a brief, single-line reason", async () => {
357
+ await writeFile(
358
+ join(testDir, "factory.ts"),
359
+ `
360
+ export function StringFactory(props: { text: string }): string {
361
+ return props.text;
362
+ }
363
+ `,
364
+ );
365
+ const file = join(testDir, "main.ts");
366
+ await writeFile(
367
+ file,
368
+ `
369
+ import { StringFactory } from "./factory";
370
+ export const { length } = StringFactory({ text: "hello" });
371
+ `,
372
+ );
373
+
374
+ const result = await tryFoldFile(file);
375
+
376
+ expect(result.ok).toBe(false);
377
+ if (result.ok) return;
378
+ expect(result.reason).toBe(
379
+ '"length" (destructured from StringFactory(...)) is not foldable: not a composite call or object',
380
+ );
381
+ });
289
382
  });
290
383
 
291
384
  // chant #1020 (epic #1019) — cross-file resolution: an imported binding now
@@ -1596,3 +1689,237 @@ describe("tryFoldFile — registered call-form intrinsics (#1044)", () => {
1596
1689
  expect(result.reason).toContain("same-file resource reference");
1597
1690
  });
1598
1691
  });
1692
+
1693
+ /**
1694
+ * chant #1063 — cross-file references into an ACTIVE LEXICON PACKAGE's
1695
+ * exports.
1696
+ *
1697
+ * #1020 taught fold to follow an identifier into another module, but only
1698
+ * through a relative/absolute specifier — a bare package specifier was
1699
+ * skipped outright, so `Azure`, `GCP`, `S3Actions` and gitlab's `CI` all
1700
+ * failed as "unresolved identifier" even though every one of them is a plain
1701
+ * `as const` object the folder already handles. These tests install a real
1702
+ * package into the fixture tree's own `node_modules` and drive the identical
1703
+ * resolution a real `@intentius/chant-lexicon-azure` import takes: a genuine
1704
+ * bare specifier, resolved by {@link fastResolveBareSpecifier} walking up to
1705
+ * `<testDir>/node_modules`, then really imported.
1706
+ *
1707
+ * Every fixture package gets a UNIQUE name. `bareSpecifierPathCache` is
1708
+ * process-wide and keyed by specifier alone (deliberately — see its doc), so
1709
+ * two tests reusing one name would have the second silently resolve to the
1710
+ * first's already-deleted directory.
1711
+ */
1712
+ describe("tryFoldFile — active lexicon package exports (#1063)", () => {
1713
+ let testDir: string;
1714
+ let seq = 0;
1715
+
1716
+ beforeEach(async () => {
1717
+ testDir = join(tmpdir(), `chant-fold-import-lexpkg-test-${Date.now()}-${Math.random()}`);
1718
+ await mkdir(testDir, { recursive: true });
1719
+ });
1720
+
1721
+ afterEach(async () => {
1722
+ await rm(testDir, { recursive: true, force: true });
1723
+ });
1724
+
1725
+ /**
1726
+ * Install a package into `<testDir>/node_modules/<name>` and return its
1727
+ * bare specifier. `lexiconName` is what a build would list in
1728
+ * `chant.config.ts`'s `lexicons`; the package name follows chant's one
1729
+ * naming convention (`@intentius/chant-lexicon-<name>`), same as
1730
+ * `loadPlugin()` uses.
1731
+ */
1732
+ async function installLexiconPackage(source: string): Promise<{ lexicon: string; specifier: string }> {
1733
+ const lexicon = `fold1063x${seq++}${Date.now().toString(36)}`;
1734
+ const specifier = `@intentius/chant-lexicon-${lexicon}`;
1735
+ const dir = join(testDir, "node_modules", specifier);
1736
+ await mkdir(dir, { recursive: true });
1737
+ await writeFile(
1738
+ join(dir, "package.json"),
1739
+ JSON.stringify({ name: specifier, version: "0.0.0", type: "module", exports: { ".": "./index.js" } }),
1740
+ );
1741
+ await writeFile(join(dir, "index.js"), source);
1742
+ return { lexicon, specifier };
1743
+ }
1744
+
1745
+ /** A pseudo-parameter namespace shaped exactly like `lexicons/azure/src/pseudo.ts`'s `Azure`, plus a plain-data constants object and a callable export. */
1746
+ const LEXICON_SOURCE = `
1747
+ const INTRINSIC_MARKER = Symbol.for("chant.intrinsic");
1748
+ class ArmPseudoParameter {
1749
+ constructor(expression) {
1750
+ this[INTRINSIC_MARKER] = true;
1751
+ this.expression = expression;
1752
+ }
1753
+ toJSON() { return this.expression; }
1754
+ }
1755
+ export const ResourceGroupLocation = new ArmPseudoParameter("[resourceGroup().location]");
1756
+ export const Azure = { ResourceGroupLocation };
1757
+ export const CI = { CommitBranch: "$CI_COMMIT_BRANCH", DefaultBranch: "$CI_DEFAULT_BRANCH" };
1758
+ export const S3Actions = { ReadOnly: ["s3:GetObject", "s3:ListBucket"] };
1759
+ export function defaultTags(tags) { return tags; }
1760
+ `;
1761
+
1762
+ test("a lexicon package's plain-data export resolves and folds when referenced as a value", async () => {
1763
+ const { lexicon, specifier } = await installLexiconPackage(LEXICON_SOURCE);
1764
+ const file = join(testDir, "main.ts");
1765
+ await writeFile(
1766
+ file,
1767
+ `
1768
+ import { CI, S3Actions } from ${JSON.stringify(specifier)};
1769
+ throw new Error("must never execute — sentinel for #1063 fold verification");
1770
+ export const branch = CI.CommitBranch;
1771
+ export const rule = \`\${CI.CommitBranch} == \${CI.DefaultBranch}\`;
1772
+ export const actions = S3Actions.ReadOnly;
1773
+ `,
1774
+ );
1775
+
1776
+ const result = await tryFoldFile(file, [], createFoldSession([], undefined, [lexicon]));
1777
+
1778
+ expect(result.ok).toBe(true);
1779
+ if (!result.ok) return;
1780
+ expect(result.exportedValues.get("branch")).toBe("$CI_COMMIT_BRANCH");
1781
+ // A template literal is the real test that the VALUE arrived, not a
1782
+ // symbolic envelope: an envelope would stringify to "[object Object]".
1783
+ expect(result.exportedValues.get("rule")).toBe("$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH");
1784
+ expect(result.exportedValues.get("actions")).toEqual(["s3:GetObject", "s3:ListBucket"]);
1785
+ });
1786
+
1787
+ test("a pseudo-parameter reached through the namespace stays the REAL live Intrinsic instance, prototype intact", async () => {
1788
+ const { lexicon, specifier } = await installLexiconPackage(LEXICON_SOURCE);
1789
+ await writeFile(
1790
+ join(testDir, "resources.ts"),
1791
+ `
1792
+ import { createResource } from ${JSON.stringify(runtimePath)};
1793
+ export const Group = createResource("Test::Group", "azure", {});
1794
+ `,
1795
+ );
1796
+ const file = join(testDir, "main.ts");
1797
+ await writeFile(
1798
+ file,
1799
+ `
1800
+ import { Azure } from ${JSON.stringify(specifier)};
1801
+ import { Group } from "./resources";
1802
+ export const group = new Group({ location: Azure.ResourceGroupLocation });
1803
+ `,
1804
+ );
1805
+
1806
+ const result = await tryFoldFile(file, [], createFoldSession([], undefined, [lexicon]));
1807
+
1808
+ expect(result.ok).toBe(true);
1809
+ if (!result.ok) return;
1810
+ const [, entity] = result.entities[0];
1811
+ const location = (entity as unknown as { props: { location: unknown } }).props.location;
1812
+ // Not a plain-object copy: the generic revive walk would have rebuilt it
1813
+ // as `{ expression: … }` and dropped `toJSON`, which is what actually
1814
+ // reaches the serializer.
1815
+ expect(isIntrinsic(location)).toBe(true);
1816
+ expect((location as { toJSON(): unknown }).toJSON()).toBe("[resourceGroup().location]");
1817
+ // Identity: the very object the module exports, not a reconstruction —
1818
+ // the same one a run-path import of this package would hand the file.
1819
+ const mod = (await import(join(testDir, "node_modules", specifier, "index.js"))) as {
1820
+ Azure: { ResourceGroupLocation: unknown };
1821
+ };
1822
+ expect(location).toBe(mod.Azure.ResourceGroupLocation);
1823
+ });
1824
+
1825
+ test("a lexicon package NOT active for this build is not resolved", async () => {
1826
+ const { specifier } = await installLexiconPackage(LEXICON_SOURCE);
1827
+ const file = join(testDir, "main.ts");
1828
+ await writeFile(
1829
+ file,
1830
+ `
1831
+ import { CI } from ${JSON.stringify(specifier)};
1832
+ export const branch = CI.CommitBranch;
1833
+ `,
1834
+ );
1835
+
1836
+ // The package is installed and importable — the ONLY thing missing is
1837
+ // this build having loaded it as a lexicon.
1838
+ const result = await tryFoldFile(file, [], createFoldSession([], undefined, ["aws"]));
1839
+
1840
+ expect(result.ok).toBe(false);
1841
+ if (result.ok) return;
1842
+ expect(result.reason).toContain("unresolved identifier: CI");
1843
+ });
1844
+
1845
+ test("with no active lexicons at all, a bare specifier is skipped exactly as before", async () => {
1846
+ const { specifier } = await installLexiconPackage(LEXICON_SOURCE);
1847
+ const file = join(testDir, "main.ts");
1848
+ await writeFile(
1849
+ file,
1850
+ `
1851
+ import { CI } from ${JSON.stringify(specifier)};
1852
+ export const branch = CI.CommitBranch;
1853
+ `,
1854
+ );
1855
+
1856
+ const result = await tryFoldFile(file);
1857
+
1858
+ expect(result.ok).toBe(false);
1859
+ if (result.ok) return;
1860
+ expect(result.reason).toContain("unresolved identifier: CI");
1861
+ });
1862
+
1863
+ test("a non-lexicon package is never followed, even when it is installed right next to one that is", async () => {
1864
+ const { lexicon } = await installLexiconPackage(LEXICON_SOURCE);
1865
+ const vendorDir = join(testDir, "node_modules", "some-vendor-pkg");
1866
+ await mkdir(vendorDir, { recursive: true });
1867
+ await writeFile(
1868
+ join(vendorDir, "package.json"),
1869
+ JSON.stringify({ name: "some-vendor-pkg", version: "0.0.0", type: "module", exports: { ".": "./index.js" } }),
1870
+ );
1871
+ await writeFile(join(vendorDir, "index.js"), `export const SETTINGS = { region: "us-east-1" };`);
1872
+
1873
+ const file = join(testDir, "main.ts");
1874
+ await writeFile(
1875
+ file,
1876
+ `
1877
+ import { SETTINGS } from "some-vendor-pkg";
1878
+ export const region = SETTINGS.region;
1879
+ `,
1880
+ );
1881
+
1882
+ const result = await tryFoldFile(file, [], createFoldSession([], undefined, [lexicon]));
1883
+
1884
+ expect(result.ok).toBe(false);
1885
+ if (result.ok) return;
1886
+ expect(result.reason).toContain("unresolved identifier: SETTINGS");
1887
+ });
1888
+
1889
+ test("a lexicon package's CALLABLE export is not bound as an identifier value", async () => {
1890
+ const { lexicon, specifier } = await installLexiconPackage(LEXICON_SOURCE);
1891
+ const file = join(testDir, "main.ts");
1892
+ await writeFile(
1893
+ file,
1894
+ `
1895
+ import { defaultTags } from ${JSON.stringify(specifier)};
1896
+ export const tags = defaultTags;
1897
+ `,
1898
+ );
1899
+
1900
+ const result = await tryFoldFile(file, [], createFoldSession([], undefined, [lexicon]));
1901
+
1902
+ expect(result.ok).toBe(false);
1903
+ if (result.ok) return;
1904
+ expect(result.reason).toContain("unresolved identifier: defaultTags");
1905
+ });
1906
+
1907
+ test("process.env still rejects, with the build-parameter guidance, even with an active lexicon package in scope", async () => {
1908
+ const { lexicon, specifier } = await installLexiconPackage(LEXICON_SOURCE);
1909
+ const file = join(testDir, "main.ts");
1910
+ await writeFile(
1911
+ file,
1912
+ `
1913
+ import { CI } from ${JSON.stringify(specifier)};
1914
+ export const branch = CI.CommitBranch;
1915
+ export const region = process.env.AWS_REGION;
1916
+ `,
1917
+ );
1918
+
1919
+ const result = await tryFoldFile(file, [], createFoldSession([], undefined, [lexicon]));
1920
+
1921
+ expect(result.ok).toBe(false);
1922
+ if (result.ok) return;
1923
+ expect(result.reason).toContain(`ambient "process" read is not foldable`);
1924
+ });
1925
+ });