@intentius/chant 0.44.14 → 0.46.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 (220) hide show
  1. package/dist/audit/discover.d.ts +26 -2
  2. package/dist/audit/discover.d.ts.map +1 -1
  3. package/dist/audit/report-model.d.ts +10 -0
  4. package/dist/audit/report-model.d.ts.map +1 -1
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/cli/build-params-cli.d.ts +16 -7
  7. package/dist/cli/build-params-cli.d.ts.map +1 -1
  8. package/dist/cli/commands/audit.d.ts +20 -0
  9. package/dist/cli/commands/audit.d.ts.map +1 -1
  10. package/dist/cli/commands/build.d.ts +9 -0
  11. package/dist/cli/commands/build.d.ts.map +1 -1
  12. package/dist/cli/commands/check-lexicon-docs.d.ts +13 -0
  13. package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -1
  14. package/dist/cli/commands/check-lexicon-examples.d.ts +24 -14
  15. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
  16. package/dist/cli/commands/check-lexicon.d.ts +14 -0
  17. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  18. package/dist/cli/commands/init-lexicon/templates/codegen.d.ts.map +1 -1
  19. package/dist/cli/commands/init-lexicon/templates/docs.d.ts +5 -0
  20. package/dist/cli/commands/init-lexicon/templates/docs.d.ts.map +1 -1
  21. package/dist/cli/commands/init-lexicon.d.ts.map +1 -1
  22. package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
  23. package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
  24. package/dist/cli/commands/lint.d.ts.map +1 -1
  25. package/dist/cli/commands/onboard.d.ts +28 -0
  26. package/dist/cli/commands/onboard.d.ts.map +1 -1
  27. package/dist/cli/handlers/build.d.ts.map +1 -1
  28. package/dist/cli/handlers/graph.d.ts.map +1 -1
  29. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  30. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  31. package/dist/cli/handlers/run.d.ts.map +1 -1
  32. package/dist/cli/handlers/search.d.ts +27 -0
  33. package/dist/cli/handlers/search.d.ts.map +1 -1
  34. package/dist/cli/main.d.ts.map +1 -1
  35. package/dist/cli/registry.d.ts +7 -0
  36. package/dist/cli/registry.d.ts.map +1 -1
  37. package/dist/codegen/docs-pages.d.ts +39 -0
  38. package/dist/codegen/docs-pages.d.ts.map +1 -0
  39. package/dist/codegen/docs-sections.d.ts.map +1 -1
  40. package/dist/codegen/docs-sidebar.d.ts +13 -2
  41. package/dist/codegen/docs-sidebar.d.ts.map +1 -1
  42. package/dist/codegen/docs-types.d.ts +30 -10
  43. package/dist/codegen/docs-types.d.ts.map +1 -1
  44. package/dist/codegen/docs.d.ts +14 -2
  45. package/dist/codegen/docs.d.ts.map +1 -1
  46. package/dist/codegen/lexicon-regen.d.ts +11 -0
  47. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  48. package/dist/codegen/surface-snapshot.d.ts +27 -3
  49. package/dist/codegen/surface-snapshot.d.ts.map +1 -1
  50. package/dist/codegen/validate.d.ts +21 -3
  51. package/dist/codegen/validate.d.ts.map +1 -1
  52. package/dist/components/capability-plugin.d.ts +13 -2
  53. package/dist/components/capability-plugin.d.ts.map +1 -1
  54. package/dist/components/driver.d.ts +38 -0
  55. package/dist/components/driver.d.ts.map +1 -1
  56. package/dist/components/index.d.ts +1 -1
  57. package/dist/components/index.d.ts.map +1 -1
  58. package/dist/components/starter-plugin.d.ts.map +1 -1
  59. package/dist/config.d.ts +85 -5
  60. package/dist/config.d.ts.map +1 -1
  61. package/dist/discovery/fold-import.d.ts.map +1 -1
  62. package/dist/env.d.ts +12 -1
  63. package/dist/env.d.ts.map +1 -1
  64. package/dist/fold/fold.d.ts +100 -1
  65. package/dist/fold/fold.d.ts.map +1 -1
  66. package/dist/graph-ir.d.ts +12 -0
  67. package/dist/graph-ir.d.ts.map +1 -1
  68. package/dist/graph-ops.d.ts +17 -0
  69. package/dist/graph-ops.d.ts.map +1 -0
  70. package/dist/lexicon.d.ts +182 -2
  71. package/dist/lexicon.d.ts.map +1 -1
  72. package/dist/lifecycle/change-set.d.ts +23 -2
  73. package/dist/lifecycle/change-set.d.ts.map +1 -1
  74. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  75. package/dist/lifecycle/index.d.ts +1 -0
  76. package/dist/lifecycle/index.d.ts.map +1 -1
  77. package/dist/lifecycle/observe.d.ts +8 -0
  78. package/dist/lifecycle/observe.d.ts.map +1 -1
  79. package/dist/lifecycle/replay.d.ts.map +1 -1
  80. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  81. package/dist/lifecycle/teardown.d.ts +130 -0
  82. package/dist/lifecycle/teardown.d.ts.map +1 -0
  83. package/dist/lifecycle/types.d.ts +7 -0
  84. package/dist/lifecycle/types.d.ts.map +1 -1
  85. package/dist/lint/engine.d.ts +6 -2
  86. package/dist/lint/engine.d.ts.map +1 -1
  87. package/dist/lint/policy.d.ts.map +1 -1
  88. package/dist/lint/rule.d.ts +31 -0
  89. package/dist/lint/rule.d.ts.map +1 -1
  90. package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
  91. package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
  92. package/dist/lint/rules/index.d.ts +2 -1
  93. package/dist/lint/rules/index.d.ts.map +1 -1
  94. package/dist/managed-fields.d.ts +33 -21
  95. package/dist/managed-fields.d.ts.map +1 -1
  96. package/dist/observation.d.ts +23 -1
  97. package/dist/observation.d.ts.map +1 -1
  98. package/dist/op/builders.d.ts +36 -7
  99. package/dist/op/builders.d.ts.map +1 -1
  100. package/dist/op/index.d.ts +1 -1
  101. package/dist/op/index.d.ts.map +1 -1
  102. package/dist/testing.d.ts +136 -0
  103. package/dist/testing.d.ts.map +1 -0
  104. package/dist/yaml.d.ts +0 -8
  105. package/dist/yaml.d.ts.map +1 -1
  106. package/package.json +6 -1
  107. package/src/__snapshots__/okf.test.ts.snap +20 -0
  108. package/src/audit/discover.test.ts +55 -1
  109. package/src/audit/discover.ts +70 -2
  110. package/src/audit/edge-init-safety.test.ts +149 -18
  111. package/src/audit/report-model.ts +12 -1
  112. package/src/audit/rules-doc.ts +1 -0
  113. package/src/build-params.test.ts +52 -0
  114. package/src/cli/build-params-cli.test.ts +25 -8
  115. package/src/cli/build-params-cli.ts +37 -23
  116. package/src/cli/commands/__fixtures__/audit-coverage/.github/workflows/ci.yml +5 -0
  117. package/src/cli/commands/__fixtures__/audit-coverage/Dockerfile +2 -0
  118. package/src/cli/commands/__fixtures__/audit-coverage/infra/main.tf +3 -0
  119. package/src/cli/commands/__fixtures__/audit-coverage/infra/stack.json +6 -0
  120. package/src/cli/commands/__fixtures__/audit-coverage/k8s/deploy.yaml +12 -0
  121. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/pages/getting-started.mdx +16 -0
  122. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content.config.ts +10 -2
  123. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate-cli.ts +3 -1
  124. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate.ts +2 -1
  125. package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +2 -1
  126. package/src/cli/commands/audit.test.ts +83 -1
  127. package/src/cli/commands/audit.ts +121 -28
  128. package/src/cli/commands/build.test.ts +275 -5
  129. package/src/cli/commands/build.ts +86 -14
  130. package/src/cli/commands/check-lexicon-docs.ts +30 -0
  131. package/src/cli/commands/check-lexicon-examples.test.ts +73 -7
  132. package/src/cli/commands/check-lexicon-examples.ts +66 -17
  133. package/src/cli/commands/check-lexicon.test.ts +46 -2
  134. package/src/cli/commands/check-lexicon.ts +64 -4
  135. package/src/cli/commands/init-lexicon/templates/codegen.ts +5 -2
  136. package/src/cli/commands/init-lexicon/templates/docs.ts +35 -2
  137. package/src/cli/commands/init-lexicon.test.ts +42 -1
  138. package/src/cli/commands/init-lexicon.ts +3 -1
  139. package/src/cli/commands/lexicon-rolling-upgrade.ts +1 -1
  140. package/src/cli/commands/lexicon-surface-diff.test.ts +1 -1
  141. package/src/cli/commands/lexicon-surface-diff.ts +10 -1
  142. package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
  143. package/src/cli/commands/lint.ts +19 -6
  144. package/src/cli/commands/onboard.test.ts +319 -225
  145. package/src/cli/commands/onboard.ts +144 -58
  146. package/src/cli/handlers/build.test.ts +3 -3
  147. package/src/cli/handlers/build.ts +2 -0
  148. package/src/cli/handlers/components.ts +2 -2
  149. package/src/cli/handlers/graph.test.ts +44 -0
  150. package/src/cli/handlers/graph.ts +24 -4
  151. package/src/cli/handlers/lifecycle.test.ts +348 -1
  152. package/src/cli/handlers/lifecycle.ts +235 -3
  153. package/src/cli/handlers/run.test.ts +5 -5
  154. package/src/cli/handlers/run.ts +1 -0
  155. package/src/cli/handlers/search-live.test.ts +217 -0
  156. package/src/cli/handlers/search.test.ts +41 -1
  157. package/src/cli/handlers/search.ts +60 -8
  158. package/src/cli/main.ts +17 -4
  159. package/src/cli/registry.ts +7 -0
  160. package/src/cli/security-boundary.test.ts +2 -1
  161. package/src/codegen/docs-pages.test.ts +194 -0
  162. package/src/codegen/docs-pages.ts +138 -0
  163. package/src/codegen/docs-sections.ts +0 -8
  164. package/src/codegen/docs-sidebar.ts +50 -37
  165. package/src/codegen/docs-types.ts +32 -4
  166. package/src/codegen/docs.ts +78 -40
  167. package/src/codegen/lexicon-regen.ts +22 -2
  168. package/src/codegen/rolling-upgrade.test.ts +1 -1
  169. package/src/codegen/rolling-upgrade.ts +1 -1
  170. package/src/codegen/surface-snapshot.test.ts +139 -0
  171. package/src/codegen/surface-snapshot.ts +297 -122
  172. package/src/codegen/validate.test.ts +49 -3
  173. package/src/codegen/validate.ts +36 -7
  174. package/src/components/auto-release.ts +1 -1
  175. package/src/components/capability-plugin.ts +23 -2
  176. package/src/components/driver.test.ts +45 -0
  177. package/src/components/driver.ts +64 -27
  178. package/src/components/index.ts +2 -0
  179. package/src/components/starter-plugin.ts +5 -2
  180. package/src/config.test.ts +40 -0
  181. package/src/config.ts +159 -8
  182. package/src/discovery/fold-import.test.ts +427 -2
  183. package/src/discovery/fold-import.ts +163 -7
  184. package/src/discovery/sandbox/driver.test.ts +11 -0
  185. package/src/env.test.ts +35 -1
  186. package/src/env.ts +17 -3
  187. package/src/fold/fold.test.ts +41 -0
  188. package/src/fold/fold.ts +372 -2
  189. package/src/graph-ir-live.test.ts +42 -0
  190. package/src/graph-ir.ts +42 -2
  191. package/src/graph-ops.test.ts +69 -0
  192. package/src/graph-ops.ts +42 -0
  193. package/src/lexicon.ts +182 -2
  194. package/src/lifecycle/change-set.test.ts +41 -0
  195. package/src/lifecycle/change-set.ts +30 -2
  196. package/src/lifecycle/deep-observe.ts +4 -0
  197. package/src/lifecycle/index.ts +1 -0
  198. package/src/lifecycle/observe.ts +29 -2
  199. package/src/lifecycle/replay.ts +3 -0
  200. package/src/lifecycle/snapshot.ts +3 -0
  201. package/src/lifecycle/teardown.test.ts +537 -0
  202. package/src/lifecycle/teardown.ts +357 -0
  203. package/src/lifecycle/types.ts +7 -0
  204. package/src/lint/engine.ts +7 -1
  205. package/src/lint/policy.ts +14 -3
  206. package/src/lint/rule.ts +23 -0
  207. package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
  208. package/src/lint/rules/cor021-env-literal-name.ts +114 -0
  209. package/src/lint/rules/index.ts +4 -1
  210. package/src/managed-fields.test.ts +15 -42
  211. package/src/managed-fields.ts +43 -26
  212. package/src/observation.test.ts +24 -8
  213. package/src/observation.ts +48 -4
  214. package/src/op/builders.ts +40 -7
  215. package/src/op/index.ts +1 -1
  216. package/src/ownership.test.ts +63 -1
  217. package/src/testing.test.ts +261 -0
  218. package/src/testing.ts +338 -0
  219. package/src/yaml.test.ts +15 -0
  220. package/src/yaml.ts +19 -2
package/src/fold/fold.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as ts from "typescript";
2
+ import { relative } from "node:path";
2
3
  import { intrinsicCallFolds, intrinsicTagFolds, type IntrinsicDef } from "../lexicon";
3
4
  import {
4
5
  SUPPORTED_BINARY_OPERATORS,
@@ -58,7 +59,15 @@ import { isFoldableHelperName } from "./foldable-helpers";
58
59
  * #1044) → {@link FoldedIntrinsicCall}, the same `{__intrinsic}` family
59
60
  * the tagged-template form already reduces to.
60
61
  *
61
- * Everything else a user's function, a method call, an array `.map`, a
62
+ * chant #1373 adds a third, the only one that evaluates rather than
63
+ * enveloping: a call to a PROJECT-LOCAL function — declared in this file or
64
+ * imported from a sibling project file — whose body is itself inside the
65
+ * fold subset. ../discovery/fold-import.ts hands such a function in through
66
+ * `externals` as a {@link FoldableFunction}, and {@link callFoldableFunction}
67
+ * folds its body against the defining module's scope with the folded
68
+ * arguments bound. Still nothing is imported or run.
69
+ *
70
+ * Everything else — a package's function, a method call, an array `.map`, a
62
71
  * registered name shadowed by a local binding — still throws.
63
72
  *
64
73
  * Cross-file identifier resolution (chant #1020): `consts` alone is always
@@ -282,6 +291,295 @@ export class FoldError extends Error {
282
291
  }
283
292
  }
284
293
 
294
+ /**
295
+ * A project-local function `fold()` can CALL — chant #1373.
296
+ *
297
+ * Produced by ../discovery/fold-import.ts for every top-level function a
298
+ * project file declares (`export function f(...) {...}`, `export const f =
299
+ * (...) => ...`, and their non-exported siblings) and placed in the folding
300
+ * file's `externals` under the function's name, so a call through a bare
301
+ * identifier bound to one evaluates STATICALLY: the arguments fold in the
302
+ * caller's scope, the parameters are bound, and the body folds in the
303
+ * DEFINING module's scope (`consts`/`externals` here are that module's, not
304
+ * the caller's). Nothing is imported and nothing runs — this is the same
305
+ * "evaluate the source instead of the module" move #1023 makes for a
306
+ * composite factory body, applied to a plain function.
307
+ *
308
+ * Why this exists: the advice every chant project gets is to read build
309
+ * parameters through a small helper (`optionalAccountId(params.accountId)`)
310
+ * so defaulting and validation live in one place. Before #1373 a call to
311
+ * that helper was "a function call as a value" and unfoldable, and because
312
+ * fallback is per file and taint propagates along imports, ONE helper call
313
+ * at the top of a parameter file demoted every stack that imported it.
314
+ *
315
+ * The value is a marker, never a real function: a folded file's export
316
+ * namespace may carry one (an importer's `buildExternals` picks it up by
317
+ * name), and `fold()` refuses it anywhere a VALUE is expected — a function
318
+ * object cannot be serialized, and the run path's export namespace holds the
319
+ * real function the collector ignores, so ignoring the marker matches.
320
+ *
321
+ * Not a `FoldedValue`: it never appears inside a folded tree.
322
+ */
323
+ export class FoldableFunction {
324
+ constructor(
325
+ /** The binding name, for diagnostics. */
326
+ readonly name: string,
327
+ readonly fn: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression,
328
+ /** Absolute path of the defining module, for diagnostics. */
329
+ readonly file: string,
330
+ /**
331
+ * The defining module's top-level `const` initializers, with every
332
+ * `new`-bound one already REMOVED — a body that mentions one reads the
333
+ * live instance out of `externals` instead (the same object the module's
334
+ * own fold registered), never a by-name `{__attrRef}` that would name an
335
+ * entity of the wrong file once revived in the caller.
336
+ */
337
+ readonly consts: Map<string, ts.Expression>,
338
+ /** The defining module's resolved imports, its own pre-built resources, and its own sibling functions. Read live, never copied, so a function declared before a const it reads still sees that const's value. */
339
+ readonly externals: ReadonlyMap<string, unknown>,
340
+ /** Why an import of the defining module did NOT resolve, by local name — enriches an "unresolved identifier" inside the body. */
341
+ readonly failures?: ReadonlyMap<string, string>,
342
+ ) {}
343
+
344
+ /**
345
+ * Set once a call to this function has RETURNED a live object (a value
346
+ * with a prototype — a pre-built resource instance of the defining module,
347
+ * say) into a caller. The caller then shares that object's identity with
348
+ * the defining module exactly as an imported resource binding would, and
349
+ * fold-import.ts records the same `liveSources` edge for it so the two
350
+ * files fold or run together. A function that returns only plain data never
351
+ * sets this, which is what keeps a parameter helper from tainting anything.
352
+ */
353
+ leakedIdentity = false;
354
+ }
355
+
356
+ /** True when `value` (or anything inside it, through plain objects and arrays) carries a prototype other than Object/Array — i.e. is a live instance, not folded data. */
357
+ function carriesLiveObject(value: unknown, seen = new Set<unknown>()): boolean {
358
+ if (value === null || typeof value !== "object") return typeof value === "function";
359
+ if (seen.has(value)) return false;
360
+ seen.add(value);
361
+ const proto = Object.getPrototypeOf(value);
362
+ if (proto !== Object.prototype && proto !== Array.prototype && proto !== null) return true;
363
+ for (const inner of Object.values(value)) {
364
+ if (carriesLiveObject(inner, seen)) return true;
365
+ }
366
+ return false;
367
+ }
368
+
369
+ export function isFoldableFunction(value: unknown): value is FoldableFunction {
370
+ return value instanceof FoldableFunction;
371
+ }
372
+
373
+ /** A binding element a folded function can bind plainly: `{ a }` / `{ a: b }`, no rest, default, or nested pattern. */
374
+ function plainBindingKey(el: ts.BindingElement): string | undefined {
375
+ if (el.dotDotDotToken || el.initializer || !ts.isIdentifier(el.name)) return undefined;
376
+ const key = el.propertyName ?? el.name;
377
+ if (ts.isIdentifier(key) || ts.isStringLiteral(key) || ts.isNumericLiteral(key)) return key.text;
378
+ return undefined;
379
+ }
380
+
381
+ /**
382
+ * The SHAPE half of what makes a project-local function foldable (chant
383
+ * #1373) — a reason string, or `undefined` when the function is admissible.
384
+ * Deliberately the same statement-level contract #1023 gives a composite
385
+ * factory body: parameters bound plainly (an identifier, optionally
386
+ * defaulted, or a flat object pattern), and a body that is either a single
387
+ * expression or `const` declarations followed by one `return`. Every
388
+ * expression inside is then folded by {@link fold} itself, so the expression
389
+ * subset is defined exactly once — with the additions {@link fold} refuses
390
+ * INSIDE a function body (a `new`, a tagged template, a registered helper or
391
+ * intrinsic call), because each of those reduces to an envelope revived
392
+ * against the CALLER's imports, which are not the scope the body was written
393
+ * in.
394
+ */
395
+ export function findFunctionSubsetViolation(
396
+ fn: ts.FunctionDeclaration | ts.ArrowFunction | ts.FunctionExpression,
397
+ ): string | undefined {
398
+ if (fn.asteriskToken) return "a generator function is not foldable";
399
+ if (fn.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) return "an async function is not foldable";
400
+ if (!fn.body) return "a function without a body (an overload signature) is not foldable";
401
+
402
+ for (const param of fn.parameters) {
403
+ if (param.dotDotDotToken) return "a rest parameter is not foldable";
404
+ if (ts.isIdentifier(param.name)) continue;
405
+ if (ts.isObjectBindingPattern(param.name)) {
406
+ for (const el of param.name.elements) {
407
+ if (plainBindingKey(el) === undefined) {
408
+ return "a destructured parameter with a rest, default, or nested element is not foldable";
409
+ }
410
+ }
411
+ continue;
412
+ }
413
+ return "an array-destructured parameter is not foldable";
414
+ }
415
+
416
+ if (!ts.isBlock(fn.body)) return undefined;
417
+
418
+ const statements = fn.body.statements;
419
+ for (let i = 0; i < statements.length; i += 1) {
420
+ const statement = statements[i];
421
+ const last = i === statements.length - 1;
422
+
423
+ if (ts.isReturnStatement(statement)) {
424
+ if (!last) return "an early `return` is not foldable";
425
+ continue;
426
+ }
427
+ if (!ts.isVariableStatement(statement)) {
428
+ return `\`${ts.SyntaxKind[statement.kind]}\` in a function body is not foldable — only \`const\` declarations and a final \`return\` are`;
429
+ }
430
+ if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) {
431
+ return "`let`/`var` in a function body is not foldable";
432
+ }
433
+ for (const decl of statement.declarationList.declarations) {
434
+ if (!decl.initializer) return "an uninitialized `const` in a function body is not foldable";
435
+ if (ts.isIdentifier(decl.name)) continue;
436
+ if (ts.isObjectBindingPattern(decl.name)) {
437
+ for (const el of decl.name.elements) {
438
+ if (plainBindingKey(el) === undefined) {
439
+ return "a destructured `const` with a rest, default, or nested element is not foldable";
440
+ }
441
+ }
442
+ continue;
443
+ }
444
+ return "an array-destructured `const` in a function body is not foldable";
445
+ }
446
+ }
447
+ return undefined;
448
+ }
449
+
450
+ /**
451
+ * How many project-local function bodies are being folded around the current
452
+ * `fold()` call — 0 at a file's own top level. Two jobs: terminate a function
453
+ * that (directly or through another) calls itself, which has no fixpoint and
454
+ * no file boundary for fold-import's cycle detection to notice; and tell the
455
+ * envelope-producing branches of {@link fold} that they are inside a body,
456
+ * where an envelope must not be produced (see {@link findFunctionSubsetViolation}).
457
+ */
458
+ let functionBodyDepth = 0;
459
+ const MAX_FUNCTION_CALL_DEPTH = 32;
460
+
461
+ function insideFunctionBody(node: ts.Node, what: string): FoldError | undefined {
462
+ if (functionBodyDepth === 0) return undefined;
463
+ return foldError(node, `${what} inside a folded function body is not foldable`);
464
+ }
465
+
466
+ function fileLabel(file: string): string {
467
+ const rel = relative(process.cwd(), file);
468
+ return rel.startsWith("..") ? file : rel;
469
+ }
470
+
471
+ /**
472
+ * Evaluate a call to a project-local function (chant #1373) — see
473
+ * {@link FoldableFunction}. Arguments fold in the caller's scope (`consts`/
474
+ * `externals`); the body folds in the callee's, with the parameters bound on
475
+ * top. Any failure inside the body is re-thrown at the CALL site, naming the
476
+ * callee, its file and the position inside it, and the reason — so the
477
+ * `[fold:run]` line for the importing file says which helper to fix and why,
478
+ * rather than the bare "function call as a value" it said before.
479
+ */
480
+ function callFoldableFunction(
481
+ callee: FoldableFunction,
482
+ node: ts.CallExpression,
483
+ consts: Map<string, ts.Expression>,
484
+ intrinsics: readonly IntrinsicDef[],
485
+ externals: ReadonlyMap<string, unknown> | undefined,
486
+ ): FoldedValue {
487
+ const label = `call to "${callee.name}" (${fileLabel(callee.file)})`;
488
+ const violation = findFunctionSubsetViolation(callee.fn);
489
+ if (violation) throw foldError(node, `${label} is not foldable: ${violation}`);
490
+ if (functionBodyDepth >= MAX_FUNCTION_CALL_DEPTH) {
491
+ throw foldError(node, `${label} is not foldable: call depth exceeded — is it recursive?`);
492
+ }
493
+
494
+ const args: FoldedValue[] = [];
495
+ for (const arg of node.arguments) {
496
+ if (ts.isSpreadElement(arg)) throw foldError(arg, `${label} is not foldable: a spread argument is not foldable`);
497
+ args.push(fold(arg, consts, intrinsics, externals));
498
+ }
499
+
500
+ const bodyConsts = new Map(callee.consts);
501
+ const bodyExternals = new Map(callee.externals);
502
+ const bind = (name: string, value: unknown): void => {
503
+ // A parameter or body binding SHADOWS a module-level const of the same
504
+ // name — `fold()` consults `consts` before `externals`.
505
+ bodyConsts.delete(name);
506
+ bodyExternals.set(name, value);
507
+ };
508
+ const destructure = (pattern: ts.ObjectBindingPattern, value: unknown): void => {
509
+ if (value === null || typeof value !== "object") {
510
+ throw foldError(pattern, `destructured source in \`${briefNodeText(pattern)}\` is not an object`);
511
+ }
512
+ for (const el of pattern.elements) {
513
+ bind((el.name as ts.Identifier).text, (value as Record<string, unknown>)[plainBindingKey(el) as string]);
514
+ }
515
+ };
516
+
517
+ functionBodyDepth += 1;
518
+ try {
519
+ const result = evaluateFunctionBody(callee, args, bodyConsts, bodyExternals, intrinsics, bind, destructure);
520
+ // Live objects that ARRIVED through the arguments are the caller's own
521
+ // already; only one the body produced from its module's scope is new.
522
+ if (!callee.leakedIdentity && carriesLiveObject(result) && !args.some((arg) => carriesLiveObject(arg))) {
523
+ callee.leakedIdentity = true;
524
+ }
525
+ return result;
526
+ } catch (err) {
527
+ if (!(err instanceof FoldError)) throw err;
528
+ // The inner message is positioned inside the CALLEE file. Strip that
529
+ // prefix and re-anchor at the call site: callee, its file and position,
530
+ // then the reason.
531
+ const prefix = `${err.line}:${err.column} - `;
532
+ const inner = err.message.startsWith(prefix) ? err.message.slice(prefix.length) : err.message;
533
+ let reason = inner;
534
+ const unresolved = /^unresolved identifier: (\w+)$/.exec(inner);
535
+ if (unresolved && callee.failures?.has(unresolved[1])) {
536
+ reason = `${inner} (${callee.failures.get(unresolved[1])})`;
537
+ }
538
+ throw foldError(
539
+ node,
540
+ `${label} is not foldable: ${fileLabel(callee.file)}:${err.line}:${err.column} - ${reason}`,
541
+ err.ruleId,
542
+ );
543
+ } finally {
544
+ functionBodyDepth -= 1;
545
+ }
546
+ }
547
+
548
+ function evaluateFunctionBody(
549
+ callee: FoldableFunction,
550
+ args: readonly FoldedValue[],
551
+ bodyConsts: Map<string, ts.Expression>,
552
+ bodyExternals: Map<string, unknown>,
553
+ intrinsics: readonly IntrinsicDef[],
554
+ bind: (name: string, value: unknown) => void,
555
+ destructure: (pattern: ts.ObjectBindingPattern, value: unknown) => void,
556
+ ): FoldedValue {
557
+ callee.fn.parameters.forEach((param, i) => {
558
+ let value: unknown = args[i];
559
+ if (value === undefined && param.initializer) {
560
+ value = fold(param.initializer, bodyConsts, intrinsics, bodyExternals);
561
+ }
562
+ if (ts.isIdentifier(param.name)) bind(param.name.text, value);
563
+ else destructure(param.name as ts.ObjectBindingPattern, value);
564
+ });
565
+
566
+ const body = callee.fn.body as ts.ConciseBody;
567
+ if (!ts.isBlock(body)) return fold(body, bodyConsts, intrinsics, bodyExternals);
568
+
569
+ for (const statement of body.statements) {
570
+ if (ts.isReturnStatement(statement)) {
571
+ return statement.expression ? fold(statement.expression, bodyConsts, intrinsics, bodyExternals) : undefined;
572
+ }
573
+ for (const decl of (statement as ts.VariableStatement).declarationList.declarations) {
574
+ const value = fold(decl.initializer as ts.Expression, bodyConsts, intrinsics, bodyExternals);
575
+ if (ts.isIdentifier(decl.name)) bind(decl.name.text, value);
576
+ else destructure(decl.name as ts.ObjectBindingPattern, value);
577
+ }
578
+ }
579
+ // A block body with no `return` evaluates to `undefined`, as it would run.
580
+ return undefined;
581
+ }
582
+
285
583
  /**
286
584
  * Resolve a node's 1-based line/column via its owning `SourceFile`. Exported
287
585
  * (chant #1020) so fold-import.ts can build its own located `FoldError`s
@@ -350,6 +648,41 @@ function resolvesToResource(consts: Map<string, ts.Expression>, ident: ts.Identi
350
648
  return init !== undefined && ts.isNewExpression(init);
351
649
  }
352
650
 
651
+ /** True when a folded value is a {@link FoldedResource} envelope (a `new Type(...)` that nothing constructed yet). */
652
+ function isFoldedResource(value: FoldedValue): value is FoldedResource {
653
+ return typeof value === "object" && value !== null && !Array.isArray(value) && "__resource" in value;
654
+ }
655
+
656
+ /**
657
+ * chant #1535 — an attribute read whose object folded to a resource ENVELOPE
658
+ * rather than resolving through {@link resolvesToResource}. That happens when
659
+ * the const's initializer is not a bare `new` but an expression that yields
660
+ * one: `const provider = flag ? new OIDCProvider({...}) : undefined;` then
661
+ * `provider.Arn`. Indexing the envelope (`{__resource, props}`) by the
662
+ * attribute name returns `undefined`, and the property vanished from the
663
+ * output without a word — a trust policy built with `Principal: { Federated:
664
+ * provider.Arn }` landed in CloudFormation as `Principal: {}`.
665
+ *
666
+ * When the object is a plain identifier, the answer is the same symbolic
667
+ * `{__attrRef}` the bare-`new` case produces — the serializer resolves it by
668
+ * the const's name, exactly as it would have for `const provider = new
669
+ * OIDCProvider({...})`. Any other expression shape has no name to key the
670
+ * ref on, so it refuses and the file falls back to run rather than emitting
671
+ * something wrong.
672
+ */
673
+ function attrRefOnFoldedResource(
674
+ node: ts.PropertyAccessExpression | ts.ElementAccessExpression,
675
+ attribute: string,
676
+ ): AttrRefValue {
677
+ if (ts.isIdentifier(node.expression)) {
678
+ return { __attrRef: { entity: node.expression.text, attribute } };
679
+ }
680
+ throw foldError(
681
+ node,
682
+ `attribute "${attribute}" read on an inline resource expression is not foldable — bind the resource to a const first (falls back to run)`,
683
+ );
684
+ }
685
+
353
686
  /**
354
687
  * True when `node` is an identifier, or a dotted/bracketed access chain
355
688
  * rooted at an identifier, that neither `consts` nor `externals` can resolve
@@ -474,6 +807,12 @@ export function fold(
474
807
  return fold(node.expression, consts, intrinsics, externals);
475
808
  }
476
809
 
810
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
811
+ // chant #1373 — a function is callable here (see {@link FoldableFunction})
812
+ // but never a VALUE: nothing downstream can serialize one.
813
+ throw foldError(node, "a function used as a value is not foldable");
814
+ }
815
+
477
816
  if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
478
817
  return node.text;
479
818
  }
@@ -489,6 +828,8 @@ export function fold(
489
828
  if (ts.isIdentifier(node) && node.text === "undefined") return undefined;
490
829
 
491
830
  if (ts.isTaggedTemplateExpression(node)) {
831
+ const inside = insideFunctionBody(node, "a tagged template intrinsic");
832
+ if (inside) throw inside;
492
833
  return foldTaggedTemplate(node, consts, intrinsics, externals);
493
834
  }
494
835
 
@@ -547,7 +888,11 @@ export function fold(
547
888
  // `AttrRef` fall out of `network.vpc.VpcId` with zero special-casing
548
889
  // here (see fold-import.ts's module doc).
549
890
  if (externals?.has(node.text)) {
550
- return externals.get(node.text) as FoldedValue;
891
+ const external = externals.get(node.text);
892
+ if (isFoldableFunction(external)) {
893
+ throw foldError(node, `function "${node.text}" used as a value is not foldable`);
894
+ }
895
+ return external as FoldedValue;
551
896
  }
552
897
  // chant #1064 — a bare `process` reference is ALWAYS an ambient
553
898
  // environment read (`process.env.X`, `process.argv`, …), never
@@ -605,6 +950,7 @@ export function fold(
605
950
  }
606
951
  const obj = fold(node.expression, consts, intrinsics, externals);
607
952
  if (obj === null || obj === undefined) return undefined;
953
+ if (isFoldedResource(obj)) return attrRefOnFoldedResource(node, node.name.text);
608
954
  return (obj as { [key: string]: FoldedValue })[node.name.text];
609
955
  }
610
956
 
@@ -615,6 +961,7 @@ export function fold(
615
961
  }
616
962
  const obj = fold(node.expression, consts, intrinsics, externals);
617
963
  if (obj === null || obj === undefined) return undefined;
964
+ if (isFoldedResource(obj)) return attrRefOnFoldedResource(node, key);
618
965
  return (obj as { [key: string]: FoldedValue })[key];
619
966
  }
620
967
 
@@ -719,6 +1066,8 @@ export function fold(
719
1066
  `nested \`new ${briefNodeText(node.expression)}(...)\` as a value needs a plain imported constructor — falls back to run`,
720
1067
  );
721
1068
  }
1069
+ const inside = insideFunctionBody(node, `\`new ${node.expression.text}(...)\``);
1070
+ if (inside) throw inside;
722
1071
  return foldResource(node, consts, intrinsics, externals);
723
1072
  }
724
1073
 
@@ -740,6 +1089,8 @@ export function fold(
740
1089
  isFoldableHelperName(node.expression.text) &&
741
1090
  !consts.has(node.expression.text)
742
1091
  ) {
1092
+ const inside = insideFunctionBody(node, `authoring helper call \`${node.expression.text}(...)\``);
1093
+ if (inside) throw inside;
743
1094
  return {
744
1095
  __helper: node.expression.text,
745
1096
  args: node.arguments.map((arg) => fold(arg, consts, intrinsics, externals)),
@@ -769,6 +1120,8 @@ export function fold(
769
1120
  if (ts.isIdentifier(node.expression) && !consts.has(node.expression.text)) {
770
1121
  const calleeName = node.expression.text;
771
1122
  if (intrinsics.some((i) => i.name === calleeName && intrinsicCallFolds(i))) {
1123
+ const inside = insideFunctionBody(node, `intrinsic call \`${calleeName}(...)\``);
1124
+ if (inside) throw inside;
772
1125
  return {
773
1126
  __intrinsic: calleeName,
774
1127
  args: node.arguments.map((arg) => foldIntrinsicValue(arg, consts, intrinsics, externals)),
@@ -776,6 +1129,23 @@ export function fold(
776
1129
  }
777
1130
  }
778
1131
 
1132
+ // chant #1373 — the third call shape, and the only open-ended one: a
1133
+ // PROJECT-LOCAL function (declared in this file or imported from a
1134
+ // sibling project file) whose body is itself foldable. fold-import.ts
1135
+ // places a {@link FoldableFunction} marker in `externals` for each such
1136
+ // declaration (a same-file `const f = () => …` is in `consts` too, which
1137
+ // is why this is not gated on `consts` like the two above); the call is
1138
+ // evaluated right here, statically, against the defining module's scope
1139
+ // — see {@link callFoldableFunction}. Checked AFTER the two registered
1140
+ // shapes so a registered name keeps its registered meaning; a package
1141
+ // export never produces a marker, so `node_modules` stays out.
1142
+ if (ts.isIdentifier(node.expression)) {
1143
+ const callee = externals?.get(node.expression.text);
1144
+ if (isFoldableFunction(callee)) {
1145
+ return callFoldableFunction(callee, node, consts, intrinsics, externals);
1146
+ }
1147
+ }
1148
+
779
1149
  throw foldError(node, callExpressionMessage(node));
780
1150
  }
781
1151
 
@@ -82,6 +82,27 @@ describe("buildLiveGraphIr", () => {
82
82
  JSON.stringify(buildLiveGraphIr(observations)),
83
83
  );
84
84
  });
85
+
86
+ // #1279 — what a stack publishes is the stack's, not every member's. It lands
87
+ // on the IR's `exports` (where the declared graph keeps the same thing) and
88
+ // never on a node's attrs.
89
+ it("projects stack exports onto the IR's exports, not onto the nodes (#1279)", () => {
90
+ const ir = buildLiveGraphIr([
91
+ { ...observations[0], stackExports: { prod: { expVpcId: "vpc-0a1b", expWebIp: "54.1.2.3" } } },
92
+ ]);
93
+ expect(ir.exports).toEqual([
94
+ { name: "expVpcId", value: "vpc-0a1b", stack: "prod" },
95
+ { name: "expWebIp", value: "54.1.2.3", stack: "prod" },
96
+ ]);
97
+ for (const n of ir.nodes) {
98
+ expect(Object.keys(n.attrs).filter((k) => k.startsWith("exp"))).toEqual([]);
99
+ }
100
+ expect(ir.nodes.find((n) => n.id === "web-vpc")!.attrs).toEqual({ CidrBlock: "10.0.0.0/16" });
101
+ });
102
+
103
+ it("omits exports when no observation reported any", () => {
104
+ expect(buildLiveGraphIr(observations).exports).toBeUndefined();
105
+ });
85
106
  });
86
107
 
87
108
  describe("overlayGraphs (#780 drift overlay)", () => {
@@ -219,6 +240,27 @@ describe("sourceOverlayGraphs (#821 source-anchored overlay)", () => {
219
240
  expect((node.attrs as { _unobserved?: string })._unobserved).toBe("no-binding");
220
241
  });
221
242
 
243
+ it("joins observed export values onto the declared exports by name (#1279)", () => {
244
+ const withExports: GraphIR = {
245
+ ...declared,
246
+ exports: [{ name: "VpcId", node: "web-vpc", attr: "VpcId" }],
247
+ };
248
+ const liveWithExports: GraphIR = {
249
+ ...live,
250
+ exports: [
251
+ { name: "VpcId", value: "vpc-0a1b", stack: "prod" },
252
+ { name: "WebIp", value: "54.1.2.3", stack: "prod" },
253
+ ],
254
+ };
255
+ const ir = sourceOverlayGraphs(withExports, liveWithExports);
256
+ expect(ir.exports).toEqual([
257
+ { name: "VpcId", node: "web-vpc", attr: "VpcId", value: "vpc-0a1b", stack: "prod" },
258
+ { name: "WebIp", value: "54.1.2.3", stack: "prod" },
259
+ ]);
260
+ // And the member node still carries none of them.
261
+ expect(ir.nodes.find((n) => n.id === "web-vpc")!.attrs.VpcId).toBeUndefined();
262
+ });
263
+
222
264
  it("still paints a confirmed-absent declared node `accent`", () => {
223
265
  const ir = sourceOverlayGraphs(declared, live, { unobserved: {} });
224
266
  expect((ir.nodes.find((x) => x.id === "planned-db")!.attrs as { _status?: string })._status).toBe("accent");
package/src/graph-ir.ts CHANGED
@@ -134,6 +134,10 @@ export interface IRExport {
134
134
  node?: string;
135
135
  /** Producer-side attribute the output reads (e.g. "Arn"). */
136
136
  attr?: string;
137
+ /** The published value, when the graph was observed rather than declared (#1279). */
138
+ value?: unknown;
139
+ /** The deployed stack that publishes it, on an observed graph (#1279). */
140
+ stack?: string;
137
141
  }
138
142
 
139
143
  /** A cross-stack import this stack consumes — a `Parameter` fed from another
@@ -527,6 +531,14 @@ export interface LiveObservation {
527
531
  * keys, so an edge can only reference something the observation also reported.
528
532
  */
529
533
  edges?: IREdge[];
534
+ /**
535
+ * What each deployed stack publishes (#1279), keyed by stack name. Stack-level
536
+ * by nature, so it is carried here once and projected onto the IR's `exports`
537
+ * rather than copied onto every node's `attrs` — which is what the AWS
538
+ * observation used to do, leaving every VPC with the stack's `expWebIp` and
539
+ * no `CidrBlock` of its own.
540
+ */
541
+ stackExports?: Record<string, Record<string, unknown>>;
530
542
  }
531
543
 
532
544
  /**
@@ -602,7 +614,17 @@ export function buildLiveGraphIr(observations: LiveObservation[]): GraphIR {
602
614
  (a.viaAttr ?? "").localeCompare(b.viaAttr ?? ""),
603
615
  );
604
616
 
605
- return { nodes, edges, groups };
617
+ // Stack exports (#1279) land on the IR's `exports`, the same place the
618
+ // declared graph keeps what a stack publishes — not on the member nodes.
619
+ const exports: IRExport[] = [];
620
+ for (const observation of observations) {
621
+ for (const [stack, values] of Object.entries(observation.stackExports ?? {})) {
622
+ for (const [name, value] of Object.entries(values)) exports.push({ name, value, stack });
623
+ }
624
+ }
625
+ exports.sort((a, b) => a.name.localeCompare(b.name) || (a.stack ?? "").localeCompare(b.stack ?? ""));
626
+
627
+ return { nodes, edges, groups, ...(exports.length > 0 ? { exports } : {}) };
606
628
  }
607
629
 
608
630
  /** How an overlay learns which declared nodes were never looked at (#1089). */
@@ -743,5 +765,23 @@ export function sourceOverlayGraphs(declared: GraphIR, live: GraphIR, opts?: Ove
743
765
  }
744
766
  edges.sort((a, b) => edgeKey(a).localeCompare(edgeKey(b)));
745
767
 
746
- return { ...declared, nodes, edges };
768
+ // Observed export values (#1279) join the declared exports by name: the
769
+ // declaration says which node publishes it, the observation says what it
770
+ // currently is. An export only the stack carries is appended.
771
+ const liveExports = live.exports ?? [];
772
+ let exports = declared.exports;
773
+ if (liveExports.length > 0) {
774
+ const byName = new Map(liveExports.map((e) => [e.name, e]));
775
+ const merged: IRExport[] = (declared.exports ?? []).map((e) => {
776
+ const obs = byName.get(e.name);
777
+ if (!obs) return e;
778
+ byName.delete(e.name);
779
+ return { ...e, value: obs.value, ...(obs.stack ? { stack: obs.stack } : {}) };
780
+ });
781
+ for (const e of byName.values()) merged.push(e);
782
+ merged.sort((a, b) => a.name.localeCompare(b.name));
783
+ exports = merged;
784
+ }
785
+
786
+ return { ...declared, nodes, edges, ...(exports ? { exports } : {}) };
747
787
  }
@@ -0,0 +1,69 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import { mkdtemp, mkdir, writeFile, rm, realpath } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import { discover } from "./discovery/index";
8
+ import { buildGraphIr } from "./graph-ir";
9
+ import { mergeProjectOps } from "./graph-ops";
10
+
11
+ const exec = promisify(execFile);
12
+
13
+ // #1675 — a project laid out the conventional way: `sourceDir: "src"` holds the
14
+ // infra, and the op sits at the project root beside it. Discovery (scoped to
15
+ // src/) never sees the op; discoverOps (scoped to the git root) does. The IR
16
+ // must carry it.
17
+ describe("mergeProjectOps (#1675)", () => {
18
+ let root: string;
19
+ const opModule = join(process.cwd(), "packages/core/src/op/index.ts");
20
+
21
+ beforeAll(async () => {
22
+ root = await realpath(await mkdtemp(join(tmpdir(), "chant-graph-ops-")));
23
+ await exec("git", ["init", "-q", root]);
24
+ await mkdir(join(root, "src"), { recursive: true });
25
+ await writeFile(join(root, "chant.config.ts"), `export default { sourceDir: "src" };\n`);
26
+ await writeFile(
27
+ join(root, "src", "infra.ts"),
28
+ `import { Op, phase, activity } from "${opModule}";\n` +
29
+ `export const inner = Op({ name: "inner", phases: [phase("P", [activity("build")])] });\n`,
30
+ );
31
+ await writeFile(
32
+ join(root, "deploy.op.ts"),
33
+ `import { Op, phase, activity, gate } from "${opModule}";\n` +
34
+ `export default Op({ name: "deploy", overview: "ship it", depends: ["inner"], phases: [phase("Apply", [activity("build"), gate("approve")])] });\n`,
35
+ );
36
+ });
37
+
38
+ afterAll(async () => {
39
+ await rm(root, { recursive: true, force: true });
40
+ });
41
+
42
+ test("an op at the project root joins the entities discovery loaded from sourceDir", async () => {
43
+ const result = await discover(join(root, "src"));
44
+ expect(result.errors).toEqual([]);
45
+ expect([...result.entities.keys()]).toEqual(["inner"]);
46
+
47
+ const merged = await mergeProjectOps(result.entities, result.sourceFiles, root);
48
+ expect(merged.errors).toEqual([]);
49
+ expect(merged.added).toEqual(["deploy"]);
50
+
51
+ const ir = buildGraphIr(result.entities, root);
52
+ const node = ir.nodes.find((n) => n.id === "deploy");
53
+ expect(node).toMatchObject({ kind: "Temporal::Op", lexicon: "temporal", sourceLoc: { file: "deploy.op.ts" } });
54
+ expect(node?.attrs.name).toBe("deploy");
55
+ expect(node?.attrs.depends).toEqual(["inner"]);
56
+ expect(node?.attrs.phases).toEqual([
57
+ { name: "Apply", steps: [{ kind: "activity", fn: "build" }, { kind: "gate", signalName: "approve" }] },
58
+ ]);
59
+ // The sourceDir op discovery already loaded is untouched, not duplicated.
60
+ expect(ir.nodes.filter((n) => n.kind === "Temporal::Op")).toHaveLength(2);
61
+ });
62
+
63
+ test("is idempotent — a second merge adds nothing", async () => {
64
+ const result = await discover(join(root, "src"));
65
+ await mergeProjectOps(result.entities, result.sourceFiles, root);
66
+ const again = await mergeProjectOps(result.entities, result.sourceFiles, root);
67
+ expect(again.added).toEqual([]);
68
+ });
69
+ });