@intentius/chant 0.70.1 → 0.71.1

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 (84) hide show
  1. package/dist/cli/handlers/fan-out.d.ts +45 -0
  2. package/dist/cli/handlers/fan-out.d.ts.map +1 -0
  3. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/handlers/operator.d.ts.map +1 -1
  6. package/dist/cli/handlers/run.d.ts +35 -0
  7. package/dist/cli/handlers/run.d.ts.map +1 -1
  8. package/dist/cli/main.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +23 -0
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/deploy-units.d.ts +12 -2
  12. package/dist/components/deploy-units.d.ts.map +1 -1
  13. package/dist/components/fan-out-output.d.ts +70 -0
  14. package/dist/components/fan-out-output.d.ts.map +1 -0
  15. package/dist/components/fan-out-run.d.ts +80 -0
  16. package/dist/components/fan-out-run.d.ts.map +1 -0
  17. package/dist/components/fan-out-support.d.ts +65 -0
  18. package/dist/components/fan-out-support.d.ts.map +1 -0
  19. package/dist/components/fan-out.d.ts +194 -0
  20. package/dist/components/fan-out.d.ts.map +1 -0
  21. package/dist/components/index.d.ts +4 -0
  22. package/dist/components/index.d.ts.map +1 -1
  23. package/dist/discovery/fold-import.d.ts +36 -1
  24. package/dist/discovery/fold-import.d.ts.map +1 -1
  25. package/dist/fold/subset.d.ts +22 -0
  26. package/dist/fold/subset.d.ts.map +1 -1
  27. package/dist/index.d.ts +2 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/lifecycle/affected.d.ts +26 -0
  30. package/dist/lifecycle/affected.d.ts.map +1 -1
  31. package/dist/op/activities/activity-contracts.d.ts +16 -1
  32. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  33. package/dist/op/activities/index.d.ts +1 -1
  34. package/dist/op/activities/index.d.ts.map +1 -1
  35. package/dist/op/activities/shell.d.ts +31 -2
  36. package/dist/op/activities/shell.d.ts.map +1 -1
  37. package/dist/op/activity-contract.d.ts +1 -1
  38. package/dist/op/activity-contract.d.ts.map +1 -1
  39. package/dist/op/activity-profiles.d.ts +19 -0
  40. package/dist/op/activity-profiles.d.ts.map +1 -1
  41. package/dist/op/builders.d.ts +21 -3
  42. package/dist/op/builders.d.ts.map +1 -1
  43. package/dist/op/gate-name.d.ts +10 -0
  44. package/dist/op/gate-name.d.ts.map +1 -1
  45. package/dist/op/step-output-ref.d.ts +25 -8
  46. package/dist/op/step-output-ref.d.ts.map +1 -1
  47. package/package.json +2 -1
  48. package/src/cli/handlers/fan-out.test.ts +394 -0
  49. package/src/cli/handlers/fan-out.ts +336 -0
  50. package/src/cli/handlers/lifecycle.test.ts +74 -1
  51. package/src/cli/handlers/lifecycle.ts +29 -1
  52. package/src/cli/handlers/operator.test.ts +22 -0
  53. package/src/cli/handlers/operator.ts +11 -3
  54. package/src/cli/handlers/run.ts +7 -1
  55. package/src/cli/main.ts +25 -3
  56. package/src/cli/registry.ts +23 -0
  57. package/src/components/deploy-units.ts +14 -4
  58. package/src/components/fan-out-output.test.ts +216 -0
  59. package/src/components/fan-out-output.ts +162 -0
  60. package/src/components/fan-out-run.test.ts +194 -0
  61. package/src/components/fan-out-run.ts +221 -0
  62. package/src/components/fan-out-support.test.ts +125 -0
  63. package/src/components/fan-out-support.ts +95 -0
  64. package/src/components/fan-out.test.ts +284 -0
  65. package/src/components/fan-out.ts +421 -0
  66. package/src/components/index.ts +33 -0
  67. package/src/discovery/fold-import.ts +81 -3
  68. package/src/fold/subset-public-export.test.ts +31 -0
  69. package/src/fold/subset.ts +23 -0
  70. package/src/index.ts +6 -0
  71. package/src/lifecycle/affected.test.ts +118 -0
  72. package/src/lifecycle/affected.ts +118 -14
  73. package/src/meta/declared-imports.test.ts +141 -0
  74. package/src/op/activities/activity-contracts.ts +17 -1
  75. package/src/op/activities/index.ts +1 -1
  76. package/src/op/activities/shell.test.ts +156 -0
  77. package/src/op/activities/shell.ts +84 -9
  78. package/src/op/activity-profiles.test.ts +16 -2
  79. package/src/op/activity-profiles.ts +18 -0
  80. package/src/op/builders.ts +22 -4
  81. package/src/op/gate-name.ts +11 -0
  82. package/src/op/op-ir.test.ts +4 -1
  83. package/src/op/op.test.ts +7 -2
  84. package/src/op/step-output-ref.ts +29 -8
@@ -138,6 +138,124 @@ describe("affectedStacks — baseDir (caller-supplied)", () => {
138
138
  });
139
139
  });
140
140
 
141
+ // A second lexicon, so a stack that serializes through two of them can be shown
142
+ // folding into one artifact.
143
+ const fakeSerializer2: Serializer = {
144
+ name: "fake2",
145
+ rulePrefix: "FAKE2",
146
+ serialize: (entities) =>
147
+ JSON.stringify([...entities.keys()].sort().map((k) => ({ k, t: entities.get(k)!.entityType }))),
148
+ };
149
+
150
+ function otherWidget(type: string): string {
151
+ return `export const bar = { lexicon: "fake2", entityType: "${type}", [Symbol.for("chant.declarable")]: true };\n`;
152
+ }
153
+
154
+ function deployTimeParam(): string {
155
+ return `export const p = { lexicon: "fake", entityType: "Param", parameterType: "String", [Symbol.for("chant.declarable")]: true };\n`;
156
+ }
157
+
158
+ describe("affectedStacks — per-stack mode (#2420)", () => {
159
+ let root: string;
160
+ beforeEach(() => {
161
+ root = mkdtempSync(join(tmpdir(), "chant-affected-stacks-"));
162
+ });
163
+ afterEach(() => rmSync(root, { recursive: true, force: true }));
164
+
165
+ // A project root holding one source directory per stack, as ChantConfig.stacks
166
+ // describes it: { api: <infra.ts contents>, worker: ... }.
167
+ const project = (name: string, sources: Record<string, string>): string => {
168
+ const dir = join(root, name);
169
+ for (const [stack, content] of Object.entries(sources)) {
170
+ mkdirSync(join(dir, stack), { recursive: true });
171
+ writeFileSync(join(dir, stack, "infra.ts"), content);
172
+ }
173
+ return dir;
174
+ };
175
+
176
+ const stacks = [
177
+ { name: "api-stack", src: "api" },
178
+ { name: "worker-stack", src: "worker" },
179
+ ];
180
+
181
+ test("a stacks[] entry whose src is not there is refused by name at head", async () => {
182
+ const base = project("base", { api: widget("Widget"), worker: widget("Queue") });
183
+ const head = project("head", { api: widget("Gadget") });
184
+ await expect(
185
+ affectedStacks({ projectPath: head, baseDir: base, serializers: [fakeSerializer], stacks }),
186
+ ).rejects.toThrow(/stack "worker-stack" declares src "worker", which does not exist/);
187
+ });
188
+
189
+ test("a stack added since base is changed, rather than refused for having no base source", async () => {
190
+ const base = project("base", { api: widget("Widget") });
191
+ const head = project("head", { api: widget("Widget"), worker: widget("Queue") });
192
+ const r = await affectedStacks({ projectPath: head, baseDir: base, serializers: [fakeSerializer], stacks });
193
+ expect(r.changed).toEqual(["worker-stack"]);
194
+ });
195
+
196
+ test("names the changed stack, not its lexicon — and leaves the untouched stack out", async () => {
197
+ const base = project("base", { api: widget("Widget"), worker: widget("Queue") });
198
+ const head = project("head", { api: widget("Gadget"), worker: widget("Queue") });
199
+ const r = await affectedStacks({ projectPath: head, baseDir: base, serializers: [fakeSerializer], stacks });
200
+ expect(r.changed).toEqual(["api-stack"]);
201
+ expect(r.changed).not.toContain("fake"); // the lexicon name is not an answer
202
+ });
203
+
204
+ test("a no-output-change refactor in the changed stack's own directory is NOT affected", async () => {
205
+ const base = project("base", { api: widget("Widget"), worker: widget("Queue") });
206
+ const head = project("head", { api: "// a harmless refactor\n" + widget("Widget"), worker: widget("Queue") });
207
+ const r = await affectedStacks({ projectPath: head, baseDir: base, serializers: [fakeSerializer], stacks });
208
+ expect(r.changed).toEqual([]);
209
+ });
210
+
211
+ test("a stack spanning two lexicons folds into one artifact keyed by the stack", async () => {
212
+ const both = (type: string) => widget("Widget") + otherWidget(type);
213
+ const base = project("base", { api: both("Topic"), worker: widget("Queue") });
214
+ // Only the second lexicon's partition moves; the stack is still what changed.
215
+ const head = project("head", { api: both("Bus"), worker: widget("Queue") });
216
+ const r = await affectedStacks({
217
+ projectPath: head,
218
+ baseDir: base,
219
+ serializers: [fakeSerializer, fakeSerializer2],
220
+ stacks,
221
+ });
222
+ expect(r.changed).toEqual(["api-stack"]);
223
+ });
224
+
225
+ test("a deploy-time Parameter is reported as indeterminate under the stack name", async () => {
226
+ const base = project("base", { api: deployTimeParam(), worker: widget("Queue") });
227
+ const head = project("head", { api: deployTimeParam(), worker: widget("Queue") });
228
+ const r = await affectedStacks({ projectPath: head, baseDir: base, serializers: [fakeSerializer], stacks });
229
+ expect(r.indeterminate).toEqual(["api-stack"]);
230
+ });
231
+
232
+ test("dependents stay empty — the stack-to-stack relation is not in the build", async () => {
233
+ const base = project("base", { api: widget("Widget"), worker: widget("Queue") });
234
+ const head = project("head", { api: widget("Gadget"), worker: widget("Queue") });
235
+ const r = await affectedStacks({
236
+ projectPath: head,
237
+ baseDir: base,
238
+ serializers: [fakeSerializer],
239
+ stacks,
240
+ includeDependents: true,
241
+ });
242
+ expect(r.changed).toEqual(["api-stack"]);
243
+ expect(r.dependents).toEqual([]);
244
+ });
245
+
246
+ test("an empty stacks list keeps the single-root, lexicon-keyed answer", async () => {
247
+ const base = project("base", { api: widget("Widget") });
248
+ const head = project("head", { api: widget("Gadget") });
249
+ const r = await affectedStacks({
250
+ projectPath: join(head, "api"),
251
+ baseDir: join(base, "api"),
252
+ serializers: [fakeSerializer],
253
+ stacks: [],
254
+ });
255
+ expect(r.changed).toEqual(["fake"]);
256
+ });
257
+ });
258
+
141
259
  describe("affectedStacks — baseRef (git worktree)", () => {
142
260
  let repo: string;
143
261
  beforeEach(async () => {
@@ -15,8 +15,19 @@
15
15
  * be judged from a source diff — reported as **indeterminate**, not silently
16
16
  * included or excluded.
17
17
  *
18
- * This returns the set; it does not act. Fanning `lifecycle plan` / `ApplyOp`
19
- * over it is an Op the user composes.
18
+ * ## What "stack" means here depends on the project
19
+ *
20
+ * A project that declares `stacks` in `chant.config.ts` gets each declared
21
+ * stack's own source built, and the answer names stacks. A single-root project
22
+ * has no such partition to build, so its answer names lexicon partitions, which
23
+ * is the only granularity it has. The difference matters downstream: a
24
+ * component's deploy step names a stack, so only the first kind of answer can
25
+ * be joined to components (#2420).
26
+ *
27
+ * This returns the set; it does not act. `chant components fan-out`
28
+ * (../cli/handlers/fan-out.ts) is the command that acts on it, and fanning
29
+ * `lifecycle plan` / `ApplyOp` over it by hand is still an Op the user
30
+ * composes.
20
31
  */
21
32
  import { execFile } from "node:child_process";
22
33
  import { promisify } from "node:util";
@@ -140,10 +151,92 @@ async function withWorktree<T>(repoRoot: string, ref: string, fn: (dir: string)
140
151
  }
141
152
  }
142
153
 
154
+ /** One independently-deployed stack, as `ChantConfig.stacks` declares it. */
155
+ export interface StackSource {
156
+ /** The deployed stack name — what a component's deploy step names. */
157
+ name: string;
158
+ /** Source directory to build for this stack, relative to the project root. */
159
+ src: string;
160
+ }
161
+
162
+ /**
163
+ * Build each declared stack's own source directory and key the result by
164
+ * **stack name**.
165
+ *
166
+ * Without this, one build of the project root produces a map keyed by lexicon
167
+ * partition, so an aws-only estate of fifteen stacks answers "aws changed" no
168
+ * matter which one moved. That answer cannot be joined to anything: a
169
+ * component's deploy step names a stack, not a lexicon, so
170
+ * `componentsForUnits` (../components/fan-out.ts) would claim none of it and
171
+ * a fan-out derived from it would be empty. The two halves have to speak the
172
+ * same names for either to be worth having.
173
+ *
174
+ * A stack that serializes through more than one lexicon folds its partitions
175
+ * into one artifact, because the unit being deployed is the stack.
176
+ */
177
+ async function stackArtifacts(
178
+ root: string,
179
+ serializers: Serializer[],
180
+ stacks: readonly StackSource[],
181
+ /**
182
+ * Whether a missing `src` is an error. True for the head side, where every
183
+ * declared stack must exist; false for the base side, where a stack added
184
+ * since then legitimately has no source yet and reads as an empty artifact,
185
+ * which is what makes it `changed`.
186
+ */
187
+ requireSrc: boolean,
188
+ ): Promise<{ artifacts: Map<string, string>; externalInput: string[] }> {
189
+ const artifacts = new Map<string, string>();
190
+ const externalInput: string[] = [];
191
+ for (const stack of stacks) {
192
+ const src = resolve(root, stack.src);
193
+ // `build()` on a directory that is not there returns empty outputs rather
194
+ // than throwing, so a typo in `src` would make both sides build nothing,
195
+ // match, and report the stack as unchanged forever. A silent answer of
196
+ // exactly that shape is what this whole path exists to prevent.
197
+ if (requireSrc && !existsSync(src)) {
198
+ throw new Error(
199
+ `stack "${stack.name}" declares src "${stack.src}", which does not exist under ${root}. ` +
200
+ `Fix chant.config.ts's stacks[] entry: a source directory that is not there builds to nothing, ` +
201
+ `and a stack that builds to nothing can never be reported as changed.`,
202
+ );
203
+ }
204
+ const built = await build(src, serializers);
205
+ artifacts.set(
206
+ stack.name,
207
+ [...built.outputs.entries()]
208
+ .sort(([a], [b]) => a.localeCompare(b))
209
+ .map(([lexicon, output]) => `${lexicon}\n${getPrimaryOutput(output as string)}`)
210
+ .join("\n"),
211
+ );
212
+ if (externalInputStacks(built.entities).length > 0) externalInput.push(stack.name);
213
+ }
214
+ return { artifacts, externalInput: externalInput.sort() };
215
+ }
216
+
143
217
  export interface AffectedStacksOptions {
144
218
  /** Project source directory to scope (the head/working-tree build root). */
145
219
  projectPath: string;
146
220
  serializers: Serializer[];
221
+ /**
222
+ * The project's independently-deployed stacks (`ChantConfig.stacks`). With
223
+ * them, each stack's own source is built and the answer names stacks; without
224
+ * them, one build of `projectPath` answers by lexicon partition, which is the
225
+ * only granularity a single-root project has.
226
+ *
227
+ * `dependents` is always empty in this mode, and that is not an omission: the
228
+ * build holds cross-*lexicon* edges, and the relation between two deployed
229
+ * stacks is stated by a component's `dependsOn` and `stackOutput`, which
230
+ * `chant components fan-out` walks for itself. Inventing stack edges from
231
+ * lexicon ones would be a guess dressed as a graph.
232
+ *
233
+ * Each `src` resolves against `projectPath`, so in this mode `projectPath`
234
+ * has to be the project root that `ChantConfig.stacks` is written relative
235
+ * to. A `sourceDir` that narrows the build root does not apply here: the
236
+ * stacks already say which source belongs to which of them, which is the
237
+ * narrowing, and applying both would look for `<sourceDir>/<stack.src>`.
238
+ */
239
+ stacks?: readonly StackSource[];
147
240
  /** Base git ref to diff against (built in a throwaway worktree). */
148
241
  baseRef?: string;
149
242
  /** Head git ref. Defaults to the working tree (built in place — no worktree). */
@@ -175,28 +268,39 @@ export async function affectedStacks(opts: AffectedStacksOptions): Promise<Affec
175
268
  // Head: the in-place build of the working tree, unless an explicit headRef is
176
269
  // given (then a throwaway worktree — removed before the base worktree, so at
177
270
  // most one exists at a time).
271
+ const perStack = opts.stacks && opts.stacks.length > 0 ? opts.stacks : undefined;
272
+ // No cross-stack graph in per-stack mode, by construction: see `stacks` above.
273
+ const EMPTY_GRAPH: StackGraph = { nodes: [], edges: [], order: [], waves: [], cycles: [] };
274
+ /** One build root in, its artifact map and external-input set out, at whichever granularity applies. */
275
+ const readArtifacts = async (
276
+ root: string,
277
+ isHead: boolean,
278
+ ): Promise<{ artifacts: Map<string, string>; externalInput: string[]; graph: StackGraph }> => {
279
+ if (perStack) return { ...(await stackArtifacts(root, opts.serializers, perStack, isHead)), graph: EMPTY_GRAPH };
280
+ const built = await build(root, opts.serializers);
281
+ return {
282
+ artifacts: artifactMap(built.outputs),
283
+ externalInput: externalInputStacks(built.entities),
284
+ graph: built.manifest.stackGraph,
285
+ };
286
+ };
287
+
178
288
  const head = opts.headRef
179
- ? await withWorktree(repoRoot, opts.headRef, (dir) => build(join(dir, relProject), opts.serializers))
180
- : await build(projectPath, opts.serializers);
181
- const headMap = artifactMap(head.outputs);
182
- const externalInput = externalInputStacks(head.entities);
289
+ ? await withWorktree(repoRoot, opts.headRef, (dir) => readArtifacts(join(dir, relProject), true))
290
+ : await readArtifacts(projectPath, true);
183
291
 
184
292
  // Base: caller-supplied dir (cheapest), else a single worktree at baseRef.
185
293
  let baseMap: Map<string, string>;
186
294
  if (opts.baseDir) {
187
- const baseBuild = await build(resolve(opts.baseDir), opts.serializers);
188
- baseMap = artifactMap(baseBuild.outputs);
295
+ baseMap = (await readArtifacts(resolve(opts.baseDir), false)).artifacts;
189
296
  } else if (opts.baseRef) {
190
- baseMap = await withWorktree(repoRoot, opts.baseRef, async (dir) => {
191
- const baseBuild = await build(join(dir, relProject), opts.serializers);
192
- return artifactMap(baseBuild.outputs);
193
- });
297
+ baseMap = await withWorktree(repoRoot, opts.baseRef, async (dir) => (await readArtifacts(join(dir, relProject), false)).artifacts);
194
298
  } else {
195
299
  throw new Error("affectedStacks requires either baseDir or baseRef");
196
300
  }
197
301
 
198
- return computeAffected(baseMap, headMap, head.manifest.stackGraph, {
302
+ return computeAffected(baseMap, head.artifacts, head.graph, {
199
303
  includeDependents: opts.includeDependents,
200
- externalInput,
304
+ externalInput: head.externalInput,
201
305
  });
202
306
  }
@@ -0,0 +1,141 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import * as ts from "typescript";
3
+ import { readFileSync, readdirSync, statSync } from "node:fs";
4
+ import { builtinModules } from "node:module";
5
+ import { join, relative } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ /**
9
+ * A package may only import, by name, what it declares.
10
+ *
11
+ * chant-v0.71.0 shipped `packages/core/src/op/activities/apply.ts` doing
12
+ * `await import("js-yaml")` while core's package.json declared no such
13
+ * dependency. Inside this repository it resolved anyway, hoisted out of the six
14
+ * lexicons that do declare it, so every test and every local build passed. A
15
+ * consumer installing `@intentius/chant` alone got an unresolvable import, and
16
+ * the three warden repositories all failed to bundle on the version bump.
17
+ *
18
+ * That is the shape worth gating: a missing dependency is invisible in a
19
+ * workspace and only fails downstream, which is the most expensive place to
20
+ * find it, after a release is already tagged and published. It is the same
21
+ * reasoning as the two guards next door — `peer-deps.test.ts` and
22
+ * `publish-metadata.test.ts` both exist because a packaging field nobody could
23
+ * see locally broke a publish or an install.
24
+ *
25
+ * Scope and precision. This walks the real import nodes with the TypeScript
26
+ * parser rather than matching text, so a specifier inside a comment, a
27
+ * code-generation template string, or this repository's own fold-import
28
+ * fixtures is not mistaken for an import. Only a literal specifier counts: a
29
+ * variable specifier is the codebase's established way of saying "a package
30
+ * this one deliberately does not depend on" (see `loadK8sApplier` in
31
+ * `op/activities/apply.ts`), and nothing statically resolves it.
32
+ */
33
+ const CORE = fileURLToPath(new URL("../../", import.meta.url));
34
+
35
+ /**
36
+ * A package core imports by name on purpose without declaring it, and why.
37
+ *
38
+ * An entry here is a promise that the import is guarded: reaching it without
39
+ * the package installed produces an actionable error rather than a crash.
40
+ * Anything else belongs in `dependencies`.
41
+ */
42
+ const DELIBERATELY_UNDECLARED = new Map<string, string>([
43
+ [
44
+ "@cdktf/hcl2json",
45
+ "carries a ~1.8 MB wasm blob and is only needed by `chant carve`; " +
46
+ "`terraform/parse.ts` catches the failed import and prints the install line",
47
+ ],
48
+ [
49
+ "@intentius/chant-lexicon-gitlab",
50
+ "the only lexicon shipping migration rules today; `cli/commands/migrate.ts` " +
51
+ "catches the failed import and migrates with no extra rules. Core depending on " +
52
+ "a lexicon would invert the dependency direction the whole layering rests on",
53
+ ],
54
+ ]);
55
+
56
+ /** Every `.ts` file that ships, excluding tests and the fixture trees. */
57
+ function sourceFiles(dir: string): string[] {
58
+ return readdirSync(dir).flatMap((entry) => {
59
+ const path = join(dir, entry);
60
+ if (statSync(path).isDirectory()) {
61
+ return /__fixtures__|__tests__|[/\\]fixtures$/.test(path) ? [] : sourceFiles(path);
62
+ }
63
+ return /\.ts$/.test(entry) && !/\.test\.ts$/.test(entry) ? [path] : [];
64
+ });
65
+ }
66
+
67
+ /** The package name a specifier resolves to: `zod/v4` -> `zod`, `@a/b/c` -> `@a/b`. */
68
+ function packageOf(specifier: string): string {
69
+ const parts = specifier.split("/");
70
+ return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
71
+ }
72
+
73
+ /** Literal bare specifiers this file imports, from real import nodes only. */
74
+ function importedPackages(file: string): Set<string> {
75
+ const source = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true);
76
+ const out = new Set<string>();
77
+ const add = (node: ts.Expression | undefined): void => {
78
+ if (!node || !ts.isStringLiteral(node)) return;
79
+ const specifier = node.text;
80
+ if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("node:")) return;
81
+ const name = packageOf(specifier);
82
+ if (builtins.has(name)) return;
83
+ out.add(name);
84
+ };
85
+ const visit = (node: ts.Node): void => {
86
+ if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) add(node.moduleSpecifier);
87
+ else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) add(node.argument.literal as ts.Expression);
88
+ else if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) add(node.arguments[0]);
89
+ ts.forEachChild(node, visit);
90
+ };
91
+ ts.forEachChild(source, visit);
92
+ return out;
93
+ }
94
+
95
+ const builtins = new Set(builtinModules);
96
+
97
+ describe("@intentius/chant imports only what it declares", () => {
98
+ const pkg = JSON.parse(readFileSync(join(CORE, "package.json"), "utf8")) as {
99
+ name: string;
100
+ dependencies?: Record<string, string>;
101
+ peerDependencies?: Record<string, string>;
102
+ optionalDependencies?: Record<string, string>;
103
+ };
104
+ const declared = new Set([
105
+ pkg.name,
106
+ ...Object.keys(pkg.dependencies ?? {}),
107
+ ...Object.keys(pkg.peerDependencies ?? {}),
108
+ ...Object.keys(pkg.optionalDependencies ?? {}),
109
+ ]);
110
+
111
+ test("every literal bare import is declared, or deliberately not", () => {
112
+ const undeclared = new Map<string, string[]>();
113
+ for (const file of sourceFiles(join(CORE, "src"))) {
114
+ for (const name of importedPackages(file)) {
115
+ if (declared.has(name) || DELIBERATELY_UNDECLARED.has(name)) continue;
116
+ const where = undeclared.get(name) ?? [];
117
+ where.push(relative(CORE, file));
118
+ undeclared.set(name, where);
119
+ }
120
+ }
121
+
122
+ expect(
123
+ [...undeclared].map(([name, where]) => `${name} (${where.join(", ")})`),
124
+ "imported by name but absent from package.json. Add it to dependencies, " +
125
+ "or guard the import and record it in DELIBERATELY_UNDECLARED with the reason.",
126
+ ).toEqual([]);
127
+ });
128
+
129
+ test("js-yaml specifically, since that is the one that shipped broken", () => {
130
+ // chant-v0.71.0's regression, pinned by name so the fix cannot be reverted
131
+ // quietly: `renderKustomization` parses `kustomize build` output with it.
132
+ expect(declared.has("js-yaml")).toBe(true);
133
+ });
134
+
135
+ test("the deliberate exceptions are still imported, so the list cannot rot", () => {
136
+ const imported = new Set(sourceFiles(join(CORE, "src")).flatMap((f) => [...importedPackages(f)]));
137
+ for (const name of DELIBERATELY_UNDECLARED.keys()) {
138
+ expect(imported.has(name), `${name} is listed as deliberately undeclared but nothing imports it`).toBe(true);
139
+ }
140
+ });
141
+ });
@@ -37,9 +37,25 @@ export const lifecycleDiffContract = activityContract(
37
37
  z.object({ output: z.string(), exitCode: z.number(), drifted: z.boolean() }),
38
38
  );
39
39
 
40
+ /**
41
+ * The escape hatch (#2413). `returns` is what `shellCmd` captured and used to
42
+ * throw away: the trimmed stdout, the trimmed stderr, and the exit code —
43
+ * which only ever differs from `0` when the step named that code in `okExit`,
44
+ * since anything else still rejects.
45
+ *
46
+ * Running a command chant does not model in order to discard what it produced
47
+ * is the odd case, not the normal one, so the value a later step reads through
48
+ * `sh.out.stdout` is the point of the step rather than an extra.
49
+ */
40
50
  export const shellCmdContract = activityContract(
41
51
  "shellCmd",
42
- z.strictObject({ cmd: z.string(), env: z.record(z.string(), z.string()).optional(), cwd: z.string().optional() }),
52
+ z.strictObject({
53
+ cmd: z.string(),
54
+ env: z.record(z.string(), z.string()).optional(),
55
+ cwd: z.string().optional(),
56
+ okExit: z.array(z.number()).optional(),
57
+ }),
58
+ z.object({ stdout: z.string(), stderr: z.string(), exitCode: z.number() }),
43
59
  );
44
60
 
45
61
  export const httpCheckContract = activityContract(
@@ -14,7 +14,7 @@ export type { WaitForStackArgs } from "./wait";
14
14
  // provides gitlabPipeline. The gitlabPipeline step builder stays in core.
15
15
 
16
16
  export { shellCmd } from "./shell";
17
- export type { ShellCmdArgs } from "./shell";
17
+ export type { ShellCmdArgs, ShellCmdResult } from "./shell";
18
18
 
19
19
  export { httpCheck, statusOk } from "./http-check";
20
20
  export type { HttpCheckArgs, HttpFetch } from "./http-check";
@@ -0,0 +1,156 @@
1
+ /**
2
+ * The escape hatch's safety properties (#2411, #2412) and what it publishes
3
+ * (#2413, #2414).
4
+ *
5
+ * `shellCmd` runs a command chant did not write and cannot model, which is
6
+ * what makes these different from every other activity: nothing here can know
7
+ * whether repeating the command is safe, how much it will say, or what a
8
+ * non-zero exit means.
9
+ */
10
+
11
+ import { describe, test, expect } from "vitest";
12
+ import { z } from "zod";
13
+ import { shellCmd } from "./shell";
14
+ import { shellCmdContract } from "./activity-contracts";
15
+ import { shell } from "../builders";
16
+ import { phase } from "../builders";
17
+ import { collectStepOutputRefs, stepOutput, validateStepOutputRefs } from "../step-output-ref";
18
+ import { ACTIVITY_PROFILES } from "../activity-profiles";
19
+ import { runOpLocally } from "../local-executor";
20
+ import { memoryGateLedgerPort } from "../gate";
21
+ import type { ActivityFn } from "../activity-registry";
22
+ import type { ShellCmdArgs, ShellCmdResult } from "./shell";
23
+
24
+ describe("the at-most-once default (#2411)", () => {
25
+ test("shell() asks for a profile that does not retry", () => {
26
+ const step = shell("echo hi");
27
+ expect(step.profile).toBe("atMostOnce");
28
+ expect(ACTIVITY_PROFILES.atMostOnce.retry.maximumAttempts).toBe(1);
29
+ });
30
+
31
+ test("an author who knows the command is safe to repeat can still say so", () => {
32
+ // The direction that matters: retrying is opt-in, because it is the claim
33
+ // that needs evidence about the command.
34
+ expect(shell("echo hi", { profile: "fastIdempotent" }).profile).toBe("fastIdempotent");
35
+ expect(ACTIVITY_PROFILES.fastIdempotent.retry.maximumAttempts).toBeGreaterThan(1);
36
+ });
37
+
38
+ test("the profile allows a long command, since a shell step is often a build", () => {
39
+ expect(ACTIVITY_PROFILES.atMostOnce.timeout).toBe("20m");
40
+ });
41
+ });
42
+
43
+ describe("the stdout ceiling (#2412)", () => {
44
+ test("a command producing well over 1 MiB completes instead of rejecting", async () => {
45
+ // Node's default maxBuffer is 1 MiB and this activity was the only exec
46
+ // site leaving it unset. 4 MiB is comfortably past the old ceiling and
47
+ // far short of the new one.
48
+ const bytes = 4 * 1024 * 1024;
49
+ const out = await shellCmd({ cmd: `node -e "process.stdout.write('x'.repeat(${bytes}))"` });
50
+ expect(out.stdout.length).toBe(bytes);
51
+ });
52
+
53
+ test("stdout is returned trimmed, as before", async () => {
54
+ expect((await shellCmd({ cmd: "echo hello" })).stdout).toBe("hello");
55
+ });
56
+
57
+ test("cwd and env reach the command", async () => {
58
+ const out = await shellCmd({ cmd: "pwd && echo $CHANT_SHELL_TEST", cwd: "/tmp", env: { CHANT_SHELL_TEST: "set" } });
59
+ expect(out.stdout).toContain("set");
60
+ });
61
+ });
62
+
63
+ describe("what a shell step publishes (#2413)", () => {
64
+ test("the contract declares a return schema, so a later step may reference it", () => {
65
+ const returns = shellCmdContract.returns as z.ZodTypeAny | undefined;
66
+ expect(returns).toBeDefined();
67
+ expect(returns!.parse({ stdout: "a", stderr: "b", exitCode: 0 })).toEqual({ stdout: "a", stderr: "b", exitCode: 0 });
68
+ });
69
+
70
+ test("a step reading a shell step's stdout passes OPS013", () => {
71
+ const host = shell("echo db.internal", { id: "host" });
72
+ const smoke = shell("./smoke.sh", { env: { HOST: host.out.stdout } });
73
+ const issues = validateStepOutputRefs(
74
+ { name: "deploy", phases: [phase("Go", [host, smoke])] },
75
+ new Map([["shellCmd", shellCmdContract]]),
76
+ );
77
+ expect(issues).toEqual([]);
78
+ });
79
+
80
+ test("a path the shell result does not declare is still flagged", () => {
81
+ const host = shell("echo db.internal", { id: "host" });
82
+ const smoke = shell("./smoke.sh", { env: { HOST: stepOutput(host, "stdoutt") } });
83
+ const issues = validateStepOutputRefs(
84
+ { name: "deploy", phases: [phase("Go", [host, smoke])] },
85
+ new Map([["shellCmd", shellCmdContract]]),
86
+ );
87
+ expect(issues).toHaveLength(1);
88
+ expect(issues[0].message).toContain('output path "stdoutt"');
89
+ });
90
+
91
+ test("stderr survives the call instead of being printed and dropped", async () => {
92
+ const out = await shellCmd({ cmd: `node -e "process.stderr.write('warned')"` });
93
+ expect(out).toMatchObject({ stdout: "", stderr: "warned", exitCode: 0 });
94
+ });
95
+
96
+ test("a non-zero exit still rejects, and the code is in the message", async () => {
97
+ await expect(shellCmd({ cmd: "exit 3" })).rejects.toThrow(/exited 3 \(expected 0\)/);
98
+ });
99
+
100
+ test("a code the author named resolves, carrying output and the code", async () => {
101
+ // `diff` exits 1 to report a difference. Nothing about that is a failure,
102
+ // and before this the whole step was one.
103
+ const out = await shellCmd({ cmd: `node -e "process.stdout.write('changed'); process.exit(1)"`, okExit: [0, 1] });
104
+ expect(out).toEqual({ stdout: "changed", stderr: "", exitCode: 1 });
105
+ });
106
+
107
+ test("a code the author did not name still rejects", async () => {
108
+ await expect(shellCmd({ cmd: "exit 2", okExit: [0, 1] })).rejects.toThrow(/exited 2 \(expected 0, 1\)/);
109
+ });
110
+
111
+ test("a signal kill rejects even when its code is named", async () => {
112
+ // A timeout or Ctrl-C is not an exit status the author said anything
113
+ // about, so `okExit` must not swallow it.
114
+ const ac = new AbortController();
115
+ const running = shellCmd({ cmd: "sleep 5", okExit: [0, 1, 143] }, ac.signal);
116
+ ac.abort();
117
+ await expect(running).rejects.toThrow();
118
+ });
119
+ });
120
+
121
+ describe("a reference reaching the command (#2414)", () => {
122
+ test("a reference in env typechecks, which is the whole gap", () => {
123
+ const host = shell("echo db.internal", { id: "host" });
124
+ // No `as` cast: before #2414 `WithStepRefs` was shallow, so a reference
125
+ // inside `env`'s Record<string, string> was a type error even though the
126
+ // executor resolved it and the lint rule accepted it.
127
+ const smoke = shell("./smoke.sh", { env: { HOST: host.out.stdout, MODE: "ci" } });
128
+ expect(collectStepOutputRefs(smoke.args)).toEqual([
129
+ expect.objectContaining({ step: "host", path: "stdout" }),
130
+ ]);
131
+ expect((smoke.args as { env: Record<string, string> }).env.MODE).toBe("ci");
132
+ });
133
+
134
+ test("the executor carries it into the next command's environment, end to end", async () => {
135
+ const host = shell("echo db.internal", { id: "host" });
136
+ const smoke = shell("echo reached $HOST", { env: { HOST: host.out.stdout }, id: "smoke" });
137
+
138
+ const seen: ShellCmdResult[] = [];
139
+ const spy: ActivityFn = async (args, signal) => {
140
+ const out = await shellCmd(args as unknown as ShellCmdArgs, signal);
141
+ seen.push(out);
142
+ return out;
143
+ };
144
+
145
+ const result = await runOpLocally(
146
+ { name: "deploy", overview: "", phases: [phase("Go", [host, smoke])] },
147
+ new Map([["shellCmd", spy]]),
148
+ ACTIVITY_PROFILES,
149
+ undefined,
150
+ { gates: memoryGateLedgerPort() },
151
+ );
152
+
153
+ expect(result.status).toBe("ok");
154
+ expect(seen.map((s) => s.stdout)).toEqual(["db.internal", "reached db.internal"]);
155
+ });
156
+ });