@intentius/chant 0.51.0 → 0.52.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.
- package/dist/cli/build-options.d.ts +68 -0
- package/dist/cli/build-options.d.ts.map +1 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts +0 -21
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/lexicon.d.ts +10 -9
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/assert-live.d.ts +2 -3
- package/dist/lifecycle/assert-live.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +23 -0
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lint/policy.d.ts +16 -0
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +15 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/ownership.d.ts +5 -4
- package/dist/ownership.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/build-options.test.ts +101 -0
- package/src/cli/build-options.ts +109 -0
- package/src/cli/commands/build.ts +24 -53
- package/src/cli/handlers/lifecycle.test.ts +19 -0
- package/src/cli/handlers/lifecycle.ts +14 -7
- package/src/cli/handlers/run.test.ts +96 -0
- package/src/cli/handlers/run.ts +37 -1
- package/src/cli/main.ts +2 -0
- package/src/discovery/fold-import.test.ts +102 -0
- package/src/discovery/fold-import.ts +54 -4
- package/src/lexicon.ts +10 -9
- package/src/lifecycle/assert-live.ts +2 -3
- package/src/lifecycle/change-set.test.ts +144 -1
- package/src/lifecycle/change-set.ts +133 -11
- package/src/lint/policy-build-parity.test.ts +232 -0
- package/src/lint/policy.ts +51 -6
- package/src/op/local-executor.ts +28 -0
- package/src/ownership.ts +5 -4
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place chant assembles `build()`'s options for a project directory.
|
|
3
|
+
*
|
|
4
|
+
* chant #2002 — `chant build` (./commands/build.ts) and the `policyGate` Op
|
|
5
|
+
* step (../lint/policy.ts's `evaluateProjectPolicies`) both build the same
|
|
6
|
+
* directory, and each used to assemble that option object itself. The two lists
|
|
7
|
+
* drifted to nine options against two: the gate built with fold off (the path
|
|
8
|
+
* `chant build` retired as its default in #1134), without the project's config
|
|
9
|
+
* (so serializers lost their lexicon-scoped dialect settings and a policy
|
|
10
|
+
* reading `ctx.docs` inspected different documents), and without `buildRoots`
|
|
11
|
+
* (so config-declared roots contributed no entities at all). A gate can only
|
|
12
|
+
* decide on a build `chant build` would produce if the two are assembled from
|
|
13
|
+
* one place, so they are.
|
|
14
|
+
*
|
|
15
|
+
* Two functions rather than one because the *modes* are needed earlier than the
|
|
16
|
+
* rest: `chant build` arms sandboxed policy execution from the resolved
|
|
17
|
+
* `sandbox` before it loads any policy module, which happens before build-time
|
|
18
|
+
* parameters and the ownership marker exist. {@link resolveBuildModes} is that
|
|
19
|
+
* early half; {@link resolveProjectBuildOptions} takes its result and assembles
|
|
20
|
+
* everything `build()` is given.
|
|
21
|
+
*/
|
|
22
|
+
import { resolveFoldEnabled, resolveSandboxEnabled, type ChantConfig } from "../config";
|
|
23
|
+
import { collectBuildRootContributors, resolveLexiconVersions } from "./plugins";
|
|
24
|
+
import type { LexiconPlugin } from "../lexicon";
|
|
25
|
+
import type { BuildOptions } from "../build";
|
|
26
|
+
import type { OwnershipMarker } from "../ownership";
|
|
27
|
+
import type { BuildParamProvenance } from "../provenance";
|
|
28
|
+
|
|
29
|
+
/** The resolved fold/sandbox execution modes for one build. */
|
|
30
|
+
export interface BuildModes {
|
|
31
|
+
/** #1022/#1134 — fold source modules statically; the default build path. */
|
|
32
|
+
fold: boolean;
|
|
33
|
+
/** #1045 Phase 2 — execute run-fallback files in a sandboxed child. */
|
|
34
|
+
sandbox: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve this build's execution modes: an explicit CLI flag wins over
|
|
39
|
+
* `chant.config.ts`'s `build.fold`/`build.sandbox`, which wins over the default
|
|
40
|
+
* (fold on, sandbox off). Callers with no flags to offer (the `policyGate` step)
|
|
41
|
+
* pass no overrides and get the project's own answer.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveBuildModes(
|
|
44
|
+
config: ChantConfig,
|
|
45
|
+
overrides?: { fold?: boolean; sandbox?: boolean },
|
|
46
|
+
): BuildModes {
|
|
47
|
+
return {
|
|
48
|
+
fold: resolveFoldEnabled(config, overrides?.fold),
|
|
49
|
+
sandbox: resolveSandboxEnabled(config, overrides?.sandbox),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Inputs {@link resolveProjectBuildOptions} needs to assemble a build. */
|
|
54
|
+
export interface ProjectBuildOptionsInput {
|
|
55
|
+
/** The project's resolved config, from `loadChantConfigUpward`. */
|
|
56
|
+
config: ChantConfig;
|
|
57
|
+
/** Directory the config was found in — build roots are rooted here, not at a (possibly sourceDir-scoped) build path. */
|
|
58
|
+
configDir: string;
|
|
59
|
+
/** The lexicon plugins loaded for this build. */
|
|
60
|
+
plugins?: readonly LexiconPlugin[];
|
|
61
|
+
/** Resolved execution modes, from {@link resolveBuildModes}. */
|
|
62
|
+
modes: BuildModes;
|
|
63
|
+
/** The resolved ownership marker, when marking is on. */
|
|
64
|
+
ownership?: OwnershipMarker;
|
|
65
|
+
/** This build's resolved build-time parameters (#1064). */
|
|
66
|
+
buildParams?: BuildParamProvenance[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Assemble the option object `build()` takes for a project directory.
|
|
71
|
+
*
|
|
72
|
+
* Every option here is derived from the project (its config, its plugins, its
|
|
73
|
+
* resolved parameters) rather than from the command that asked, so two callers
|
|
74
|
+
* handing in the same project get byte-identical builds. A new option belongs
|
|
75
|
+
* in this function, never at a call site.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveProjectBuildOptions(input: ProjectBuildOptionsInput): BuildOptions {
|
|
78
|
+
const { config, configDir, plugins, modes, ownership, buildParams } = input;
|
|
79
|
+
|
|
80
|
+
// #1039 — each loaded plugin's registered intrinsics (e.g. AWS's `Sub`), so
|
|
81
|
+
// a file using a registered intrinsic tagged template folds instead of
|
|
82
|
+
// unconditionally falling back to run. `intrinsics` is an optional plugin
|
|
83
|
+
// extension, hence the guard.
|
|
84
|
+
const intrinsics = plugins?.flatMap((plugin) => plugin.intrinsics?.() ?? []) ?? [];
|
|
85
|
+
|
|
86
|
+
// #1063 — the same loaded plugins, by NAME, are this build's allowlist for
|
|
87
|
+
// following a bare import specifier into a lexicon package (so `Azure`,
|
|
88
|
+
// `GCP`, `S3Actions`, `CI` fold as values).
|
|
89
|
+
const lexicons = plugins?.map((plugin) => plugin.name) ?? [];
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
ownership,
|
|
93
|
+
config: config as unknown as Record<string, unknown>,
|
|
94
|
+
fold: modes.fold,
|
|
95
|
+
sandbox: modes.sandbox,
|
|
96
|
+
intrinsics,
|
|
97
|
+
lexicons,
|
|
98
|
+
// chant #1442 — which lexicon VERSION interpreted each declaration.
|
|
99
|
+
lexiconVersions: resolveLexiconVersions(lexicons),
|
|
100
|
+
buildParams,
|
|
101
|
+
// #1548 piece 3 — config-declared build roots (kustomize dirs, committed
|
|
102
|
+
// ciphertext), rendered into entities by the owning lexicon's hook.
|
|
103
|
+
buildRoots: collectBuildRootContributors(
|
|
104
|
+
plugins,
|
|
105
|
+
config as unknown as Record<string, unknown>,
|
|
106
|
+
configDir,
|
|
107
|
+
),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -5,17 +5,15 @@ import {
|
|
|
5
5
|
resolveOwnershipEnv,
|
|
6
6
|
ownershipEnvDisagreement,
|
|
7
7
|
isOwnershipParamRef,
|
|
8
|
-
resolveFoldEnabled,
|
|
9
|
-
resolveSandboxEnabled,
|
|
10
8
|
resolveKnowledgeDir,
|
|
11
9
|
} from "../../config";
|
|
10
|
+
import { resolveBuildModes, resolveProjectBuildOptions } from "../build-options";
|
|
12
11
|
import { loadOkfBundle } from "../../okf-read";
|
|
13
12
|
import { unknownEnvError } from "../../env";
|
|
14
13
|
import type { OwnershipMarker } from "../../ownership";
|
|
15
14
|
import { resolveCliBuildParams } from "../build-params-cli";
|
|
16
15
|
import type { Serializer, SerializerResult } from "../../serializer";
|
|
17
16
|
import type { LexiconPlugin } from "../../lexicon";
|
|
18
|
-
import { resolveLexiconVersions, collectBuildRootContributors } from "../plugins";
|
|
19
17
|
import { runPostSynthChecks } from "../../lint/post-synth";
|
|
20
18
|
import { coreReceiptChecks } from "../../lint/receipt-checks";
|
|
21
19
|
import { coreOutputChecks } from "../../lint/output-checks";
|
|
@@ -182,15 +180,14 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
182
180
|
const configDir = loaded.configPath ? dirname(loaded.configPath) : infraPath;
|
|
183
181
|
const policies = config.lint?.policies ?? [];
|
|
184
182
|
|
|
185
|
-
// #1022/#1134
|
|
186
|
-
// (--fold/--no-fold) wins over `chant.config.ts`'s
|
|
187
|
-
// wins over the
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const sandbox = resolveSandboxEnabled(config, options.sandbox);
|
|
183
|
+
// #1022/#1134 (fold) and #1045 Phase 2 (sandbox) — an explicit CLI flag
|
|
184
|
+
// (--fold/--no-fold, --sandbox) wins over `chant.config.ts`'s
|
|
185
|
+
// `build.fold`/`build.sandbox`, which wins over the defaults (fold on,
|
|
186
|
+
// sandbox off). Resolved here rather than with the rest of the build
|
|
187
|
+
// options below because arming sandboxed policy execution has to happen
|
|
188
|
+
// before any policy module could be loaded — see #2002 in ../build-options.ts.
|
|
189
|
+
const modes = resolveBuildModes(config, { fold: options.fold, sandbox: options.sandbox });
|
|
190
|
+
const { fold, sandbox } = modes;
|
|
194
191
|
|
|
195
192
|
// #1131 — arm sandboxed policy execution from the RESOLVED value, before any
|
|
196
193
|
// policy module could be loaded. Resolved, not `options.sandbox`, because
|
|
@@ -301,48 +298,22 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
301
298
|
}
|
|
302
299
|
}
|
|
303
300
|
|
|
304
|
-
// #
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
// `GCP`, `S3Actions`, `CI` fold as values). A plugin's `name` is the
|
|
314
|
-
// lexicon name `loadPlugin()` was called with, which is exactly what
|
|
315
|
-
// `@intentius/chant-lexicon-<name>` was imported from — see
|
|
316
|
-
// ../plugins.ts and fold-import.ts's `lexiconPackageName`.
|
|
317
|
-
const lexicons = options.plugins?.map((plugin) => plugin.name) ?? [];
|
|
318
|
-
|
|
319
|
-
// chant #1442 — which lexicon VERSION interpreted each declaration. The
|
|
320
|
-
// declaration is fingerprinted by `hashProps`; the thing that turned it into
|
|
321
|
-
// output was not recorded at all, so a lexicon bump that changed emitted
|
|
322
|
-
// output left the build digest identical. Captured here because this is the
|
|
323
|
-
// layer that knows which lexicons were loaded — `build()` is given only
|
|
324
|
-
// their names.
|
|
325
|
-
const lexiconVersions = resolveLexiconVersions(lexicons);
|
|
326
|
-
|
|
327
|
-
// Run the build
|
|
328
|
-
const result = await build(infraPath, options.serializers, undefined, {
|
|
329
|
-
ownership,
|
|
330
|
-
config: config as unknown as Record<string, unknown>,
|
|
331
|
-
fold,
|
|
332
|
-
sandbox,
|
|
333
|
-
intrinsics,
|
|
334
|
-
lexicons,
|
|
335
|
-
lexiconVersions,
|
|
336
|
-
buildParams: paramsResolution.provenance,
|
|
337
|
-
// #1548 piece 3 — config-declared build roots (kustomize dirs), rendered
|
|
338
|
-
// into entities by the owning lexicon's hook. Rooted at the config dir,
|
|
339
|
-
// not the (possibly sourceDir-scoped) infra path.
|
|
340
|
-
buildRoots: collectBuildRootContributors(
|
|
341
|
-
options.plugins,
|
|
342
|
-
config as unknown as Record<string, unknown>,
|
|
301
|
+
// Run the build. #2002 — every option comes from the shared assembler
|
|
302
|
+
// (../build-options.ts), which `evaluateProjectPolicies` calls too, so the
|
|
303
|
+
// `policyGate` step decides on the same project this command builds.
|
|
304
|
+
const result = await build(
|
|
305
|
+
infraPath,
|
|
306
|
+
options.serializers,
|
|
307
|
+
undefined,
|
|
308
|
+
resolveProjectBuildOptions({
|
|
309
|
+
config,
|
|
343
310
|
configDir,
|
|
344
|
-
|
|
345
|
-
|
|
311
|
+
plugins: options.plugins,
|
|
312
|
+
modes,
|
|
313
|
+
ownership,
|
|
314
|
+
buildParams: paramsResolution.provenance,
|
|
315
|
+
}),
|
|
316
|
+
);
|
|
346
317
|
|
|
347
318
|
// #1022 — report per-file fold vs run so it's visible what still runs.
|
|
348
319
|
// #1424 — one line by default; the per-file lines and their reasons are
|
|
@@ -753,6 +753,25 @@ describe("runLifecyclePlan", () => {
|
|
|
753
753
|
expect(stdoutBuf.join("\n")).toContain("bucket");
|
|
754
754
|
});
|
|
755
755
|
|
|
756
|
+
// #1983 — `--report markdown` emits the reviewer-facing projection instead
|
|
757
|
+
// of the human render, same as `--report gitlab-mr` swaps in the widget JSON.
|
|
758
|
+
test("--report markdown emits the markdown projection instead of the human render", async () => {
|
|
759
|
+
buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
|
|
760
|
+
const plugins: LexiconPlugin[] = [
|
|
761
|
+
createMockPlugin({ name: "aws", emulator: awsEmulatorStub, describeResources: staticDescribeResources({}) }),
|
|
762
|
+
];
|
|
763
|
+
const exit = await runLifecyclePlan({
|
|
764
|
+
args: makeArgs({ path: "plan", extraPositional: "prod", reportFile: "markdown" }),
|
|
765
|
+
plugins,
|
|
766
|
+
serializers: plugins.map((p) => p.serializer),
|
|
767
|
+
});
|
|
768
|
+
expect(exit).toBe(0);
|
|
769
|
+
const out = stdoutBuf.join("\n");
|
|
770
|
+
expect(out).toContain("## Plan for `prod`");
|
|
771
|
+
expect(out).toContain("### CREATE");
|
|
772
|
+
expect(out).toContain("- `bucket`");
|
|
773
|
+
});
|
|
774
|
+
|
|
756
775
|
// #1620 — the resolved read address rides plan entries the same way it rides
|
|
757
776
|
// the live diff. Regression: the plan path dropped the observation's queried
|
|
758
777
|
// map, so only unobserved rows ever carried an address while the docs
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from "../../lifecycle/observation-baseline";
|
|
19
19
|
import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
|
|
20
20
|
import { diffLive, diffLiveArtifacts, diffSnapshots, type LiveDiffResult, type LiveArtifactDiffResult, type SnapshotDiffResult } from "../../lifecycle/live-diff";
|
|
21
|
-
import { buildChangeSet, renderChangeSet, gitlabMrReport,
|
|
21
|
+
import { buildChangeSet, renderChangeSet, renderChangeSetMarkdown, gitlabMrReport, unobservedPlanNotice, type ChangeSet } from "../../lifecycle/change-set";
|
|
22
22
|
import { mergeReceiptEntries, observedValueResolver, planReceipts, readReceiptValue, type ReceiptReading } from "../../lifecycle/receipt-plan";
|
|
23
23
|
import { annotateDisruption, disruptionNotices } from "../../lifecycle/disruption";
|
|
24
24
|
import { collectEffectReceipts, isEffectReceipt } from "../../effect-receipt";
|
|
@@ -1302,12 +1302,11 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1302
1302
|
merged.entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
1303
1303
|
|
|
1304
1304
|
// Say it on stderr too, so `--json` and `--report gitlab-mr` consumers (whose
|
|
1305
|
-
// shapes have no room for it) still learn the plan has a hole (#1089).
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
}));
|
|
1305
|
+
// shapes have no room for it) still learn the plan has a hole (#1089). The
|
|
1306
|
+
// `markdown` report carries the same wording in its own body instead — a
|
|
1307
|
+
// reviewer reading a comment never sees this stderr.
|
|
1308
|
+
for (const notice of unobservedPlanNotice(merged)) {
|
|
1309
|
+
console.error(formatWarning({ message: notice }));
|
|
1311
1310
|
}
|
|
1312
1311
|
|
|
1313
1312
|
// Same reason as above (#1665): the `--json` and `--report gitlab-mr` shapes
|
|
@@ -1324,6 +1323,14 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1324
1323
|
return 0;
|
|
1325
1324
|
}
|
|
1326
1325
|
|
|
1326
|
+
// `--report markdown` emits the reviewer-facing projection (#1983) — a
|
|
1327
|
+
// counts header, entries grouped and attributed to their lexicon, holes and
|
|
1328
|
+
// disruption both carried in the body rather than left to stderr.
|
|
1329
|
+
if (args.reportFile === "markdown") {
|
|
1330
|
+
console.log(renderChangeSetMarkdown(merged));
|
|
1331
|
+
return 0;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1327
1334
|
if (args.json) {
|
|
1328
1335
|
console.log(JSON.stringify(merged, null, 2));
|
|
1329
1336
|
} else {
|
|
@@ -987,6 +987,102 @@ describe("runOp dispatcher", () => {
|
|
|
987
987
|
});
|
|
988
988
|
});
|
|
989
989
|
|
|
990
|
+
/**
|
|
991
|
+
* chant #2003 — `--sandbox` is a global flag and `../main.ts` arms the
|
|
992
|
+
* process-wide policy latch off it for every command, so `chant run <op>
|
|
993
|
+
* --sandbox` on an Op with a `policyGate` step used to reach `loadPolicyChecks`
|
|
994
|
+
* mid-run and show the user a message written for a chant maintainer ("This is
|
|
995
|
+
* a chant bug"). At the CLI level, not on `loadPolicyChecks`: the defect was
|
|
996
|
+
* that the combination got that far, not what the refusal says.
|
|
997
|
+
*/
|
|
998
|
+
describe("runOp: --sandbox with a policyGate step (chant #2003)", () => {
|
|
999
|
+
const policyGateOp = (name: string) =>
|
|
1000
|
+
localOp(name, [{ kind: "activity", fn: "policyGate", args: { path: "." } }]);
|
|
1001
|
+
|
|
1002
|
+
beforeEach(() => {
|
|
1003
|
+
discoverOpsMock.mockReset();
|
|
1004
|
+
loadTemporalClientMock.mockReset();
|
|
1005
|
+
loadChantConfigMock.mockReset();
|
|
1006
|
+
resolveProfileMock.mockReset();
|
|
1007
|
+
existsSyncMock.mockReset();
|
|
1008
|
+
spawnChildMock.mockReset();
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
test("local mode → refuses before any phase runs, naming the combination", async () => {
|
|
1012
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([policyGateOp("gate")]), errors: [] });
|
|
1013
|
+
const stderr = makeStderrSpy();
|
|
1014
|
+
|
|
1015
|
+
const exit = await runOp({ args: makeArgs({ path: "gate", temporal: false, sandbox: true }), plugins: [], serializers: [] });
|
|
1016
|
+
|
|
1017
|
+
expect(exit).toBe(1);
|
|
1018
|
+
const out = stderr.join("\n");
|
|
1019
|
+
expect(out).toContain("policyGate");
|
|
1020
|
+
expect(out).toContain("--sandbox");
|
|
1021
|
+
// The message the user used to get instead.
|
|
1022
|
+
expect(out).not.toContain("This is a chant bug");
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
test("--temporal → the same refusal, as a CLI error rather than an activity failure", async () => {
|
|
1026
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([policyGateOp("gate")]), errors: [] });
|
|
1027
|
+
const stderr = makeStderrSpy();
|
|
1028
|
+
|
|
1029
|
+
const exit = await runOp({ args: makeArgs({ path: "gate", temporal: true, sandbox: true }), plugins: [], serializers: [] });
|
|
1030
|
+
|
|
1031
|
+
expect(exit).toBe(1);
|
|
1032
|
+
expect(stderr.join("\n")).toContain("policyGate");
|
|
1033
|
+
// Refused before the project config is even loaded — nothing is scheduled.
|
|
1034
|
+
expect(loadChantConfigMock).not.toHaveBeenCalled();
|
|
1035
|
+
expect(loadTemporalClientMock).not.toHaveBeenCalled();
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
test("a policyGate nested in an effect step is found too", async () => {
|
|
1039
|
+
discoverOpsMock.mockResolvedValue({
|
|
1040
|
+
ops: new Map([
|
|
1041
|
+
localOp("gate", [
|
|
1042
|
+
{ kind: "effect", steps: [{ kind: "activity", fn: "policyGate", args: { path: "." } }] },
|
|
1043
|
+
]),
|
|
1044
|
+
]),
|
|
1045
|
+
errors: [],
|
|
1046
|
+
});
|
|
1047
|
+
const stderr = makeStderrSpy();
|
|
1048
|
+
|
|
1049
|
+
const exit = await runOp({ args: makeArgs({ path: "gate", temporal: false, sandbox: true }), plugins: [], serializers: [] });
|
|
1050
|
+
|
|
1051
|
+
expect(exit).toBe(1);
|
|
1052
|
+
expect(stderr.join("\n")).toContain("policyGate");
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
test("without --sandbox the same Op is not refused", async () => {
|
|
1056
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([policyGateOp("gate")]), errors: [] });
|
|
1057
|
+
loadChantConfigMock.mockResolvedValue({ config: {} });
|
|
1058
|
+
resolveProfileMock.mockReturnValue({ address: "localhost:7233", namespace: "default", taskQueue: "q" });
|
|
1059
|
+
existsSyncMock.mockReturnValue(false);
|
|
1060
|
+
const stderr = makeStderrSpy();
|
|
1061
|
+
|
|
1062
|
+
// The Temporal path, so the run stops at the missing worker.ts rather than
|
|
1063
|
+
// actually building a project: reaching that point proves the pre-flight
|
|
1064
|
+
// let it through.
|
|
1065
|
+
const exit = await runOp({ args: makeArgs({ path: "gate", temporal: true }), plugins: [], serializers: [] });
|
|
1066
|
+
|
|
1067
|
+
expect(exit).toBe(1);
|
|
1068
|
+
expect(stderr.join("\n")).toContain("worker.ts not found");
|
|
1069
|
+
expect(loadChantConfigMock).toHaveBeenCalled();
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
test("--sandbox on an Op with no policyGate step is untouched", async () => {
|
|
1073
|
+
discoverOpsMock.mockResolvedValue({
|
|
1074
|
+
ops: new Map([localOp("hello", [{ kind: "activity", fn: "shellCmd", args: { cmd: "true" } }])]),
|
|
1075
|
+
errors: [],
|
|
1076
|
+
});
|
|
1077
|
+
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
1078
|
+
|
|
1079
|
+
const exit = await runOp({ args: makeArgs({ path: "hello", temporal: false, sandbox: true }), plugins: [], serializers: [] });
|
|
1080
|
+
|
|
1081
|
+
expect(exit).toBe(0);
|
|
1082
|
+
stderrWrite.mockRestore();
|
|
1083
|
+
});
|
|
1084
|
+
});
|
|
1085
|
+
|
|
990
1086
|
describe("Temporal-only subcommand guards", () => {
|
|
991
1087
|
const cases: Array<[string, (ctx: { args: ParsedArgs; plugins: never[]; serializers: never[] }) => Promise<number>]> = [
|
|
992
1088
|
["list", runOpList],
|
package/src/cli/handlers/run.ts
CHANGED
|
@@ -4,8 +4,9 @@ import { createConnection } from "node:net";
|
|
|
4
4
|
import { spawn as spawnChild, type ChildProcess } from "node:child_process";
|
|
5
5
|
import { loadChantConfig, resolveAutoReleaseDisabled, type ChantConfig } from "../../config";
|
|
6
6
|
import { discoverOps } from "../../op/discover";
|
|
7
|
+
import type { OpConfig } from "../../op/types";
|
|
7
8
|
import { loadActivities, loadProfiles } from "../../op/activity-registry";
|
|
8
|
-
import { runOpLocally, findGate, LocalGateUnsupportedError, OpRunFailure, type StepRecord } from "../../op/local-executor";
|
|
9
|
+
import { runOpLocally, findGate, findPolicyGateStep, LocalGateUnsupportedError, OpRunFailure, type StepRecord } from "../../op/local-executor";
|
|
9
10
|
import { renderHuman, renderJson } from "../../op/local-output";
|
|
10
11
|
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
11
12
|
import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
|
|
@@ -561,6 +562,32 @@ function renderProgress(opName: string, history: WorkflowHistoryRaw): void {
|
|
|
561
562
|
* live reaching an actual cloud shell-out. Erroring here is the safe minimum
|
|
562
563
|
* called out on the issue; a real preview is future work.
|
|
563
564
|
*/
|
|
565
|
+
/**
|
|
566
|
+
* Pre-flight for `chant run <op> --sandbox` on an Op containing a `policyGate`
|
|
567
|
+
* step (chant #2003). `--sandbox` is a global flag, and `../main.ts` arms the
|
|
568
|
+
* process-wide policy latch off it for every command — so the gate's
|
|
569
|
+
* `loadPolicyChecks` refuses mid-run with a message addressed to a chant
|
|
570
|
+
* maintainer ("This is a chant bug"), or, under `--temporal`, as a
|
|
571
|
+
* non-retryable activity failure inside the workflow. Neither is an answer a
|
|
572
|
+
* user can act on. Refuse here instead, before anything runs, naming the
|
|
573
|
+
* combination and what to do about it.
|
|
574
|
+
*
|
|
575
|
+
* Keyed on the flag rather than on whether the project declares
|
|
576
|
+
* `lint.policies`: the flag is what arms the latch, and a gate that builds the
|
|
577
|
+
* project in this process is a divergence from what `--sandbox` promises
|
|
578
|
+
* whether or not a policy module happens to be declared. Returns true when the
|
|
579
|
+
* caller should stop.
|
|
580
|
+
*/
|
|
581
|
+
function refusesPolicyGateUnderSandbox(ctx: CommandContext, config: OpConfig, opName: string): boolean {
|
|
582
|
+
if (!ctx.args.sandbox) return false;
|
|
583
|
+
if (!findPolicyGateStep(config)) return false;
|
|
584
|
+
console.error(formatError({
|
|
585
|
+
message: `Op "${opName}" has a policyGate step, which cannot run under --sandbox: the gate builds this project and imports its lint.policies in the chant process, which --sandbox forbids.`,
|
|
586
|
+
hint: "Re-run without --sandbox, or drop the policyGate step from this Op. Running the gate itself inside the sandbox boundary is tracked on chant#1157.",
|
|
587
|
+
}));
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
|
|
564
591
|
export async function runOp(ctx: CommandContext): Promise<number> {
|
|
565
592
|
if (ctx.args.components && ctx.args.report) {
|
|
566
593
|
console.error(formatError({
|
|
@@ -1061,6 +1088,9 @@ export async function runOpLocal(ctx: CommandContext): Promise<number> {
|
|
|
1061
1088
|
|
|
1062
1089
|
const { config } = discovered;
|
|
1063
1090
|
|
|
1091
|
+
// Pre-flight: --sandbox cannot cover a policyGate step (#2003).
|
|
1092
|
+
if (refusesPolicyGateUnderSandbox(ctx, config, opName)) return 1;
|
|
1093
|
+
|
|
1064
1094
|
// Pre-flight: gates/schedules need a durable runtime — fail before any step.
|
|
1065
1095
|
const gate = findGate(config);
|
|
1066
1096
|
if (gate) {
|
|
@@ -1145,6 +1175,12 @@ async function runOpTemporal(ctx: CommandContext): Promise<number> {
|
|
|
1145
1175
|
}
|
|
1146
1176
|
|
|
1147
1177
|
const { config } = discovered;
|
|
1178
|
+
|
|
1179
|
+
// Pre-flight: --sandbox cannot cover a policyGate step (#2003). Refused here
|
|
1180
|
+
// too, so the durable path fails as a CLI error rather than as a
|
|
1181
|
+
// non-retryable activity failure buried in workflow history.
|
|
1182
|
+
if (refusesPolicyGateUnderSandbox(ctx, config, opName)) return 1;
|
|
1183
|
+
|
|
1148
1184
|
const projectPath = resolve(".");
|
|
1149
1185
|
|
|
1150
1186
|
// Load config + profile
|
package/src/cli/main.ts
CHANGED
|
@@ -624,6 +624,8 @@ Options:
|
|
|
624
624
|
OR with a path arg: SARIF report destination (migrate)
|
|
625
625
|
OR '--report gitlab-mr': emit the GitLab MR plan-widget
|
|
626
626
|
JSON (lifecycle plan)
|
|
627
|
+
OR '--report markdown': emit a reviewer-facing markdown
|
|
628
|
+
render, holes and disruption included (lifecycle plan)
|
|
627
629
|
--from <name> Source lexicon for migrate (default: github)
|
|
628
630
|
--to <name> Target lexicon for migrate (default: gitlab)
|
|
629
631
|
--emit <fmt> Migration output format: yaml (default) or ts
|
|
@@ -2015,6 +2015,108 @@ describe("tryFoldFile — active lexicon package exports (#1063)", () => {
|
|
|
2015
2015
|
});
|
|
2016
2016
|
});
|
|
2017
2017
|
|
|
2018
|
+
describe("tryFoldFile — sandbox trust boundary, subpath import of an active lexicon package (chant #1995)", () => {
|
|
2019
|
+
let testDir: string;
|
|
2020
|
+
let seq = 0;
|
|
2021
|
+
|
|
2022
|
+
beforeEach(async () => {
|
|
2023
|
+
testDir = join(tmpdir(), `chant-fold-import-lexsubpath-test-${Date.now()}-${Math.random()}`);
|
|
2024
|
+
await mkdir(testDir, { recursive: true });
|
|
2025
|
+
});
|
|
2026
|
+
|
|
2027
|
+
afterEach(async () => {
|
|
2028
|
+
await rm(testDir, { recursive: true, force: true });
|
|
2029
|
+
});
|
|
2030
|
+
|
|
2031
|
+
/**
|
|
2032
|
+
* Install a package into `<testDir>/node_modules/<name>` shaped like a real
|
|
2033
|
+
* lexicon package: a barrel at "." AND a `./generated/index` subpath — the
|
|
2034
|
+
* exact shape `examples/cc-azure-canonical/src/cc-cluster/cluster.ts`
|
|
2035
|
+
* imports `AksCluster` through (`@intentius/chant-lexicon-azure/generated/index`).
|
|
2036
|
+
* The subpath module exports a real resource CLASS with the same runtime
|
|
2037
|
+
* shape `createResource` (chant-core's own factory) produces — plain,
|
|
2038
|
+
* dependency-free JS using `Symbol.for("chant.declarable")` directly (same
|
|
2039
|
+
* global-registry trick `../declarable.ts`'s own `DECLARABLE_MARKER` uses,
|
|
2040
|
+
* and the same convention `installLexiconPackage`'s `LEXICON_SOURCE` above
|
|
2041
|
+
* follows), rather than importing chant-core's runtime module: a real
|
|
2042
|
+
* package's OWN `.js` file is loaded by a genuine `import()`, not run
|
|
2043
|
+
* through this repo's TS loader, so a bare relative import of `runtime.ts`
|
|
2044
|
+
* from inside it would need an extension Node's ESM resolver never gets —
|
|
2045
|
+
* self-contained avoids that entirely and is just as faithful a stand-in.
|
|
2046
|
+
*/
|
|
2047
|
+
async function installSubpathLexiconPackage(): Promise<{ lexicon: string; specifier: string }> {
|
|
2048
|
+
const lexicon = `fold1995x${seq++}${Date.now().toString(36)}`;
|
|
2049
|
+
const specifier = `@intentius/chant-lexicon-${lexicon}`;
|
|
2050
|
+
const dir = join(testDir, "node_modules", specifier);
|
|
2051
|
+
await mkdir(join(dir, "generated"), { recursive: true });
|
|
2052
|
+
await writeFile(
|
|
2053
|
+
join(dir, "package.json"),
|
|
2054
|
+
JSON.stringify({
|
|
2055
|
+
name: specifier,
|
|
2056
|
+
version: "0.0.0",
|
|
2057
|
+
type: "module",
|
|
2058
|
+
exports: { ".": "./index.js", "./generated/index": "./generated/index.js" },
|
|
2059
|
+
}),
|
|
2060
|
+
);
|
|
2061
|
+
await writeFile(join(dir, "index.js"), `export * from "./generated/index.js";`);
|
|
2062
|
+
await writeFile(
|
|
2063
|
+
join(dir, "generated", "index.js"),
|
|
2064
|
+
`
|
|
2065
|
+
const DECLARABLE_MARKER = Symbol.for("chant.declarable");
|
|
2066
|
+
export function Widget(props) {
|
|
2067
|
+
this[DECLARABLE_MARKER] = true;
|
|
2068
|
+
this.lexicon = ${JSON.stringify(lexicon)};
|
|
2069
|
+
this.entityType = "Test::Widget";
|
|
2070
|
+
this.kind = "resource";
|
|
2071
|
+
this.props = props ?? {};
|
|
2072
|
+
}
|
|
2073
|
+
`,
|
|
2074
|
+
);
|
|
2075
|
+
return { lexicon, specifier };
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
test("a subpath import of a declared-active lexicon package is trusted, and folds under --sandbox", async () => {
|
|
2079
|
+
const { lexicon, specifier } = await installSubpathLexiconPackage();
|
|
2080
|
+
const file = join(testDir, "main.ts");
|
|
2081
|
+
await writeFile(
|
|
2082
|
+
file,
|
|
2083
|
+
`
|
|
2084
|
+
import { Widget } from ${JSON.stringify(`${specifier}/generated/index`)};
|
|
2085
|
+
export const widget = new Widget({ name: "test" });
|
|
2086
|
+
`,
|
|
2087
|
+
);
|
|
2088
|
+
|
|
2089
|
+
const sandboxed = await tryFoldFile(file, [], createFoldSession([], undefined, [lexicon], true));
|
|
2090
|
+
|
|
2091
|
+
expect(sandboxed.ok, !sandboxed.ok ? sandboxed.reason : "").toBe(true);
|
|
2092
|
+
if (!sandboxed.ok) return;
|
|
2093
|
+
const [, entity] = sandboxed.entities[0];
|
|
2094
|
+
expect(isDeclarable(entity)).toBe(true);
|
|
2095
|
+
});
|
|
2096
|
+
|
|
2097
|
+
test("a subpath import of a lexicon package NOT active for this build stays refused under --sandbox", async () => {
|
|
2098
|
+
// chant #1995's own boundary — the fix must not widen trust beyond the
|
|
2099
|
+
// build's declared-active set: an installed, importable package whose
|
|
2100
|
+
// root the build never listed is still untrusted, subpath or not.
|
|
2101
|
+
const { specifier } = await installSubpathLexiconPackage();
|
|
2102
|
+
const file = join(testDir, "main.ts");
|
|
2103
|
+
await writeFile(
|
|
2104
|
+
file,
|
|
2105
|
+
`
|
|
2106
|
+
import { Widget } from ${JSON.stringify(`${specifier}/generated/index`)};
|
|
2107
|
+
export const widget = new Widget({ name: "test" });
|
|
2108
|
+
`,
|
|
2109
|
+
);
|
|
2110
|
+
|
|
2111
|
+
const sandboxed = await tryFoldFile(file, [], createFoldSession([], undefined, ["aws"], true));
|
|
2112
|
+
|
|
2113
|
+
expect(sandboxed.ok).toBe(false);
|
|
2114
|
+
if (sandboxed.ok) return;
|
|
2115
|
+
expect(sandboxed.reason).toContain("--sandbox");
|
|
2116
|
+
expect(sandboxed.reason).toContain(`constructor "Widget"`);
|
|
2117
|
+
});
|
|
2118
|
+
});
|
|
2119
|
+
|
|
2018
2120
|
/**
|
|
2019
2121
|
* chant #1169 — a `new Type(...)` used as a VALUE.
|
|
2020
2122
|
*
|
|
@@ -1078,11 +1078,46 @@ function paramsModulePath(): string | null {
|
|
|
1078
1078
|
* - **Lexicons of THIS build only.** Not "any `@intentius/chant-lexicon-*`
|
|
1079
1079
|
* package on disk", and emphatically not "any bare specifier". A lexicon
|
|
1080
1080
|
* the build did not load is as out of scope as `node:fs`.
|
|
1081
|
+
*
|
|
1082
|
+
* chant #1995 — the "no subpaths" restriction above stays as documented for
|
|
1083
|
+
* THIS function's caller ({@link resolveActiveLexiconExport}, #1063's
|
|
1084
|
+
* data-export resolution): every lexicon's public data surface is reachable
|
|
1085
|
+
* from the barrel, so a subpath buys that caller nothing and would cost the
|
|
1086
|
+
* same cold-resolution risk. {@link isTrustedExecutableBinding} (#1093) asks a
|
|
1087
|
+
* different question — not "does this specifier name a reachable data
|
|
1088
|
+
* export" but "has the CLI already loaded this code" — and a project file
|
|
1089
|
+
* reaching a lexicon's generated module by subpath instead of through the
|
|
1090
|
+
* barrel is code the CLI already imported and executed either way, so that
|
|
1091
|
+
* caller resolves a subpath's package root separately, via
|
|
1092
|
+
* {@link bareSpecifierPackageRoot}, rather than widening this function.
|
|
1081
1093
|
*/
|
|
1082
1094
|
function activeLexiconPackage(specifier: string, lexiconPackages: ReadonlySet<string>): string | undefined {
|
|
1083
1095
|
return lexiconPackages.has(specifier) ? specifier : undefined;
|
|
1084
1096
|
}
|
|
1085
1097
|
|
|
1098
|
+
/**
|
|
1099
|
+
* chant #1995 — the package-ROOT portion of a bare subpath specifier
|
|
1100
|
+
* (`@intentius/chant-lexicon-azure/generated/index` ->
|
|
1101
|
+
* `@intentius/chant-lexicon-azure`), or `undefined` when `specifier` is a
|
|
1102
|
+
* relative/absolute path, is already a bare root with no subpath, or is
|
|
1103
|
+
* malformed (a lone `@scope` with nothing after it).
|
|
1104
|
+
*
|
|
1105
|
+
* Text only, no resolution — the same discipline {@link activeLexiconPackage}
|
|
1106
|
+
* documents and for the identical reason: resolving an arbitrary bare
|
|
1107
|
+
* specifier to find out what package it belongs to is the pathological cold
|
|
1108
|
+
* `require.resolve` chant#1020 measured. A scoped package (`@scope/name`)
|
|
1109
|
+
* reserves its first two `/`-separated segments for the root; an unscoped
|
|
1110
|
+
* package (`name`) reserves one.
|
|
1111
|
+
*/
|
|
1112
|
+
function bareSpecifierPackageRoot(specifier: string): string | undefined {
|
|
1113
|
+
if (specifier.startsWith(".") || isAbsolute(specifier)) return undefined;
|
|
1114
|
+
const segments = specifier.split("/");
|
|
1115
|
+
if (specifier.startsWith("@")) {
|
|
1116
|
+
return segments.length > 2 ? `${segments[0]}/${segments[1]}` : undefined;
|
|
1117
|
+
}
|
|
1118
|
+
return segments.length > 1 ? segments[0] : undefined;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1086
1121
|
/**
|
|
1087
1122
|
* chant #1063 — resolve one named import binding against an active lexicon
|
|
1088
1123
|
* package's REAL exports, for {@link buildExternals}.
|
|
@@ -2706,10 +2741,19 @@ function isChantOwnedHelperBinding(binding: ImportBinding, ctx: ResolveCtx): boo
|
|
|
2706
2741
|
* in the CLI's own process when the #1045 sandbox is active. Exactly two
|
|
2707
2742
|
* arms, and neither of them trusts text the project controls:
|
|
2708
2743
|
*
|
|
2709
|
-
* 1. **An ACTIVE lexicon package of this build** —
|
|
2710
|
-
* {@link FoldSession.lexiconPackages}, a
|
|
2711
|
-
* lexicon names the BUILD resolved and
|
|
2712
|
-
* (../cli/plugins.ts), not from anything
|
|
2744
|
+
* 1. **An ACTIVE lexicon package of this build, root OR subpath** — the
|
|
2745
|
+
* exact specifier matched against {@link FoldSession.lexiconPackages}, a
|
|
2746
|
+
* closed set built from the lexicon names the BUILD resolved and
|
|
2747
|
+
* `loadPlugins` already imported (../cli/plugins.ts), not from anything
|
|
2748
|
+
* the file under fold says; OR (chant #1995) a subpath of one of those
|
|
2749
|
+
* same packages (`@intentius/chant-lexicon-azure/generated/index`), whose
|
|
2750
|
+
* package ROOT is extracted from the specifier TEXT
|
|
2751
|
+
* ({@link bareSpecifierPackageRoot}, no resolution) and checked against
|
|
2752
|
+
* the identical set. A subpath is trusted because the code it names
|
|
2753
|
+
* belongs to a package the CLI already imported and executed to load that
|
|
2754
|
+
* lexicon's serializers/lint rules — reaching one of that package's own
|
|
2755
|
+
* modules by subpath instead of through the barrel names code the process
|
|
2756
|
+
* already trusts, not code the project controls.
|
|
2713
2757
|
* 2. **chant-core's own executing tree** — the specifier is RESOLVED and the
|
|
2714
2758
|
* resulting path checked against {@link chantCoreRoot}. A text match is
|
|
2715
2759
|
* not enough here: `@intentius/chant` and `@intentius/chant-lexicon-evil`
|
|
@@ -2738,6 +2782,12 @@ function isChantOwnedHelperBinding(binding: ImportBinding, ctx: ResolveCtx): boo
|
|
|
2738
2782
|
*/
|
|
2739
2783
|
function isTrustedExecutableBinding(binding: ImportBinding, ctx: ResolveCtx): boolean {
|
|
2740
2784
|
if (activeLexiconPackage(binding.specifier, ctx.lexiconPackages) !== undefined) return true;
|
|
2785
|
+
// chant #1995 — arm 1's subpath case: same allowlist, matched against the
|
|
2786
|
+
// specifier's package ROOT (text only, see bareSpecifierPackageRoot) rather
|
|
2787
|
+
// than the specifier itself, so `<activeLexiconPkg>/generated/index` is
|
|
2788
|
+
// trusted exactly like the bare package import already is.
|
|
2789
|
+
const subpathRoot = bareSpecifierPackageRoot(binding.specifier);
|
|
2790
|
+
if (subpathRoot !== undefined && activeLexiconPackage(subpathRoot, ctx.lexiconPackages) !== undefined) return true;
|
|
2741
2791
|
// Only a chant-shaped or project-relative specifier is worth resolving; any
|
|
2742
2792
|
// other bare specifier is untrusted by definition, and resolving it to find
|
|
2743
2793
|
// that out would cost the pathological cold `require.resolve` (chant#1020).
|