@intentius/chant 0.23.0 → 0.25.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 (62) hide show
  1. package/dist/build.d.ts.map +1 -1
  2. package/dist/cli/commands/build.d.ts.map +1 -1
  3. package/dist/cli/handlers/build.d.ts.map +1 -1
  4. package/dist/cli/handlers/run.d.ts +8 -0
  5. package/dist/cli/handlers/run.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/plugins.d.ts +8 -0
  8. package/dist/cli/plugins.d.ts.map +1 -1
  9. package/dist/config-import.d.ts +33 -0
  10. package/dist/config-import.d.ts.map +1 -0
  11. package/dist/config-sandbox.d.ts +47 -0
  12. package/dist/config-sandbox.d.ts.map +1 -0
  13. package/dist/config.d.ts +29 -2
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/discovery/entity-wire-codec.d.ts +1 -1
  16. package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
  17. package/dist/discovery/sandbox/config-run.d.ts +24 -0
  18. package/dist/discovery/sandbox/config-run.d.ts.map +1 -0
  19. package/dist/discovery/sandbox/config-wire.d.ts +68 -0
  20. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -0
  21. package/dist/discovery/sandbox/driver.d.ts +20 -0
  22. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  23. package/dist/discovery/sandbox/fork.d.ts +52 -0
  24. package/dist/discovery/sandbox/fork.d.ts.map +1 -0
  25. package/dist/discovery/sandbox/run.d.ts +10 -20
  26. package/dist/discovery/sandbox/run.d.ts.map +1 -1
  27. package/dist/lexicon-output.d.ts +62 -6
  28. package/dist/lexicon-output.d.ts.map +1 -1
  29. package/dist/lint/config.d.ts +7 -12
  30. package/dist/lint/config.d.ts.map +1 -1
  31. package/dist/project-root.d.ts +51 -0
  32. package/dist/project-root.d.ts.map +1 -0
  33. package/package.json +1 -1
  34. package/src/build.test.ts +19 -0
  35. package/src/build.ts +21 -10
  36. package/src/cli/commands/build.ts +42 -6
  37. package/src/cli/handlers/build.test.ts +11 -9
  38. package/src/cli/handlers/build.ts +7 -2
  39. package/src/cli/handlers/run.test.ts +31 -0
  40. package/src/cli/handlers/run.ts +15 -0
  41. package/src/cli/main.test.ts +23 -0
  42. package/src/cli/main.ts +27 -3
  43. package/src/cli/plugins.ts +10 -2
  44. package/src/config-import.ts +43 -0
  45. package/src/config-sandbox.ts +138 -0
  46. package/src/config.ts +37 -5
  47. package/src/discovery/entity-wire-codec.ts +21 -12
  48. package/src/discovery/entity-wire.test.ts +26 -0
  49. package/src/discovery/sandbox/config-boundary.test.ts +239 -0
  50. package/src/discovery/sandbox/config-run.ts +130 -0
  51. package/src/discovery/sandbox/config-wire.test.ts +110 -0
  52. package/src/discovery/sandbox/config-wire.ts +174 -0
  53. package/src/discovery/sandbox/driver.ts +68 -0
  54. package/src/discovery/sandbox/fork.ts +110 -0
  55. package/src/discovery/sandbox/run.ts +28 -85
  56. package/src/lexicon-output.test.ts +137 -1
  57. package/src/lexicon-output.ts +112 -13
  58. package/src/lint/config.test.ts +9 -4
  59. package/src/lint/config.ts +15 -27
  60. package/src/lint/policy.ts +5 -5
  61. package/src/project-root.test.ts +105 -0
  62. package/src/project-root.ts +78 -0
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import type { Severity, RuleConfig } from "./rule.js";
3
+ export { findProjectRoot } from "../project-root.js";
3
4
  export declare const LintConfigSchema: z.ZodObject<{
4
5
  rules: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodEnum<{
5
6
  error: "error";
@@ -75,18 +76,6 @@ export declare function parseRuleConfig(value: RuleConfig): ParsedRuleConfig;
75
76
  * Default configuration with all rules enabled at strict preset severities
76
77
  */
77
78
  export declare const DEFAULT_CONFIG: LintConfig;
78
- /**
79
- * Walk up from `startDir` to the nearest ancestor holding a chant project
80
- * config (`chant.config.ts` or `chant.config.json`). Returns that directory, or
81
- * `startDir` unchanged when none is found before the filesystem root.
82
- *
83
- * Linting a subpath (`chant graph src --format ir`, `chant lint src/lib`) must
84
- * still see the project-root config: its `lint.overrides` globs are written
85
- * project-root-relative (`src/lib/**`), and a rule set scoped only to the lint
86
- * arg would silently drop them. Config discovery therefore anchors on the
87
- * project root, not the path being linted.
88
- */
89
- export declare function findProjectRoot(startDir: string): string;
90
79
  /**
91
80
  * Load lint configuration from a directory.
92
81
  *
@@ -94,6 +83,12 @@ export declare function findProjectRoot(startDir: string): string;
94
83
  * then falls back to `chant.config.json` (legacy LintConfig format).
95
84
  * Returns default configuration if neither exists.
96
85
  *
86
+ * chant #1113 — the `chant.config.ts` branch executes project-authored code,
87
+ * so it goes through `../config-sandbox.ts` like every other config load
88
+ * rather than `require`-ing the file itself. Unarmed (which is every `chant
89
+ * lint` invocation today — `lint` has no `--sandbox` flag) that is the
90
+ * identical `createRequire` path this used before, moved one module over.
91
+ *
97
92
  * @param dir - Directory path to search for config file
98
93
  * @returns Loaded and merged configuration, or default config if not found
99
94
  */
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lint/config.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AAmBnD,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAS3B,CAAC;AAkGH;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,wCAAwC;IACxC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,uCAAuC;IACvC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;CACnC;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,mFAAmF;IACnF,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACnC,gDAAgD;IAChD,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,gDAAgD;IAChD,SAAS,CAAC,EAAE,YAAY,EAAE,CAAC;IAC3B,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,KAAK,GAAG,QAAQ,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,UAAU,GAAG,gBAAgB,CAsBnE;AAED;;GAEG;AACH,eAAO,MAAM,cAAc,EAAE,UAG5B,CAAC;AA0HF;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAUxD;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CAoClD;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAkBpG"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lint/config.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AASnD,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAiBlD,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAS3B,CAAC;AAkGH;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,wCAAwC;IACxC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,uCAAuC;IACvC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;CACnC;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,mFAAmF;IACnF,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IACnC,gDAAgD;IAChD,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,gDAAgD;IAChD,SAAS,CAAC,EAAE,YAAY,EAAE,CAAC;IAC3B,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,KAAK,GAAG,QAAQ,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,UAAU,GAAG,gBAAgB,CAsBnE;AAED;;GAEG;AACH,eAAO,MAAM,cAAc,EAAE,UAG5B,CAAC;AA0HF;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CAkClD;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAkBpG"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Shared upward config-discovery walk (chant #1117).
3
+ *
4
+ * Before this, `chant build <subdir>` and `lint.policies`
5
+ * (`./lint/policy.ts`'s `evaluateProjectPolicies`) each searched only the
6
+ * build directory and its immediate parent for `chant.config.ts`/`.json`,
7
+ * while `chant lint`/`chant graph` (`./lint/config.ts`'s old, file-local
8
+ * `findProjectRoot`) already walked all the way up. A project with a deeper
9
+ * `src/<stack>` layout — `chant build src/<stack>` two or more levels below
10
+ * the project root — silently never found the root config: `buildParams`'
11
+ * declared `env:` mappings went inert, `ownership`/`lint.policies`/etc quietly
12
+ * fell back to defaults, and nothing warned (loomster#162: `LOOM_TIER`/
13
+ * `LOOM_ENV` inert under every `npm run synth:*` for two releases).
14
+ *
15
+ * `findProjectConfig` is the one walk every config-discovery call site now
16
+ * shares. It stops at the first of:
17
+ *
18
+ * 1. A directory holding `chant.config.ts` or `chant.config.json` — found.
19
+ * 2. A directory holding `.git` or `package.json` with no chant config of its
20
+ * own — the project boundary. Discovery must never wander past the actual
21
+ * project into an unrelated ancestor directory just because this project
22
+ * happens not to declare a config (a stray `chant.config.ts` two levels
23
+ * above an unrelated git repo must never be picked up).
24
+ * 3. `startDir` itself, unchanged, if the walk reaches the filesystem root
25
+ * without ever finding a config OR a boundary marker. This is not just a
26
+ * "give up gracefully" nicety — several callers (`resolveProjectLexicons`
27
+ * -> `findInfraFiles`) scope a real directory walk off this function's
28
+ * result; if a rootless/marker-less start dir (a bare tmpdir, as chant's
29
+ * own test suites use) resolved all the way to `/`, that downstream walk
30
+ * would scan the entire filesystem instead of failing fast. Falling back
31
+ * to `startDir` keeps every caller's blast radius local no matter how far
32
+ * up the walk had to look.
33
+ */
34
+ export interface ProjectConfigSearch {
35
+ /** The resolved project root: the config's directory, the boundary directory, or the (resolved) `startDir` when neither was found. */
36
+ dir: string;
37
+ /** Absolute path to the discovered `chant.config.ts`/`.json`, if any. */
38
+ configPath?: string;
39
+ }
40
+ /** Walk up from `startDir` (inclusive) to the nearest chant config or project boundary. See {@link ProjectConfigSearch}. */
41
+ export declare function findProjectConfig(startDir: string): ProjectConfigSearch;
42
+ /**
43
+ * Walk up from `startDir` to the nearest ancestor holding a chant project
44
+ * config (`chant.config.ts` or `chant.config.json`), the `.git`/`package.json`
45
+ * project boundary, or `startDir` itself when neither is found. Thin wrapper
46
+ * over {@link findProjectConfig} for callers that only need the directory
47
+ * (e.g. `chant lint`'s rule/plugin resolution, which just needs *a* stable
48
+ * root to resolve relative paths against).
49
+ */
50
+ export declare function findProjectRoot(startDir: string): string;
51
+ //# sourceMappingURL=project-root.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"project-root.d.ts","sourceRoot":"","sources":["../src/project-root.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,MAAM,WAAW,mBAAmB;IAClC,sIAAsI;IACtI,GAAG,EAAE,MAAM,CAAC;IACZ,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,4HAA4H;AAC5H,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,mBAAmB,CAqBvE;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAExD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
package/src/build.test.ts CHANGED
@@ -467,6 +467,25 @@ describe("detectCrossLexiconRefs", () => {
467
467
  expect(collected[0].getOutputValue()).toEqual({ "Fn::Sub": "http://example.com/path" });
468
468
  });
469
469
 
470
+ // chant #1121 — `output(<already-resolved value>, name)`, exactly the
471
+ // shape of a top-level `export const oParamName = output(data.Name,
472
+ // "oParamName")`, must reach the serializer as a plain `Value`, never a
473
+ // `Fn::GetAtt` pointing at the output's own logical id.
474
+ test("literal-valued output() emits its value verbatim, not a self-referencing Fn::GetAtt", () => {
475
+ const literalOutput = output("fold-output-repro", "oParamName");
476
+
477
+ const entities = new Map<string, Declarable>([
478
+ ["oParamName", literalOutput as unknown as Declarable],
479
+ ]);
480
+
481
+ const collected = collectLexiconOutputs(entities);
482
+ expect(collected).toHaveLength(1);
483
+ expect(collected[0].outputName).toBe("oParamName");
484
+ expect(collected[0].sourceEntity).toBe("");
485
+ expect(collected[0].sourceAttribute).toBeNull();
486
+ expect(collected[0].getOutputValue()).toBe("fold-output-repro");
487
+ });
488
+
470
489
  test("deduplicates when same cross-lexicon ref appears in multiple entities", () => {
471
490
  const alphaBucket = {
472
491
  lexicon: "alpha",
package/src/build.ts CHANGED
@@ -296,18 +296,28 @@ export function collectLexiconOutputs(
296
296
  for (const [name, entity] of entities) {
297
297
  if (isLexiconOutput(entity as unknown)) {
298
298
  const lexiconOutput = entity as unknown as LexiconOutput;
299
- // Resolve source entity name from the WeakRef parent identity
300
- const parent = lexiconOutput._sourceParent?.deref();
301
- let sourceName = name;
302
- if (parent) {
303
- for (const [entityName, e] of entities) {
304
- if (e === parent) {
305
- sourceName = entityName;
306
- break;
299
+ // A literal-valued output (chant #1121) has no source entity at all —
300
+ // it isn't a reference to anything, so it must not fall back to the
301
+ // output's OWN map key the way an AttrRef whose parent didn't resolve
302
+ // would. Leaving `sourceEntity` empty keeps `getOutputValue()`'s
303
+ // (never-reached, for a literal) `Fn::GetAtt` fallback from ever being
304
+ // handed a bogus source, and keeps consumers that read `sourceEntity`
305
+ // directly (e.g. `graph-ir.ts`'s cross-stack export, the build
306
+ // manifest) from reporting the output as its own source.
307
+ if (lexiconOutput._literalValue === null) {
308
+ // Resolve source entity name from the WeakRef parent identity
309
+ const parent = lexiconOutput._sourceParent?.deref();
310
+ let sourceName = name;
311
+ if (parent) {
312
+ for (const [entityName, e] of entities) {
313
+ if (e === parent) {
314
+ sourceName = entityName;
315
+ break;
316
+ }
307
317
  }
308
318
  }
319
+ lexiconOutput._setSourceEntity(sourceName);
309
320
  }
310
- lexiconOutput._setSourceEntity(sourceName);
311
321
  outputs.push(lexiconOutput);
312
322
  continue;
313
323
  }
@@ -317,7 +327,8 @@ export function collectLexiconOutputs(
317
327
  const prevLength = outputs.length;
318
328
  walk(entity.props);
319
329
  for (let i = prevLength; i < outputs.length; i++) {
320
- if (!outputs[i].sourceEntity) {
330
+ // Same #1121 guard as above — a literal has no source entity to name.
331
+ if (!outputs[i].sourceEntity && outputs[i]._literalValue === null) {
321
332
  outputs[i]._setSourceEntity(name);
322
333
  }
323
334
  }
@@ -1,5 +1,5 @@
1
1
  import { build } from "../../build";
2
- import { loadChantConfig, resolveOwnershipMarker, resolveFoldEnabled, resolveSandboxEnabled } from "../../config";
2
+ import { loadChantConfigUpward, resolveOwnershipMarker, resolveFoldEnabled, resolveSandboxEnabled } from "../../config";
3
3
  import { resolveCliBuildParams } from "../build-params-cli";
4
4
  import type { Serializer, SerializerResult } from "../../serializer";
5
5
  import type { LexiconPlugin } from "../../lexicon";
@@ -128,11 +128,14 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
128
128
  // Resolve the path
129
129
  const infraPath = resolve(options.path);
130
130
 
131
- // Resolve opt-in ownership marking from project config (search the infra dir
132
- // and its parent, where chant.config.* usually lives).
133
- const loaded = await loadChantConfig(infraPath).then((r) =>
134
- r.configPath ? r : loadChantConfig(dirname(infraPath)),
135
- );
131
+ // Resolve opt-in ownership marking from project config. chant #1117 walks
132
+ // up from the infra dir to the project root (`loadChantConfigUpward`), not
133
+ // just the infra dir's immediate parent: a project whose stacks live two or
134
+ // more levels below `chant.config.ts` (loomster's `src/<stack>` layout)
135
+ // otherwise never finds the root config at all, and every declared
136
+ // `buildParams`/`ownership`/`lint.policies` setting silently falls back to
137
+ // its default.
138
+ const loaded = await loadChantConfigUpward(infraPath);
136
139
  const config = loaded.config;
137
140
  const ownership = resolveOwnershipMarker(config);
138
141
 
@@ -155,6 +158,19 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
155
158
  // as fold, resolved independently.
156
159
  const sandbox = resolveSandboxEnabled(config, options.sandbox);
157
160
 
161
+ // #1113 — the bootstrap limit, surfaced rather than left implicit. Reading
162
+ // `build.sandbox` out of `chant.config.ts` requires evaluating that file, so
163
+ // a config-only opt-in cannot have covered its own evaluation; only the CLI
164
+ // flag, known before any config is touched, arms `../config-sandbox.ts`.
165
+ // Say so instead of letting two different boundaries share one word.
166
+ if (sandbox && !options.sandbox && loaded.configPath?.endsWith(".ts")) {
167
+ warnings.push(
168
+ formatWarning({
169
+ message: `build.sandbox is enabled by ${loaded.configPath}, but that file was itself evaluated in this process — reading the setting requires running the config. Pass --sandbox on the command line to evaluate chant.config.ts inside the boundary too.`,
170
+ }),
171
+ );
172
+ }
173
+
158
174
  // #1064 (factored into ../build-params-cli.ts's resolveCliBuildParams by
159
175
  // #1108, so the component deploy driver runs the identical sequence) —
160
176
  // resolve declared build-time parameters (chant.config.ts's buildParams)
@@ -174,6 +190,26 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
174
190
  return { success: false, resourceCount: 0, fileCount: 0, errors, warnings };
175
191
  }
176
192
 
193
+ // chant #1117 — a project that declares buildParams but resolves NONE of
194
+ // them for this build is the exact shape that let loomster#162 live for two
195
+ // releases: the discovered config wasn't the one the project author
196
+ // expected (a stale --path, a workspace boundary that stopped the walk
197
+ // short), or every declared parameter's `env:` var went unset, and either
198
+ // way every `params.<name>` read silently falls back to `undefined` — with
199
+ // no error (an all-`required: false` declaration resolves successfully to
200
+ // an empty set). Warn, naming the config path this build actually
201
+ // discovered, so a mismatch is visible instead of silent.
202
+ if (
203
+ Object.keys(config.buildParams ?? {}).length > 0 &&
204
+ paramsResolution.provenance.length === 0
205
+ ) {
206
+ warnings.push(
207
+ formatWarning({
208
+ message: `chant.config.ts declares buildParams${loaded.configPath ? ` (${loaded.configPath})` : ""}, but none resolved for this build — every params.<name> read will be undefined. Pass --param/--params-file, or check that this is the config you expect.`,
209
+ }),
210
+ );
211
+ }
212
+
177
213
  // #1039 — thread each loaded plugin's registered intrinsics (e.g. AWS's
178
214
  // `Sub`) through to the fold path, so a file using a registered intrinsic
179
215
  // tagged template folds instead of unconditionally falling back to run.
@@ -8,8 +8,10 @@
8
8
  * run --components` (see ../handlers/run.test.ts's "build-time parameters"
9
9
  * describe block for the equivalent local/`--temporal` coverage).
10
10
  *
11
- * Mocks `generateComponentsPipeline` and `loadChantConfig` and exercises the
12
- * public `runBuild` dispatcher (`runGenerateComponents` itself isn't
11
+ * Mocks `generateComponentsPipeline` and `loadChantConfigUpward` (chant
12
+ * #1117 `runGenerateComponents` walks up to the project root now, same as
13
+ * `chant build` proper, instead of reading `args.path` alone) and exercises
14
+ * the public `runBuild` dispatcher (`runGenerateComponents` itself isn't
13
15
  * exported), mirroring `run.test.ts`'s style of driving the handler through
14
16
  * its `CommandContext` entrypoint rather than reaching into private helpers.
15
17
  */
@@ -17,14 +19,14 @@ import { describe, test, expect, vi, beforeEach } from "vitest";
17
19
  import type { ParsedArgs } from "../registry";
18
20
 
19
21
  const generateComponentsPipelineMock = vi.fn();
20
- const loadChantConfigMock = vi.fn();
22
+ const loadChantConfigUpwardMock = vi.fn();
21
23
 
22
24
  vi.mock("../../components/cli-support", () => ({
23
25
  generateComponentsPipeline: (...args: unknown[]) => generateComponentsPipelineMock(...args),
24
26
  }));
25
27
  vi.mock("../../config", async () => {
26
28
  const actual = await vi.importActual<typeof import("../../config")>("../../config");
27
- return { ...actual, loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args) };
29
+ return { ...actual, loadChantConfigUpward: (...args: unknown[]) => loadChantConfigUpwardMock(...args) };
28
30
  });
29
31
 
30
32
  const { runBuild } = await import("./build");
@@ -54,7 +56,7 @@ function makeStderrSpy() {
54
56
  describe("runBuild --components --generate (chant #1108 build-time parameters)", () => {
55
57
  beforeEach(() => {
56
58
  generateComponentsPipelineMock.mockReset();
57
- loadChantConfigMock.mockReset().mockResolvedValue({ config: {} });
59
+ loadChantConfigUpwardMock.mockReset().mockResolvedValue({ config: {} });
58
60
  });
59
61
 
60
62
  test("no declared buildParams → generateComponentsPipeline is called with an empty provenance array", async () => {
@@ -69,7 +71,7 @@ describe("runBuild --components --generate (chant #1108 build-time parameters)",
69
71
  });
70
72
 
71
73
  test("chant.config.ts's declared buildParams resolve, log, and are forwarded to generateComponentsPipeline", async () => {
72
- loadChantConfigMock.mockResolvedValue({
74
+ loadChantConfigUpwardMock.mockResolvedValue({
73
75
  config: { buildParams: { tier: { type: "string", default: "light" } } },
74
76
  });
75
77
  generateComponentsPipelineMock.mockResolvedValue({ success: true, yaml: "stages: []", stages: [], jobs: [] });
@@ -91,7 +93,7 @@ describe("runBuild --components --generate (chant #1108 build-time parameters)",
91
93
  });
92
94
 
93
95
  test("--param overrides a declared default", async () => {
94
- loadChantConfigMock.mockResolvedValue({
96
+ loadChantConfigUpwardMock.mockResolvedValue({
95
97
  config: { buildParams: { tier: { type: "string", default: "light" } } },
96
98
  });
97
99
  generateComponentsPipelineMock.mockResolvedValue({ success: true, yaml: "stages: []", stages: [], jobs: [] });
@@ -115,7 +117,7 @@ describe("runBuild --components --generate (chant #1108 build-time parameters)",
115
117
  });
116
118
 
117
119
  test("an unresolved required build-time parameter → exit 1, never reaches generateComponentsPipeline", async () => {
118
- loadChantConfigMock.mockResolvedValue({
120
+ loadChantConfigUpwardMock.mockResolvedValue({
119
121
  config: { buildParams: { tier: { type: "string" } } },
120
122
  });
121
123
  const stderr = makeStderrSpy();
@@ -128,7 +130,7 @@ describe("runBuild --components --generate (chant #1108 build-time parameters)",
128
130
  });
129
131
 
130
132
  test("an enum violation on --param → exit 1, never reaches generateComponentsPipeline", async () => {
131
- loadChantConfigMock.mockResolvedValue({
133
+ loadChantConfigUpwardMock.mockResolvedValue({
132
134
  config: { buildParams: { tier: { type: "string", enum: ["light", "production"] } } },
133
135
  });
134
136
  const stderr = makeStderrSpy();
@@ -4,7 +4,7 @@ import { buildCommand, buildCommandWatch, printErrors, printWarnings, resolveBui
4
4
  import { formatError, formatInfo, formatSuccess, formatBold } from "../format";
5
5
  import type { CommandContext } from "../registry";
6
6
  import { generateComponentsPipeline } from "../../components/cli-support";
7
- import { loadChantConfig, type ChantConfig } from "../../config";
7
+ import { loadChantConfigUpward, type ChantConfig } from "../../config";
8
8
  import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
9
9
 
10
10
  /**
@@ -23,12 +23,17 @@ import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
23
23
  * Before this, `params.*` (`@intentius/chant/params`) was always `{}` under
24
24
  * this command too — generate mode shares `discoverComponents` with `chant
25
25
  * run --components`, so it had the identical gap.
26
+ *
27
+ * chant #1117 — loads config by walking up from `args.path` to the project
28
+ * root (`loadChantConfigUpward`), same as `chant build` proper, instead of
29
+ * `args.path` alone: a components-only project built from a subdirectory
30
+ * otherwise never sees the root `chant.config.ts`'s `buildParams` either.
26
31
  */
27
32
  async function runGenerateComponents(ctx: CommandContext): Promise<number> {
28
33
  const { args } = ctx;
29
34
  const lexicon = args.generate as string;
30
35
 
31
- const { config } = await loadChantConfig(resolve(args.path)).catch(() => ({ config: {} as ChantConfig }));
36
+ const { config } = await loadChantConfigUpward(resolve(args.path)).catch(() => ({ config: {} as ChantConfig }));
32
37
  const paramsResolution = resolveCliBuildParams(config.buildParams, {
33
38
  cli: parseParamFlags(args.param),
34
39
  paramsFile: args.paramsFile,
@@ -882,6 +882,37 @@ describe("runOp dispatcher: --components routes to runOpComponents", () => {
882
882
  expect(discoverOpsMock).not.toHaveBeenCalled();
883
883
  vi.restoreAllMocks();
884
884
  });
885
+
886
+ // chant #1116 — --report is Op/Temporal-only (reads a past workflow run);
887
+ // the component driver never checked it, so it was silently ignored and the
888
+ // command fell through to a real dispatch. Hard-error instead, before
889
+ // runComponents is ever reached.
890
+ test("--report combined with --components → exit 1 before any dispatch, no fall-through (#1116)", async () => {
891
+ discoverOpsMock.mockReset();
892
+ const stderr = makeStderrSpy();
893
+
894
+ const exit = await runOp({ args: makeArgs({ path: "svc", components: true, report: true, temporal: false }), plugins: [], serializers: [] });
895
+
896
+ expect(exit).toBe(1);
897
+ expect(stderr.join("\n")).toContain("not supported with --components");
898
+ expect(stderr.join("\n")).toContain("#1116");
899
+ expect(discoverOpsMock).not.toHaveBeenCalled();
900
+ expect(runComponentsMock).not.toHaveBeenCalled();
901
+ });
902
+
903
+ // Plain --components (no --report) must be unaffected: it still reaches a
904
+ // real dispatch through runComponents — mocked here, never a real cloud call.
905
+ test("plain --components (no --report) still dispatches to runComponents (#1116 regression guard)", async () => {
906
+ discoverOpsMock.mockReset();
907
+ runComponentsMock.mockResolvedValue({ success: true, selected: ["svc"], run: { order: ["svc"], waves: [["svc"]], results: [{ component: "svc", ok: true, records: [] }], ok: true } });
908
+ vi.spyOn(process.stderr, "write").mockImplementation(() => true);
909
+
910
+ const exit = await runOp({ args: makeArgs({ path: "svc", components: true, report: false, temporal: false }), plugins: [], serializers: [] });
911
+
912
+ expect(exit).toBe(0);
913
+ expect(runComponentsMock).toHaveBeenCalled();
914
+ vi.restoreAllMocks();
915
+ });
885
916
  });
886
917
 
887
918
  describe("runOpComponents", () => {
@@ -548,8 +548,23 @@ function renderProgress(opName: string, history: WorkflowHistoryRaw): void {
548
548
  * (`../../components/driver.ts`) rather than a `*.op.ts` Op. Checked first,
549
549
  * mirroring `runGraph`'s `if (ctx.args.components) return
550
550
  * runComponentGraph(ctx)` branch (../handlers/graph.ts).
551
+ *
552
+ * chant #1116 — `--report` with `--components` is checked and hard-errored
553
+ * before that dispatch. There is no preview/dry-run mode for the component
554
+ * driver: unlike the Op path (where `--report` reads a past Temporal run),
555
+ * `runOpComponents` has never read `ctx.args.report` at all, so the flag was
556
+ * silently ignored and the command fell through to a real dispatch — observed
557
+ * live reaching an actual cloud shell-out. Erroring here is the safe minimum
558
+ * called out on the issue; a real preview is future work.
551
559
  */
552
560
  export async function runOp(ctx: CommandContext): Promise<number> {
561
+ if (ctx.args.components && ctx.args.report) {
562
+ console.error(formatError({
563
+ message: "--report is not supported with --components",
564
+ hint: "No preview/dry-run mode exists yet for the component driver (see chant#1116). Omit --report.",
565
+ }));
566
+ return 1;
567
+ }
553
568
  if (ctx.args.components) return runOpComponents(ctx);
554
569
  if (ctx.args.local && ctx.args.temporal) {
555
570
  console.error(formatError({
@@ -270,6 +270,29 @@ describe("parseArgs", () => {
270
270
  const result = parseArgs(["build", "src", "--params-file", "./params.json"]);
271
271
  expect(result.paramsFile).toBe("./params.json");
272
272
  });
273
+
274
+ // ── --param=name=value hard error (chant #1118) ──────────────────────────
275
+ // The joined `--flag=value` form is not supported anywhere in this parser
276
+ // (see "ignores unknown flags" above) — a dropped --param can silently
277
+ // change what a build measures/deploys, so this form is rejected loudly
278
+ // instead of silently accepted as a no-op.
279
+
280
+ test("--param=name=value throws instead of silently dropping", () => {
281
+ expect(() => parseArgs(["build", "src", "--param=tier=production"])).toThrow(/--param=tier=production/);
282
+ });
283
+
284
+ test("--param=name=value error names the working form", () => {
285
+ expect(() => parseArgs(["build", "src", "--param=tier=production"])).toThrow(/--param name=value/);
286
+ });
287
+
288
+ test("--param= (empty value) also throws", () => {
289
+ expect(() => parseArgs(["build", "src", "--param="])).toThrow(/--param=/);
290
+ });
291
+
292
+ test("plain --param name=value is unaffected", () => {
293
+ const result = parseArgs(["build", "src", "--param", "tier=production"]);
294
+ expect(result.param).toEqual(["tier=production"]);
295
+ });
273
296
  });
274
297
 
275
298
  // ── resolveCommand tests ──────────────────────────────────────────
package/src/cli/main.ts CHANGED
@@ -5,7 +5,8 @@ import { isEntryPoint } from "./is-entry-point";
5
5
  import { formatSuccess, formatError } from "./format";
6
6
  import { loadPlugins, resolveProjectLexicons } from "./plugins";
7
7
  import { resolveCommand, type CommandDef, type ParsedArgs } from "./registry";
8
- import { loadChantConfig } from "../config";
8
+ import { loadChantConfigUpward } from "../config";
9
+ import { armSandboxConfigEvaluation } from "../config-sandbox";
9
10
  import { ENV_VAR, unknownEnvError } from "../env";
10
11
  import { initRuntime } from "../runtime-adapter";
11
12
  import { runBuild } from "./handlers/build";
@@ -224,6 +225,16 @@ export function parseArgs(args: string[]): ParsedArgs {
224
225
  result.sandbox = true;
225
226
  } else if (arg === "--param") {
226
227
  (result.param ??= []).push(args[++i]);
228
+ } else if (arg.startsWith("--param=")) {
229
+ // chant #1118 — this parser never supports an `--flag=value` joined
230
+ // form for any value-taking flag (every branch above is an exact `===`
231
+ // match, so a joined token falls through unrecognized and is silently
232
+ // dropped — see the "ignores unknown flags" case below). `--param
233
+ // name=value` (space-separated) is the only accepted form. Rather than
234
+ // teach the parser joined forms generally, `--param=name=value` is
235
+ // called out as a hard error instead of a silent no-op: a dropped
236
+ // `--param` can silently change what a build measures/deploys.
237
+ throw new Error(`${arg} is not supported. Use --param name=value (space-separated) instead.`);
227
238
  } else if (arg === "--params-file") {
228
239
  result.paramsFile = args[++i];
229
240
  } else if (!arg.startsWith("-")) {
@@ -588,11 +599,24 @@ async function main(): Promise<void> {
588
599
  // chant.config itself may branch on the env. (#505)
589
600
  if (args.env) process.env[ENV_VAR] = args.env;
590
601
 
591
- // Initialize runtime adapter early before plugins or commands run
602
+ // chant #1113 `--sandbox` is a property of the whole invocation, and it
603
+ // has to be known BEFORE the first config load, because `chant.config.ts` is
604
+ // itself project-authored code. Arming here, straight off the parsed flag,
605
+ // is the only ordering that works: the project's own `build.sandbox: true`
606
+ // cannot cover its own evaluation (reading it means running it), so the
607
+ // command-line flag is what puts the config inside the boundary. See
608
+ // `../config-sandbox.ts`.
609
+ if (args.sandbox) armSandboxConfigEvaluation();
610
+
611
+ // Initialize runtime adapter early — before plugins or commands run.
612
+ // chant #1117 — walks up from `args.path` to the project root: for a
613
+ // subdirectory build/command (`chant build src/<stack> --env prod`) the
614
+ // declared `environments` almost always live in the root `chant.config.ts`,
615
+ // not `args.path` itself.
592
616
  const projectPath0 = resolve(args.path === "." ? "." : args.path);
593
617
  let loadedConfig;
594
618
  try {
595
- loadedConfig = await loadChantConfig(projectPath0);
619
+ loadedConfig = await loadChantConfigUpward(projectPath0);
596
620
  initRuntime();
597
621
  } catch {
598
622
  // Config may not exist yet (e.g. `chant init`)
@@ -1,5 +1,5 @@
1
1
  import { isLexiconPlugin, type LexiconPlugin } from "../lexicon";
2
- import { loadChantConfig } from "../config";
2
+ import { loadChantConfigUpward } from "../config";
3
3
  import { findInfraFiles, detectLexicons } from "../index";
4
4
  import { checkConflicts } from "./conflict-check";
5
5
 
@@ -96,9 +96,17 @@ export async function loadPlugins(lexiconNames: string[]): Promise<LexiconPlugin
96
96
  * that never needed live module exports to begin with. This is strictly
97
97
  * better than routing the detection through the sandbox: it removes the
98
98
  * execution entirely rather than containing it, at no bundling/spawn cost.
99
+ *
100
+ * chant #1117 — walks up from `projectPath` to the project root
101
+ * (`loadChantConfigUpward`) rather than reading `projectPath` alone: `chant
102
+ * build src/<stack>` calls this with the stack's own subdirectory, and a
103
+ * project that declares `lexicons` only in its root `chant.config.ts` (never
104
+ * detectable by `detectLexicons()`'s import scan alone, e.g. a lexicon that's
105
+ * loaded but never imported by name in that particular stack's files) would
106
+ * otherwise silently miss it.
99
107
  */
100
108
  export async function resolveProjectLexicons(projectPath: string): Promise<string[]> {
101
- const { config } = await loadChantConfig(projectPath);
109
+ const { config } = await loadChantConfigUpward(projectPath);
102
110
 
103
111
  if (config.lexicons && config.lexicons.length > 0) {
104
112
  return config.lexicons;
@@ -0,0 +1,43 @@
1
+ import { createRequire } from "node:module";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * The single place chant evaluates a project's `chant.config.ts` **in the CLI's
6
+ * own process**.
7
+ *
8
+ * This is the config-side analogue of `./discovery/import.ts`'s `importModule`
9
+ * — one narrow module whose only job is "execute project-authored code here",
10
+ * so that the question "did any project code run in this process?" has one
11
+ * place to look and one place to instrument (see
12
+ * `examples/sandbox-execution-boundary.test.ts`, which spies on both).
13
+ *
14
+ * Callers must not `import()` a config file directly. `./config-sandbox.ts`
15
+ * decides whether a given load is allowed to come through here at all: under
16
+ * `chant build --sandbox` it routes the evaluation into the sandboxed child
17
+ * instead (chant #1113), and these functions are never reached.
18
+ *
19
+ * `chant.config.json` is pure data and is parsed, not executed — it never goes
20
+ * through this module.
21
+ */
22
+
23
+ /** The shape a config module evaluates to, before `default`/`config`/namespace selection. */
24
+ export type ConfigModuleNamespace = Record<string, unknown>;
25
+
26
+ /**
27
+ * Import a `chant.config.ts` into THIS process and return its module namespace.
28
+ * Node's ESM registry caches it, so repeated loads within one CLI invocation
29
+ * evaluate the file once — the behavior `loadChantConfig` has always had.
30
+ */
31
+ export async function importConfigModule(configPath: string): Promise<ConfigModuleNamespace> {
32
+ return (await import(configPath)) as ConfigModuleNamespace;
33
+ }
34
+
35
+ /**
36
+ * `require()` a `chant.config.ts` into THIS process from `dir`'s resolution
37
+ * context — the synchronous path `./lint/config.ts`'s `loadConfig` has used
38
+ * since before the async loader existed (`chant lint` is a sync pipeline).
39
+ */
40
+ export function requireConfigModule(configPath: string, dir: string): ConfigModuleNamespace {
41
+ const req = createRequire(join(dir, "package.json"));
42
+ return req(configPath) as ConfigModuleNamespace;
43
+ }