@intentius/chant 0.28.0 → 0.30.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 (106) hide show
  1. package/dist/cli/handlers/components.d.ts.map +1 -1
  2. package/dist/cli/handlers/graph.d.ts.map +1 -1
  3. package/dist/cli/handlers/lifecycle.d.ts +5 -3
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/main.d.ts.map +1 -1
  6. package/dist/cli/registry.d.ts +14 -0
  7. package/dist/cli/registry.d.ts.map +1 -1
  8. package/dist/config.d.ts +46 -4
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/deep-observation.d.ts +257 -0
  11. package/dist/deep-observation.d.ts.map +1 -0
  12. package/dist/discovery/fold-import.d.ts +153 -17
  13. package/dist/discovery/fold-import.d.ts.map +1 -1
  14. package/dist/discovery/sandbox/config-wire.d.ts +3 -2
  15. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  16. package/dist/env.d.ts +5 -2
  17. package/dist/env.d.ts.map +1 -1
  18. package/dist/fold/fold.d.ts +35 -3
  19. package/dist/fold/fold.d.ts.map +1 -1
  20. package/dist/fold/subset.d.ts +9 -0
  21. package/dist/fold/subset.d.ts.map +1 -1
  22. package/dist/graph-ir.d.ts +73 -4
  23. package/dist/graph-ir.d.ts.map +1 -1
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/kubectl-context.d.ts +27 -0
  27. package/dist/kubectl-context.d.ts.map +1 -1
  28. package/dist/lexicon.d.ts +78 -6
  29. package/dist/lexicon.d.ts.map +1 -1
  30. package/dist/lifecycle/change-set.d.ts +26 -5
  31. package/dist/lifecycle/change-set.d.ts.map +1 -1
  32. package/dist/lifecycle/deep-diff.d.ts +103 -0
  33. package/dist/lifecycle/deep-diff.d.ts.map +1 -0
  34. package/dist/lifecycle/deep-observe.d.ts +62 -0
  35. package/dist/lifecycle/deep-observe.d.ts.map +1 -0
  36. package/dist/lifecycle/index.d.ts +3 -0
  37. package/dist/lifecycle/index.d.ts.map +1 -1
  38. package/dist/lifecycle/live-diff.d.ts +25 -1
  39. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  40. package/dist/lifecycle/observation-baseline.d.ts +118 -0
  41. package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
  42. package/dist/lifecycle/observe.d.ts +4 -2
  43. package/dist/lifecycle/observe.d.ts.map +1 -1
  44. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  45. package/dist/lifecycle/status.d.ts +26 -1
  46. package/dist/lifecycle/status.d.ts.map +1 -1
  47. package/dist/lifecycle/types.d.ts +8 -0
  48. package/dist/lifecycle/types.d.ts.map +1 -1
  49. package/dist/live-endpoint.d.ts +92 -0
  50. package/dist/live-endpoint.d.ts.map +1 -0
  51. package/dist/observation.d.ts +123 -0
  52. package/dist/observation.d.ts.map +1 -0
  53. package/dist/stack-output.d.ts.map +1 -1
  54. package/package.json +1 -1
  55. package/src/cli/handlers/components.test.ts +63 -4
  56. package/src/cli/handlers/components.ts +78 -35
  57. package/src/cli/handlers/graph.test.ts +155 -6
  58. package/src/cli/handlers/graph.ts +124 -29
  59. package/src/cli/handlers/lifecycle.test.ts +410 -6
  60. package/src/cli/handlers/lifecycle.ts +475 -182
  61. package/src/cli/main.test.ts +6 -0
  62. package/src/cli/main.ts +12 -0
  63. package/src/cli/registry.ts +14 -0
  64. package/src/config.test.ts +75 -0
  65. package/src/config.ts +61 -3
  66. package/src/deep-observation.test.ts +234 -0
  67. package/src/deep-observation.ts +489 -0
  68. package/src/discovery/fold-composite.test.ts +594 -0
  69. package/src/discovery/fold-import.test.ts +372 -1
  70. package/src/discovery/fold-import.ts +1216 -116
  71. package/src/discovery/sandbox/config-wire.ts +3 -2
  72. package/src/env.test.ts +12 -0
  73. package/src/env.ts +12 -4
  74. package/src/fold/fold.test.ts +105 -0
  75. package/src/fold/fold.ts +100 -20
  76. package/src/fold/subset.test.ts +38 -7
  77. package/src/fold/subset.ts +9 -0
  78. package/src/graph-ir-live.test.ts +28 -1
  79. package/src/graph-ir.ts +115 -12
  80. package/src/index.ts +2 -0
  81. package/src/kubectl-context.ts +81 -0
  82. package/src/lexicon.ts +100 -6
  83. package/src/lifecycle/change-set.test.ts +93 -1
  84. package/src/lifecycle/change-set.ts +65 -13
  85. package/src/lifecycle/deep-diff.test.ts +157 -0
  86. package/src/lifecycle/deep-diff.ts +213 -0
  87. package/src/lifecycle/deep-observe.test.ts +174 -0
  88. package/src/lifecycle/deep-observe.ts +173 -0
  89. package/src/lifecycle/index.ts +3 -0
  90. package/src/lifecycle/live-diff.test.ts +39 -0
  91. package/src/lifecycle/live-diff.ts +51 -5
  92. package/src/lifecycle/observation-baseline.test.ts +99 -0
  93. package/src/lifecycle/observation-baseline.ts +217 -0
  94. package/src/lifecycle/observe.test.ts +74 -3
  95. package/src/lifecycle/observe.ts +82 -22
  96. package/src/lifecycle/snapshot.test.ts +39 -1
  97. package/src/lifecycle/snapshot.ts +40 -20
  98. package/src/lifecycle/status.test.ts +89 -8
  99. package/src/lifecycle/status.ts +53 -3
  100. package/src/lifecycle/types.ts +8 -0
  101. package/src/live-endpoint.test.ts +115 -0
  102. package/src/live-endpoint.ts +148 -0
  103. package/src/observation.test.ts +96 -0
  104. package/src/observation.ts +213 -0
  105. package/src/stack-output.test.ts +55 -0
  106. package/src/stack-output.ts +41 -20
@@ -3,7 +3,7 @@ import { mkdir, writeFile, rm } from "node:fs/promises";
3
3
  import { join, dirname, resolve } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { fileURLToPath } from "node:url";
6
- import { tryFoldFile, createFoldSession } from "./fold-import";
6
+ import { tryFoldFile, createFoldSession, planFoldTaint } from "./fold-import";
7
7
  import { isDeclarable } from "../declarable";
8
8
  import { isCompositeInstance } from "../composite";
9
9
  import { isAttrRefLike } from "../utils";
@@ -1923,3 +1923,374 @@ describe("tryFoldFile — active lexicon package exports (#1063)", () => {
1923
1923
  expect(result.reason).toContain(`ambient "process" read is not foldable`);
1924
1924
  });
1925
1925
  });
1926
+
1927
+ /**
1928
+ * chant #1169 — a `new Type(...)` used as a VALUE.
1929
+ *
1930
+ * The largest measured gate on the example corpus (64 files, the sole blocker
1931
+ * in 22 entries) and the one the original #1022-era rejection was most
1932
+ * deliberate about: `fold()` alone can only produce a `{__resource, props}`
1933
+ * envelope, and an envelope that reaches a serializer is not the value the run
1934
+ * path produced — `image: new Image({ name })` has to serialize as the
1935
+ * constructed Image's own shape, not as the envelope.
1936
+ *
1937
+ * The answer is not to normalize the envelope; it is to construct. These tests
1938
+ * are about proving that what gets constructed is INDISTINGUISHABLE from what
1939
+ * running the file would have built:
1940
+ *
1941
+ * - a real instance of the class the file's own `import` names, with its
1942
+ * prototype and `kind`/`entityType` intact (not a plain-object copy);
1943
+ * - built ONCE per source construction, so a resource named by a `const` and
1944
+ * referenced three times is one object — the same object discovery
1945
+ * registers, which is the only reason a reference to it can resolve to a
1946
+ * logical name at all;
1947
+ * - behind the same #1093 gate as every other construction, so `--sandbox`
1948
+ * still executes nothing project-owned in this process.
1949
+ */
1950
+ describe("tryFoldFile — constructions as values (chant #1169)", () => {
1951
+ let testDir: string;
1952
+
1953
+ beforeEach(async () => {
1954
+ testDir = join(tmpdir(), `chant-fold-import-1169-test-${Date.now()}-${Math.random()}`);
1955
+ await mkdir(testDir, { recursive: true });
1956
+ });
1957
+
1958
+ afterEach(async () => {
1959
+ await rm(testDir, { recursive: true, force: true });
1960
+ });
1961
+
1962
+ /** A resource class and two property-kind classes, from chant's real runtime factories — the exact shapes every lexicon's generated barrel produces. */
1963
+ async function writeDefs(): Promise<void> {
1964
+ await writeFile(
1965
+ join(testDir, "resources.ts"),
1966
+ `
1967
+ import { createResource, createProperty } from ${JSON.stringify(runtimePath)};
1968
+ export const Job = createResource("Test::Job", "gitlab", { Id: "Id" });
1969
+ export const Image = createProperty("Test::Image", "gitlab");
1970
+ export const Rule = createProperty("Test::Rule", "gitlab");
1971
+ `,
1972
+ );
1973
+ }
1974
+
1975
+ /** The `props` of a folded entity, without the `as unknown as` dance at every call site. */
1976
+ function propsOf(entity: unknown): Record<string, unknown> {
1977
+ return (entity as { props: Record<string, unknown> }).props;
1978
+ }
1979
+
1980
+ test("an INLINE nested construction becomes a real instance, not a `{__resource}` envelope", async () => {
1981
+ await writeDefs();
1982
+ const file = join(testDir, "main.ts");
1983
+ await writeFile(
1984
+ file,
1985
+ `
1986
+ import { Job, Image } from "./resources";
1987
+ throw new Error("must never execute — sentinel for #1169 fold verification");
1988
+ export const build = new Job({ stage: "build", image: new Image({ name: "node:22-alpine" }) });
1989
+ `,
1990
+ );
1991
+
1992
+ const result = await tryFoldFile(file);
1993
+
1994
+ expect(result.ok).toBe(true);
1995
+ if (!result.ok) return;
1996
+ const [, entity] = result.entities[0];
1997
+ const image = propsOf(entity).image;
1998
+ // The whole point: a live Declarable with its prototype, `kind` and
1999
+ // `entityType`, which is what the serializer's walker dispatches on. An
2000
+ // envelope would be a plain object with a `__resource` key and would
2001
+ // serialize as `{ __resource, props }`.
2002
+ expect(isDeclarable(image)).toBe(true);
2003
+ expect((image as unknown as { entityType: string }).entityType).toBe("Test::Image");
2004
+ expect((image as unknown as { kind: string }).kind).toBe("property");
2005
+ expect(propsOf(image)).toEqual({ name: "node:22-alpine" });
2006
+ expect(image).not.toHaveProperty("__resource");
2007
+ });
2008
+
2009
+ test("constructions nest in every value position — an array element, a deeper object, a second level", async () => {
2010
+ await writeDefs();
2011
+ const file = join(testDir, "main.ts");
2012
+ await writeFile(
2013
+ file,
2014
+ `
2015
+ import { Job, Image, Rule } from "./resources";
2016
+ export const deploy = new Job({
2017
+ image: new Image({ name: "alpine" }),
2018
+ rules: [new Rule({ if: "$CI_COMMIT_BRANCH" }), new Rule({ when: "manual" })],
2019
+ nested: { inner: new Rule({ if: "always" }) },
2020
+ });
2021
+ `,
2022
+ );
2023
+
2024
+ const result = await tryFoldFile(file);
2025
+
2026
+ expect(result.ok).toBe(true);
2027
+ if (!result.ok) return;
2028
+ const props = propsOf(result.entities[0][1]);
2029
+ const rules = props.rules as unknown[];
2030
+ expect(rules).toHaveLength(2);
2031
+ expect(rules.every((r) => isDeclarable(r))).toBe(true);
2032
+ expect(propsOf(rules[0])).toEqual({ if: "$CI_COMMIT_BRANCH" });
2033
+ expect(isDeclarable((props.nested as { inner: unknown }).inner)).toBe(true);
2034
+ });
2035
+
2036
+ test("the constructor's second argument (CFN-style attributes) folds a nested construction too", async () => {
2037
+ await writeDefs();
2038
+ const file = join(testDir, "main.ts");
2039
+ await writeFile(
2040
+ file,
2041
+ `
2042
+ import { Job, Rule } from "./resources";
2043
+ export const job = new Job({ stage: "test" }, { Metadata: new Rule({ note: "attr" }) });
2044
+ `,
2045
+ );
2046
+
2047
+ const result = await tryFoldFile(file);
2048
+
2049
+ expect(result.ok).toBe(true);
2050
+ if (!result.ok) return;
2051
+ const attributes = (result.entities[0][1] as unknown as { attributes: Record<string, unknown> }).attributes;
2052
+ expect(isDeclarable(attributes.Metadata)).toBe(true);
2053
+ expect(propsOf(attributes.Metadata)).toEqual({ note: "attr" });
2054
+ });
2055
+
2056
+ test("a NAMED same-file resource is built ONCE — every reference is the same object", async () => {
2057
+ await writeDefs();
2058
+ const file = join(testDir, "main.ts");
2059
+ await writeFile(
2060
+ file,
2061
+ `
2062
+ import { Job, Image } from "./resources";
2063
+ const nodeImage = new Image({ name: "node:22-alpine" });
2064
+ export const build = new Job({ stage: "build", image: nodeImage });
2065
+ export const check = new Job({ stage: "test", image: nodeImage });
2066
+ `,
2067
+ );
2068
+
2069
+ const result = await tryFoldFile(file);
2070
+
2071
+ expect(result.ok).toBe(true);
2072
+ if (!result.ok) return;
2073
+ const buildImage = propsOf(result.exportedValues.get("build")).image;
2074
+ const checkImage = propsOf(result.exportedValues.get("check")).image;
2075
+ expect(isDeclarable(buildImage)).toBe(true);
2076
+ // Identity, not equality. Running the module evaluates `const nodeImage`
2077
+ // once and both jobs close over that one object; two instances here would
2078
+ // be a different program.
2079
+ expect(buildImage).toBe(checkImage);
2080
+ });
2081
+
2082
+ test("a named resource referenced by another resource IS the entity discovery registers — no duplicate", async () => {
2083
+ await writeDefs();
2084
+ const file = join(testDir, "main.ts");
2085
+ await writeFile(
2086
+ file,
2087
+ `
2088
+ import { Job } from "./resources";
2089
+ export const first = new Job({ stage: "first" });
2090
+ export const second = new Job({ stage: "second", needs: [first] });
2091
+ `,
2092
+ );
2093
+
2094
+ const result = await tryFoldFile(file);
2095
+
2096
+ expect(result.ok).toBe(true);
2097
+ if (!result.ok) return;
2098
+ const first = result.exportedValues.get("first");
2099
+ const needs = propsOf(result.exportedValues.get("second")).needs as unknown[];
2100
+ // THE assertion this whole design turns on. `entities` is what discovery
2101
+ // assigns logical names to; if the reference held a second instance, the
2102
+ // serializer would find it absent from that table and inline its props
2103
+ // where the run path emits a reference — or throw "logical name not set".
2104
+ expect(needs[0]).toBe(first);
2105
+ expect(result.entities.map(([name]) => name)).toEqual(["first", "second"]);
2106
+ });
2107
+
2108
+ test("`export { x }` of a same-file resource exports that same instance", async () => {
2109
+ await writeDefs();
2110
+ const file = join(testDir, "main.ts");
2111
+ await writeFile(
2112
+ file,
2113
+ `
2114
+ import { Job, Image } from "./resources";
2115
+ const shared = new Image({ name: "alpine" });
2116
+ const job = new Job({ image: shared });
2117
+ export { job };
2118
+ `,
2119
+ );
2120
+
2121
+ const result = await tryFoldFile(file);
2122
+
2123
+ expect(result.ok).toBe(true);
2124
+ if (!result.ok) return;
2125
+ expect(result.entities.map(([name]) => name)).toEqual(["job"]);
2126
+ expect(isDeclarable(propsOf(result.exportedValues.get("job")).image)).toBe(true);
2127
+ });
2128
+
2129
+ test("an attribute reference to a same-file resource still folds to the `{__attrRef}` envelope, unchanged", async () => {
2130
+ await writeDefs();
2131
+ const file = join(testDir, "main.ts");
2132
+ await writeFile(
2133
+ file,
2134
+ `
2135
+ import { Job } from "./resources";
2136
+ export const first = new Job({ stage: "first" });
2137
+ export const second = new Job({ upstream: first.Id });
2138
+ `,
2139
+ );
2140
+
2141
+ const result = await tryFoldFile(file);
2142
+
2143
+ expect(result.ok).toBe(true);
2144
+ if (!result.ok) return;
2145
+ // #1169 deliberately leaves this path alone: a sibling ATTRIBUTE reference
2146
+ // is resolved by the serializer's own walker from the envelope's entity
2147
+ // NAME, and nothing about that had to change for a construction to fold.
2148
+ expect(propsOf(result.exportedValues.get("second")).upstream).toEqual({
2149
+ __attrRef: { entity: "first", attribute: "Id" },
2150
+ });
2151
+ });
2152
+
2153
+ test("a nested construction whose class isn't a resolvable import falls the file back to run", async () => {
2154
+ await writeDefs();
2155
+ const file = join(testDir, "main.ts");
2156
+ await writeFile(
2157
+ file,
2158
+ `
2159
+ import { Job } from "./resources";
2160
+ export const job = new Job({ image: new Unimported({ name: "x" }) });
2161
+ `,
2162
+ );
2163
+
2164
+ const result = await tryFoldFile(file);
2165
+
2166
+ expect(result.ok).toBe(false);
2167
+ if (result.ok) return;
2168
+ expect(result.reason).toContain(`constructor "Unimported" is not a resolvable import`);
2169
+ });
2170
+
2171
+ test("a nested `new ns.Type(...)` through a namespace import stays rejected", async () => {
2172
+ await writeDefs();
2173
+ const file = join(testDir, "main.ts");
2174
+ await writeFile(
2175
+ file,
2176
+ `
2177
+ import { Job } from "./resources";
2178
+ import * as res from "./resources";
2179
+ export const job = new Job({ image: new res.Image({ name: "x" }) });
2180
+ `,
2181
+ );
2182
+
2183
+ const result = await tryFoldFile(file);
2184
+
2185
+ expect(result.ok).toBe(false);
2186
+ if (result.ok) return;
2187
+ // Shape-level: a dotted callee cannot be resolved to a live class through
2188
+ // the file's named imports, so it never folds to an envelope nothing could
2189
+ // revive. Same bare-identifier rule as an intrinsic call and #1023's
2190
+ // factory-body construction.
2191
+ expect(result.reason).toContain("needs a plain imported constructor");
2192
+ });
2193
+
2194
+ test("a same-file resource used as a value rejects when no construction is available", async () => {
2195
+ // The divergence #1169 adds, in the one direction that is safe. When the
2196
+ // class can't be resolved there is no instance to point at, and re-folding
2197
+ // the initializer would build a duplicate — so the reference rejects and
2198
+ // the file falls back to run, where both references are the same object by
2199
+ // construction.
2200
+ const file = join(testDir, "main.ts");
2201
+ await writeFile(
2202
+ file,
2203
+ `
2204
+ import { Job } from "./missing-module";
2205
+ const shared = new Job({ stage: "a" });
2206
+ export const job = new Job({ needs: [shared] });
2207
+ `,
2208
+ );
2209
+
2210
+ const result = await tryFoldFile(file);
2211
+
2212
+ expect(result.ok).toBe(false);
2213
+ if (result.ok) return;
2214
+ expect(result.reason).toContain("same-file resource `shared` used as a value is not foldable");
2215
+ });
2216
+
2217
+ test("a construction whose class is a PROJECT file folds under plain --fold and REFUSES under --sandbox", async () => {
2218
+ // chant #1093 — a nested construction reaches its class through exactly the
2219
+ // same binding gate every other construction does, so it cannot widen the
2220
+ // boundary. `./resources.ts` is project source: trusted enough to import
2221
+ // under plain `--fold` (the run path imports it too), never under
2222
+ // `--sandbox`, where the file demotes to the child instead.
2223
+ await writeDefs();
2224
+ const file = join(testDir, "main.ts");
2225
+ await writeFile(
2226
+ file,
2227
+ `
2228
+ import { Job, Image } from "./resources";
2229
+ export const job = new Job({ image: new Image({ name: "alpine" }) });
2230
+ `,
2231
+ );
2232
+
2233
+ const plain = await tryFoldFile(file, [], createFoldSession([], undefined, [], false));
2234
+ expect(plain.ok).toBe(true);
2235
+
2236
+ const sandboxed = await tryFoldFile(file, [], createFoldSession([], undefined, [], true));
2237
+ expect(sandboxed.ok).toBe(false);
2238
+ if (sandboxed.ok) return;
2239
+ expect(sandboxed.reason).toContain("--sandbox");
2240
+ expect(sandboxed.reason).toContain(`constructor "Image"`);
2241
+ });
2242
+
2243
+ test("a nested instance reaches another file only through an exported object, and that edge is already recorded", async () => {
2244
+ // chant #1097's question, answered for #1169. A nested instance is an
2245
+ // inline value: it is never an export of its own, so no other file can name
2246
+ // it. The one way another file CAN observe it is by importing the object
2247
+ // that holds it — and that is an ordinary cross-file object edge, already
2248
+ // recorded in `liveSources` and already reverse-taint-propagated by
2249
+ // `planFoldTaint` (#1044). Nothing new to track.
2250
+ await writeDefs();
2251
+ await writeFile(
2252
+ join(testDir, "shared.ts"),
2253
+ `
2254
+ import { Image } from "./resources";
2255
+ export const cfg = { image: new Image({ name: "alpine" }) };
2256
+ `,
2257
+ );
2258
+ const file = join(testDir, "main.ts");
2259
+ await writeFile(
2260
+ file,
2261
+ `
2262
+ import { Job } from "./resources";
2263
+ import { cfg } from "./shared";
2264
+ export const job = new Job({ image: cfg.image });
2265
+ `,
2266
+ );
2267
+
2268
+ const session = createFoldSession();
2269
+ const sharedPath = join(testDir, "shared.ts");
2270
+ const sharedResult = await tryFoldFile(sharedPath, [], session);
2271
+ const result = await tryFoldFile(file, [], session);
2272
+
2273
+ expect(result.ok).toBe(true);
2274
+ expect(sharedResult.ok).toBe(true);
2275
+ if (!result.ok || !sharedResult.ok) return;
2276
+
2277
+ const sharedImage = (sharedResult.exportedValues.get("cfg") as { image: unknown }).image;
2278
+ // Identity across the file boundary — one instance, shared, exactly as
2279
+ // Node's module cache would give the run path.
2280
+ expect(propsOf(result.exportedValues.get("job")).image).toBe(sharedImage);
2281
+ // And the edge that makes it safe: main.ts is on record as having consumed
2282
+ // shared.ts's objects, so if shared.ts is ever forced back to run, main.ts
2283
+ // is forced with it rather than left holding a stale instance.
2284
+ expect([...result.liveSources]).toContain(sharedPath);
2285
+
2286
+ const tainted = await planFoldTaint(
2287
+ [file, sharedPath],
2288
+ new Map([
2289
+ [file, false],
2290
+ [sharedPath, true],
2291
+ ]),
2292
+ new Map([[file, result.liveSources]]),
2293
+ );
2294
+ expect(tainted.has(sharedPath)).toBe(true);
2295
+ });
2296
+ });