@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.
- package/dist/audit/discover.d.ts +26 -2
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/report-model.d.ts +10 -0
- package/dist/audit/report-model.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/cli/build-params-cli.d.ts +16 -7
- package/dist/cli/build-params-cli.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +20 -0
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +9 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-docs.d.ts +13 -0
- package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-examples.d.ts +24 -14
- package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.d.ts +14 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/init-lexicon/templates/codegen.d.ts.map +1 -1
- package/dist/cli/commands/init-lexicon/templates/docs.d.ts +5 -0
- package/dist/cli/commands/init-lexicon/templates/docs.d.ts.map +1 -1
- package/dist/cli/commands/init-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
- package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/onboard.d.ts +28 -0
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +13 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +27 -0
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +7 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/docs-pages.d.ts +39 -0
- package/dist/codegen/docs-pages.d.ts.map +1 -0
- package/dist/codegen/docs-sections.d.ts.map +1 -1
- package/dist/codegen/docs-sidebar.d.ts +13 -2
- package/dist/codegen/docs-sidebar.d.ts.map +1 -1
- package/dist/codegen/docs-types.d.ts +30 -10
- package/dist/codegen/docs-types.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts +14 -2
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/lexicon-regen.d.ts +11 -0
- package/dist/codegen/lexicon-regen.d.ts.map +1 -1
- package/dist/codegen/surface-snapshot.d.ts +27 -3
- package/dist/codegen/surface-snapshot.d.ts.map +1 -1
- package/dist/codegen/validate.d.ts +21 -3
- package/dist/codegen/validate.d.ts.map +1 -1
- package/dist/components/capability-plugin.d.ts +13 -2
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/driver.d.ts +38 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/index.d.ts +1 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/starter-plugin.d.ts.map +1 -1
- package/dist/config.d.ts +85 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/env.d.ts +12 -1
- package/dist/env.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +100 -1
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +12 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-ops.d.ts +17 -0
- package/dist/graph-ops.d.ts.map +1 -0
- package/dist/lexicon.d.ts +182 -2
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +23 -2
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +1 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +8 -0
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts.map +1 -1
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/lifecycle/teardown.d.ts +130 -0
- package/dist/lifecycle/teardown.d.ts.map +1 -0
- package/dist/lifecycle/types.d.ts +7 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/lint/engine.d.ts +6 -2
- package/dist/lint/engine.d.ts.map +1 -1
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/lint/rule.d.ts +31 -0
- package/dist/lint/rule.d.ts.map +1 -1
- package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
- package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
- package/dist/lint/rules/index.d.ts +2 -1
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/managed-fields.d.ts +33 -21
- package/dist/managed-fields.d.ts.map +1 -1
- package/dist/observation.d.ts +23 -1
- package/dist/observation.d.ts.map +1 -1
- package/dist/op/builders.d.ts +36 -7
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/index.d.ts +1 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/testing.d.ts +136 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/yaml.d.ts +0 -8
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/__snapshots__/okf.test.ts.snap +20 -0
- package/src/audit/discover.test.ts +55 -1
- package/src/audit/discover.ts +70 -2
- package/src/audit/edge-init-safety.test.ts +149 -18
- package/src/audit/report-model.ts +12 -1
- package/src/audit/rules-doc.ts +1 -0
- package/src/build-params.test.ts +52 -0
- package/src/cli/build-params-cli.test.ts +25 -8
- package/src/cli/build-params-cli.ts +37 -23
- package/src/cli/commands/__fixtures__/audit-coverage/.github/workflows/ci.yml +5 -0
- package/src/cli/commands/__fixtures__/audit-coverage/Dockerfile +2 -0
- package/src/cli/commands/__fixtures__/audit-coverage/infra/main.tf +3 -0
- package/src/cli/commands/__fixtures__/audit-coverage/infra/stack.json +6 -0
- package/src/cli/commands/__fixtures__/audit-coverage/k8s/deploy.yaml +12 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/pages/getting-started.mdx +16 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content.config.ts +10 -2
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate-cli.ts +3 -1
- package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate.ts +2 -1
- package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +2 -1
- package/src/cli/commands/audit.test.ts +83 -1
- package/src/cli/commands/audit.ts +121 -28
- package/src/cli/commands/build.test.ts +275 -5
- package/src/cli/commands/build.ts +86 -14
- package/src/cli/commands/check-lexicon-docs.ts +30 -0
- package/src/cli/commands/check-lexicon-examples.test.ts +73 -7
- package/src/cli/commands/check-lexicon-examples.ts +66 -17
- package/src/cli/commands/check-lexicon.test.ts +46 -2
- package/src/cli/commands/check-lexicon.ts +64 -4
- package/src/cli/commands/init-lexicon/templates/codegen.ts +5 -2
- package/src/cli/commands/init-lexicon/templates/docs.ts +35 -2
- package/src/cli/commands/init-lexicon.test.ts +42 -1
- package/src/cli/commands/init-lexicon.ts +3 -1
- package/src/cli/commands/lexicon-rolling-upgrade.ts +1 -1
- package/src/cli/commands/lexicon-surface-diff.test.ts +1 -1
- package/src/cli/commands/lexicon-surface-diff.ts +10 -1
- package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
- package/src/cli/commands/lint.ts +19 -6
- package/src/cli/commands/onboard.test.ts +319 -225
- package/src/cli/commands/onboard.ts +144 -58
- package/src/cli/handlers/build.test.ts +3 -3
- package/src/cli/handlers/build.ts +2 -0
- package/src/cli/handlers/components.ts +2 -2
- package/src/cli/handlers/graph.test.ts +44 -0
- package/src/cli/handlers/graph.ts +24 -4
- package/src/cli/handlers/lifecycle.test.ts +348 -1
- package/src/cli/handlers/lifecycle.ts +235 -3
- package/src/cli/handlers/run.test.ts +5 -5
- package/src/cli/handlers/run.ts +1 -0
- package/src/cli/handlers/search-live.test.ts +217 -0
- package/src/cli/handlers/search.test.ts +41 -1
- package/src/cli/handlers/search.ts +60 -8
- package/src/cli/main.ts +17 -4
- package/src/cli/registry.ts +7 -0
- package/src/cli/security-boundary.test.ts +2 -1
- package/src/codegen/docs-pages.test.ts +194 -0
- package/src/codegen/docs-pages.ts +138 -0
- package/src/codegen/docs-sections.ts +0 -8
- package/src/codegen/docs-sidebar.ts +50 -37
- package/src/codegen/docs-types.ts +32 -4
- package/src/codegen/docs.ts +78 -40
- package/src/codegen/lexicon-regen.ts +22 -2
- package/src/codegen/rolling-upgrade.test.ts +1 -1
- package/src/codegen/rolling-upgrade.ts +1 -1
- package/src/codegen/surface-snapshot.test.ts +139 -0
- package/src/codegen/surface-snapshot.ts +297 -122
- package/src/codegen/validate.test.ts +49 -3
- package/src/codegen/validate.ts +36 -7
- package/src/components/auto-release.ts +1 -1
- package/src/components/capability-plugin.ts +23 -2
- package/src/components/driver.test.ts +45 -0
- package/src/components/driver.ts +64 -27
- package/src/components/index.ts +2 -0
- package/src/components/starter-plugin.ts +5 -2
- package/src/config.test.ts +40 -0
- package/src/config.ts +159 -8
- package/src/discovery/fold-import.test.ts +427 -2
- package/src/discovery/fold-import.ts +163 -7
- package/src/discovery/sandbox/driver.test.ts +11 -0
- package/src/env.test.ts +35 -1
- package/src/env.ts +17 -3
- package/src/fold/fold.test.ts +41 -0
- package/src/fold/fold.ts +372 -2
- package/src/graph-ir-live.test.ts +42 -0
- package/src/graph-ir.ts +42 -2
- package/src/graph-ops.test.ts +69 -0
- package/src/graph-ops.ts +42 -0
- package/src/lexicon.ts +182 -2
- package/src/lifecycle/change-set.test.ts +41 -0
- package/src/lifecycle/change-set.ts +30 -2
- package/src/lifecycle/deep-observe.ts +4 -0
- package/src/lifecycle/index.ts +1 -0
- package/src/lifecycle/observe.ts +29 -2
- package/src/lifecycle/replay.ts +3 -0
- package/src/lifecycle/snapshot.ts +3 -0
- package/src/lifecycle/teardown.test.ts +537 -0
- package/src/lifecycle/teardown.ts +357 -0
- package/src/lifecycle/types.ts +7 -0
- package/src/lint/engine.ts +7 -1
- package/src/lint/policy.ts +14 -3
- package/src/lint/rule.ts +23 -0
- package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
- package/src/lint/rules/cor021-env-literal-name.ts +114 -0
- package/src/lint/rules/index.ts +4 -1
- package/src/managed-fields.test.ts +15 -42
- package/src/managed-fields.ts +43 -26
- package/src/observation.test.ts +24 -8
- package/src/observation.ts +48 -4
- package/src/op/builders.ts +40 -7
- package/src/op/index.ts +1 -1
- package/src/ownership.test.ts +63 -1
- package/src/testing.test.ts +261 -0
- package/src/testing.ts +338 -0
- package/src/yaml.test.ts +15 -0
- 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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|