@intentius/chant 0.124.48 → 0.124.50
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/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +20 -12
- package/dist/cli/handlers/graph-reader.d.ts +26 -0
- package/dist/cli/handlers/graph-reader.d.ts.map +1 -0
- package/dist/cli/handlers/graph-reader.js +41 -0
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/graph.js +14 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +40 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +22 -0
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +11 -0
- package/dist/cli/registry.d.ts +8 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/pr-run.d.ts +3 -0
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +1 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -0
- package/dist/gate-decision.d.ts +9 -0
- package/dist/gate-decision.d.ts.map +1 -1
- package/dist/gate-decision.js +22 -7
- package/dist/lexicon.d.ts +23 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/gate-approval-base.d.ts +25 -0
- package/dist/op/gate-approval-base.d.ts.map +1 -0
- package/dist/op/gate-approval-base.js +71 -0
- package/dist/op/gate-approval.d.ts +7 -4
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-approval.js +2 -25
- package/dist/op/gate-name.d.ts +13 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-name.js +14 -1
- package/dist/op/gate.d.ts +4 -3
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +9 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +24 -5
- package/dist/pr-loop.d.ts +10 -0
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +4 -0
- package/dist/pr-migrations.d.ts +62 -0
- package/dist/pr-migrations.d.ts.map +1 -0
- package/dist/pr-migrations.js +50 -0
- package/dist/pr-post.js +1 -1
- package/dist/wave-runtime.mjs +219 -218
- package/dist/workspace/check.schema.json +4 -2
- package/dist/workspace/checks/gate-approval.d.ts +58 -0
- package/dist/workspace/checks/gate-approval.d.ts.map +1 -0
- package/dist/workspace/checks/gate-approval.js +103 -0
- package/dist/workspace/checks.d.ts +1 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +4 -0
- package/dist/workspace/declaration.d.ts +7 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +13 -1
- package/dist/workspace/declaration.schema.json +33 -0
- package/dist/workspace/exit-cli.d.ts +44 -0
- package/dist/workspace/exit-cli.d.ts.map +1 -0
- package/dist/workspace/exit-cli.js +198 -0
- package/dist/workspace/exit.d.ts +159 -0
- package/dist/workspace/exit.d.ts.map +1 -0
- package/dist/workspace/exit.js +480 -0
- package/dist/workspace/exit.schema.json +257 -0
- package/dist/workspace/graph-cache.d.ts +3 -1
- package/dist/workspace/graph-cache.d.ts.map +1 -1
- package/dist/workspace/graph-cache.js +12 -1
- package/dist/workspace/import-fetch.d.ts +38 -0
- package/dist/workspace/import-fetch.d.ts.map +1 -0
- package/dist/workspace/import-fetch.js +99 -0
- package/dist/workspace/import-init.d.ts +26 -0
- package/dist/workspace/import-init.d.ts.map +1 -0
- package/dist/workspace/import-init.js +130 -0
- package/dist/workspace/import-source.d.ts +147 -0
- package/dist/workspace/import-source.d.ts.map +1 -0
- package/dist/workspace/import-source.js +370 -0
- package/dist/workspace/init.d.ts +4 -0
- package/dist/workspace/init.d.ts.map +1 -1
- package/dist/workspace/init.js +70 -3
- package/dist/workspace/kind-readers.d.ts +6 -0
- package/dist/workspace/kind-readers.d.ts.map +1 -1
- package/dist/workspace/kind-readers.js +18 -0
- package/dist/workspace/kinds.d.ts +24 -1
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/kinds.js +64 -4
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.js +13 -4
- package/dist/workspace/new-migrations.d.ts +47 -0
- package/dist/workspace/new-migrations.d.ts.map +1 -0
- package/dist/workspace/new-migrations.js +91 -0
- package/dist/workspace/pr-report.schema.json +46 -0
- package/dist/workspace/promote.d.ts +96 -0
- package/dist/workspace/promote.d.ts.map +1 -0
- package/dist/workspace/promote.js +263 -0
- package/dist/workspace/reason-codes.d.ts +13 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +14 -0
- package/dist/workspace/search-contract.d.ts +1 -1
- package/dist/workspace/search-contract.d.ts.map +1 -1
- package/dist/workspace/workspace-kinds.schema.json +50 -7
- package/package.json +1 -1
- package/src/cli/handlers/approve.ts +10 -2
- package/src/cli/handlers/graph-reader.test.ts +31 -0
- package/src/cli/handlers/graph-reader.ts +51 -0
- package/src/cli/handlers/graph.ts +14 -0
- package/src/cli/handlers/pr.ts +38 -1
- package/src/cli/main.test.ts +2 -0
- package/src/cli/main.ts +22 -0
- package/src/cli/parse-args.ts +8 -0
- package/src/cli/registry.ts +8 -0
- package/src/components/pr-env-run-bundle.test.ts +1 -0
- package/src/components/pr-run.ts +4 -0
- package/src/config.ts +16 -0
- package/src/gate-decision-bundle.test.ts +4 -2
- package/src/gate-decision-identity.test.ts +11 -0
- package/src/gate-decision.ts +26 -6
- package/src/lexicon.ts +24 -0
- package/src/op/gate-approval-at-base.test.ts +97 -0
- package/src/op/gate-approval-base.ts +72 -0
- package/src/op/gate-approval.ts +10 -21
- package/src/op/gate-name.ts +16 -1
- package/src/op/gate.ts +4 -3
- package/src/op/local-executor.ts +25 -5
- package/src/pr-loop-bundle.test.ts +3 -2
- package/src/pr-loop.test.ts +52 -0
- package/src/pr-loop.ts +12 -0
- package/src/pr-migrations.ts +97 -0
- package/src/pr-post.ts +1 -1
- package/src/workspace/__fixtures__/import/spacelift.json +9 -0
- package/src/workspace/__fixtures__/import/tfc.json +13 -0
- package/src/workspace/behold-kinds.test.ts +1 -0
- package/src/workspace/check-contract.test.ts +3 -2
- package/src/workspace/check.schema.json +4 -2
- package/src/workspace/checks/gate-approval.test.ts +77 -0
- package/src/workspace/checks/gate-approval.ts +121 -0
- package/src/workspace/checks.ts +4 -0
- package/src/workspace/declaration.schema.json +33 -0
- package/src/workspace/declaration.ts +19 -2
- package/src/workspace/exit-cli.ts +219 -0
- package/src/workspace/exit.schema.json +257 -0
- package/src/workspace/exit.test.ts +185 -0
- package/src/workspace/exit.ts +560 -0
- package/src/workspace/graph-cache.ts +12 -2
- package/src/workspace/identity.test.ts +18 -3
- package/src/workspace/import-fetch.ts +151 -0
- package/src/workspace/import-init.ts +147 -0
- package/src/workspace/import-source.test.ts +183 -0
- package/src/workspace/import-source.ts +462 -0
- package/src/workspace/init.test.ts +55 -1
- package/src/workspace/init.ts +66 -4
- package/src/workspace/kind-readers.test.ts +36 -0
- package/src/workspace/kind-readers.ts +18 -1
- package/src/workspace/kinds.test.ts +23 -0
- package/src/workspace/kinds.ts +81 -5
- package/src/workspace/lineage-upgrade.test.ts +20 -0
- package/src/workspace/lineage-upgrade.ts +16 -7
- package/src/workspace/new-migrations.test.ts +98 -0
- package/src/workspace/new-migrations.ts +109 -0
- package/src/workspace/pr-report.schema.json +46 -0
- package/src/workspace/promote.test.ts +114 -0
- package/src/workspace/promote.ts +302 -0
- package/src/workspace/reason-codes.test.ts +4 -0
- package/src/workspace/reason-codes.ts +14 -0
- package/src/workspace/workspace-kinds.schema.json +50 -7
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant graph` over a graph reader module (#4413).
|
|
3
|
+
*
|
|
4
|
+
* A kind's `graph.reader` names a module in the package that supplies the
|
|
5
|
+
* kind; `chant workspace graph` writes a reader project whose
|
|
6
|
+
* `graph.reader.module` is that module, and the member's own `chant graph`
|
|
7
|
+
* runs here. Because the member's chant loads it, the reader runs under the
|
|
8
|
+
* member's toolchain, as a lexicon reader does. The module's default export
|
|
9
|
+
* is `(input: { dir, config }) => GraphIR`, sync or async. It reads files and
|
|
10
|
+
* returns data; chant gives it no write handle, and nothing it returns is
|
|
11
|
+
* run.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { pathToFileURL } from "node:url";
|
|
15
|
+
import { isAbsolute } from "node:path";
|
|
16
|
+
import type { GraphIR } from "../../graph-ir";
|
|
17
|
+
|
|
18
|
+
export interface GraphReaderInput {
|
|
19
|
+
/** The member's directory, absolute, as the kind's config placed it in `config.dir` (or the reader project when it didn't). */
|
|
20
|
+
dir: string;
|
|
21
|
+
/** The kind's `graph.config`, placeholders substituted. */
|
|
22
|
+
config: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Load the module and call it, or say why not. Never throws. */
|
|
26
|
+
export async function readGraphWithModule(
|
|
27
|
+
module: string,
|
|
28
|
+
config: Record<string, unknown>,
|
|
29
|
+
projectPath: string,
|
|
30
|
+
): Promise<{ ir: GraphIR } | { error: string }> {
|
|
31
|
+
if (!isAbsolute(module)) return { error: `graph.reader.module must be an absolute path, got ${module}` };
|
|
32
|
+
let read: unknown;
|
|
33
|
+
try {
|
|
34
|
+
read = ((await import(pathToFileURL(module).href)) as { default?: unknown }).default;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return { error: `graph reader ${module} could not be loaded: ${err instanceof Error ? err.message : String(err)}` };
|
|
37
|
+
}
|
|
38
|
+
if (typeof read !== "function") return { error: `graph reader ${module} has no default export function` };
|
|
39
|
+
let out: unknown;
|
|
40
|
+
try {
|
|
41
|
+
const dir = typeof config.dir === "string" ? config.dir : projectPath;
|
|
42
|
+
out = await (read as (input: GraphReaderInput) => unknown)({ dir, config });
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return { error: `graph reader ${module} failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
45
|
+
}
|
|
46
|
+
const ir = out as Partial<GraphIR> | null;
|
|
47
|
+
if (ir === null || typeof ir !== "object" || !Array.isArray(ir.nodes) || !Array.isArray(ir.edges)) {
|
|
48
|
+
return { error: `graph reader ${module} returned something with no nodes and edges` };
|
|
49
|
+
}
|
|
50
|
+
return { ir: { ...ir, nodes: ir.nodes, edges: ir.edges, groups: ir.groups ?? {} } as GraphIR };
|
|
51
|
+
}
|
|
@@ -118,6 +118,20 @@ export function runGraph(ctx: CommandContext): Promise<number> {
|
|
|
118
118
|
async function runGraphCommand(ctx: CommandContext): Promise<number> {
|
|
119
119
|
const viewFormats = ["ir", "mermaid", "dot", "layout"] as const;
|
|
120
120
|
const isViewFormat = (viewFormats as readonly string[]).includes(ctx.args.format);
|
|
121
|
+
// A project whose config names a graph reader module is read by it (#4413): `chant workspace graph` writes such a project for a kind that supplies one.
|
|
122
|
+
if (isViewFormat && !ctx.args.live && !ctx.args.at && !ctx.args.components) {
|
|
123
|
+
const { config } = await loadChantConfigUpward(resolve(ctx.args.path === "." ? "." : ctx.args.path)).catch(() => ({ config: {} as ChantConfig }));
|
|
124
|
+
const reader = config.graph?.reader;
|
|
125
|
+
if (reader) {
|
|
126
|
+
const { readGraphWithModule } = await import("./graph-reader");
|
|
127
|
+
const read = await readGraphWithModule(reader.module, reader.config ?? {}, resolve(ctx.args.path === "." ? "." : ctx.args.path));
|
|
128
|
+
if ("error" in read) {
|
|
129
|
+
console.error(formatError({ message: read.error }));
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
return emitIr(read.ir, ctx, ctx.args.format as (typeof viewFormats)[number]);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
121
135
|
// `--projection <lexicon>` (#989) only means anything for the component
|
|
122
136
|
// graph's IR — it adds the CI/pipeline shape to `GraphIR.pipeline`, a field
|
|
123
137
|
// the other view formats' emitters (mermaid/dot/layout) don't read, and the
|
package/src/cli/handlers/pr.ts
CHANGED
|
@@ -135,6 +135,8 @@ import { prLoopHooksAt, runPrHooks, type PrHoldSettled, type PrHookPlace, type P
|
|
|
135
135
|
import { decidePrPolicyOverride, policyReviewOf, prLoopPolicyAt, prPolicyReport, runPrPolicy, spendPrPolicyOverride, type PrLoopPolicy } from "../../components/pr-policy";
|
|
136
136
|
import type { OpWaveHookMoment } from "../../op/op-waves";
|
|
137
137
|
import type { PrHookOutcome } from "../../pr-loop";
|
|
138
|
+
import type { PrMigrationFile } from "../../pr-migrations";
|
|
139
|
+
import { lexiconNames } from "../../lexicon-module";
|
|
138
140
|
|
|
139
141
|
|
|
140
142
|
/** Where the reports land unless `--output` says. */
|
|
@@ -525,7 +527,7 @@ async function publish(forge: PrForge | undefined, report: PrReport, note: strin
|
|
|
525
527
|
if (report.pr === null) return;
|
|
526
528
|
// A member the change does not reach has nothing to say: one note per
|
|
527
529
|
// member would put a line on every pull request for each member it skips.
|
|
528
|
-
if (report.member && report.status === "nothing" && !report.overdue?.length) return;
|
|
530
|
+
if (report.member && report.status === "nothing" && !report.overdue?.length && !report.migrations?.length) return;
|
|
529
531
|
try {
|
|
530
532
|
await forge.upsertNote(report.pr, prNoteMarker(report.env, report.member), note);
|
|
531
533
|
} catch (err) {
|
|
@@ -726,8 +728,10 @@ async function prPlan(ctx: CommandContext, stage: StageEvents): Promise<number>
|
|
|
726
728
|
}
|
|
727
729
|
const status = p.plan.order.length === 0 ? "nothing" : set.failed ? "plan-failed" : "planned";
|
|
728
730
|
const overdue = await overdueOps(p.forge, p.member);
|
|
731
|
+
const migrations = await newMigrations(p);
|
|
729
732
|
const planned = prReport({
|
|
730
733
|
stage: "plan",
|
|
734
|
+
...(migrations.length > 0 ? { migrations } : {}),
|
|
731
735
|
pr: ctx.args.pr,
|
|
732
736
|
env: p.env,
|
|
733
737
|
...(p.member ? { member: p.member } : {}),
|
|
@@ -757,6 +761,39 @@ async function prPlan(ctx: CommandContext, stage: StageEvents): Promise<number>
|
|
|
757
761
|
return status === "plan-failed" || judged.stop ? 1 : 0;
|
|
758
762
|
}
|
|
759
763
|
|
|
764
|
+
/**
|
|
765
|
+
* The migration files the pull request adds in a migration-directory member
|
|
766
|
+
* (#4407), each statement classified offline by the sql lexicon. Nothing is
|
|
767
|
+
* applied and no server is contacted. A workspace without such a member, or
|
|
768
|
+
* a classifier that cannot load, leaves the note as it was.
|
|
769
|
+
*/
|
|
770
|
+
async function newMigrations(p: Prepared): Promise<PrMigrationFile[]> {
|
|
771
|
+
const { newMigrationFilesBetween } = await import("../../workspace/new-migrations");
|
|
772
|
+
try {
|
|
773
|
+
return await newMigrationFilesBetween(resolve("."), p.base, p.head, {
|
|
774
|
+
...(p.member ? { member: p.member } : {}),
|
|
775
|
+
plugins: async () => {
|
|
776
|
+
const { loadPlugin } = await import("../plugins");
|
|
777
|
+
const names = ["sql", ...lexiconNames(p.config.lexicons ?? []).filter((n) => n !== "sql")];
|
|
778
|
+
const loaded = [];
|
|
779
|
+
for (const name of names) {
|
|
780
|
+
try {
|
|
781
|
+
const plugin = await loadPlugin(name);
|
|
782
|
+
if (plugin.classifyMigrations) loaded.push(plugin);
|
|
783
|
+
if (loaded.length > 0) break;
|
|
784
|
+
} catch {
|
|
785
|
+
// not installed: the next name, or the note says no lexicon classifies the dialect
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return loaded;
|
|
789
|
+
},
|
|
790
|
+
});
|
|
791
|
+
} catch (err) {
|
|
792
|
+
console.error(formatWarning({ message: `could not classify the new migration files: ${err instanceof Error ? err.message : String(err)}` }));
|
|
793
|
+
return [];
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
760
797
|
/**
|
|
761
798
|
* The scheduled watch and reconcile Ops that stopped running (#4289,
|
|
762
799
|
* `op/schedule-overdue.ts`), asked of the forge. Without a forge, or when
|
package/src/cli/main.test.ts
CHANGED
|
@@ -852,6 +852,7 @@ describe("workspace init and ls (#2534)", () => {
|
|
|
852
852
|
"workspace build",
|
|
853
853
|
"workspace check",
|
|
854
854
|
"workspace evidence",
|
|
855
|
+
"workspace exit",
|
|
855
856
|
"workspace export",
|
|
856
857
|
"workspace graph",
|
|
857
858
|
"workspace hash-index",
|
|
@@ -868,6 +869,7 @@ describe("workspace init and ls (#2534)", () => {
|
|
|
868
869
|
"workspace patch",
|
|
869
870
|
"workspace pin",
|
|
870
871
|
"workspace points",
|
|
872
|
+
"workspace promote",
|
|
871
873
|
"workspace records",
|
|
872
874
|
"workspace run",
|
|
873
875
|
"workspace runs",
|
package/src/cli/main.ts
CHANGED
|
@@ -614,6 +614,16 @@ Workspace (level 1, #2524):
|
|
|
614
614
|
write it only on confirmation (--yes writes without
|
|
615
615
|
asking; --name <name> names the workspace; --verbose
|
|
616
616
|
lists every file leaving the root project)
|
|
617
|
+
--from tfc|spacelift|backstage proposes from a
|
|
618
|
+
Terraform Cloud organization (--org, or --input <export>),
|
|
619
|
+
a Spacelift account (--endpoint, or --input <export>) or
|
|
620
|
+
the catalog-info.yaml files under dir, and shows what
|
|
621
|
+
it left out (#3178)
|
|
622
|
+
workspace promote <member> [--state <tfstate>] [--lexicon <name>] [--dry-run] [--output <patch>] [--json]
|
|
623
|
+
Move a member one rung up the kind ladder (terraform to
|
|
624
|
+
choudoufu runs live-check and the adoption report;
|
|
625
|
+
choudoufu or other to chant runs chant import), staged
|
|
626
|
+
in a worktree and gated on the patch digest like upgrade
|
|
617
627
|
workspace ls [dir] List the declaration's members and example groups.
|
|
618
628
|
A member that can't be read is listed with a reason
|
|
619
629
|
code and still exits 0. --at <rev> reads a commit's
|
|
@@ -861,6 +871,15 @@ Workspace (level 1, #2524):
|
|
|
861
871
|
manual steps. Needs no workspace file
|
|
862
872
|
workspace lineage resolve <path>
|
|
863
873
|
Close a manual step once the file is merged by hand
|
|
874
|
+
workspace exit [<member>...] [--dry-run] [--drill] [--output <file>] [--json]
|
|
875
|
+
Leave chant: build each chant member once, commit the
|
|
876
|
+
native output (CloudFormation, ARM, Kubernetes YAML,
|
|
877
|
+
Compose, CI files), point its CI at the platform's own
|
|
878
|
+
CLI, drop the chant dependencies and set the member to
|
|
879
|
+
other. Staged in a worktree and gated on the patch
|
|
880
|
+
digest (chant approve workspace-exit <scope>). op.json
|
|
881
|
+
files stay. --drill runs it on a scratch copy and
|
|
882
|
+
checks each member, changing nothing.
|
|
864
883
|
workspace upgrade [<scope>] [--to <ref|dir>] [--source <repo>[#<member>]] [--allow-code] [--dry-run] [--output <file>]
|
|
865
884
|
Bring a lineage scope to a newer template version: fetch
|
|
866
885
|
it, migrate and merge per file in a worktree, run build,
|
|
@@ -1655,6 +1674,9 @@ export const commandRegistry: CommandDef[] = [
|
|
|
1655
1674
|
{ name: "workspace metrics", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/delivery-metrics")).runWorkspaceMetrics(ctx) },
|
|
1656
1675
|
{ name: "workspace status", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/status")).runWorkspaceStatus(ctx) },
|
|
1657
1676
|
{ name: "workspace lineage", handler: async (ctx) => (await import("../workspace/lineage-cli")).runWorkspaceLineage(ctx) },
|
|
1677
|
+
{ name: "workspace exit", handler: async (ctx) => (await import("../workspace/exit-cli")).runWorkspaceExit(ctx) },
|
|
1678
|
+
// #3178 — move a member one rung up the kind ladder, staged in a worktree and gated on the patch digest.
|
|
1679
|
+
{ name: "workspace promote", handler: async (ctx) => (await import("../workspace/promote")).runWorkspacePromote(ctx) },
|
|
1658
1680
|
{ name: "workspace upgrade", handler: async (ctx) => (await import("../workspace/lineage-upgrade-cli")).runWorkspaceUpgrade(ctx) },
|
|
1659
1681
|
// #2551 — adopt a lineage, the hash index it matches against, and the versions of a family of workspaces.
|
|
1660
1682
|
{ name: "workspace adopt-lineage", handler: async (ctx) => (await import("../workspace/lineage-adopt-cli")).runWorkspaceAdoptLineage(ctx) },
|
package/src/cli/parse-args.ts
CHANGED
|
@@ -284,6 +284,9 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
284
284
|
result.emit = args[++i];
|
|
285
285
|
} else if (arg === "--dry-run") {
|
|
286
286
|
result.dryRun = true;
|
|
287
|
+
} else if (arg === "--drill") {
|
|
288
|
+
// #3177 — `chant workspace exit --drill` runs the exit on a scratch copy.
|
|
289
|
+
result.drill = true;
|
|
287
290
|
} else if (arg === "--yes") {
|
|
288
291
|
result.yes = true;
|
|
289
292
|
} else if (arg === "--push") {
|
|
@@ -922,6 +925,11 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
922
925
|
const value = args[++i];
|
|
923
926
|
if (!value || value.startsWith("-")) throw new Error(`${arg} needs a value: ${arg} <${arg.slice(2)}>`);
|
|
924
927
|
result[arg.slice(2) as "holder" | "ttl" | "token" | "outcome"] = value;
|
|
928
|
+
} else if (arg === "--input" || arg === "--org" || arg === "--endpoint") {
|
|
929
|
+
// `chant workspace init --from tfc|spacelift` (#3178): an exported file instead of the API, the Terraform Cloud organization, the Spacelift GraphQL endpoint.
|
|
930
|
+
const value = args[++i];
|
|
931
|
+
if (!value || value.startsWith("-")) throw new Error(`${arg} needs a value: ${arg} <${arg === "--input" ? "file" : arg.slice(2)}>`);
|
|
932
|
+
result[arg.slice(2) as "input" | "org" | "endpoint"] = value;
|
|
925
933
|
} else if (arg === "--signer") {
|
|
926
934
|
// `chant workspace runs statement <id> --signer <principal>` (#3192): the runner principal that will sign.
|
|
927
935
|
const value = args[++i];
|
package/src/cli/registry.ts
CHANGED
|
@@ -165,6 +165,8 @@ export interface ParsedArgs {
|
|
|
165
165
|
remove?: boolean;
|
|
166
166
|
/** `chant lifecycle rollback --dry-run` — compute the rollback delta and print it; open no PR, push nothing, leave no branch. */
|
|
167
167
|
dryRun?: boolean;
|
|
168
|
+
/** `chant workspace exit --drill` (#3177): run the exit on a scratch copy and check it, changing nothing. */
|
|
169
|
+
drill?: boolean;
|
|
168
170
|
/** `chant lifecycle teardown <env> --yes` — execute the planned deletion
|
|
169
171
|
* (#1222). Without it the command plans and stops. */
|
|
170
172
|
yes?: boolean;
|
|
@@ -628,6 +630,12 @@ export interface ParsedArgs {
|
|
|
628
630
|
claim?: string;
|
|
629
631
|
/** `chant workspace evidence sign --environment <file>` (#2553): the runner environment, hashed into the evidence. */
|
|
630
632
|
environment?: string;
|
|
633
|
+
/** `chant workspace init --from tfc|spacelift --input <file>` (#3178): an exported file read instead of the API. */
|
|
634
|
+
input?: string;
|
|
635
|
+
/** `chant workspace init --from tfc --org <name>` (#3178): the Terraform Cloud organization. */
|
|
636
|
+
org?: string;
|
|
637
|
+
/** `chant workspace init --from spacelift --endpoint <url>` (#3178): the account's GraphQL endpoint. */
|
|
638
|
+
endpoint?: string;
|
|
631
639
|
/** `chant workspace runs statement <id> --signer <principal>` (#3192): the runner principal that will sign. */
|
|
632
640
|
signer?: string;
|
|
633
641
|
/** `chant workspace evidence verify --envelope <file>` (#2553). */
|
|
@@ -47,6 +47,7 @@ describe("the pr-env-run bundle", () => {
|
|
|
47
47
|
"packages/core/src/op/emulator-lifecycle.ts",
|
|
48
48
|
"packages/core/src/plan-summary.ts",
|
|
49
49
|
"packages/core/src/pr-loop.ts",
|
|
50
|
+
"packages/core/src/pr-migrations.ts",
|
|
50
51
|
]);
|
|
51
52
|
expect(inputs.some((i) => /node_modules\/(ajv|js-yaml)\//.test(i) || i.endsWith("/op/gate.ts") || i.endsWith("/src/config.ts") || i.endsWith("/ts-loader.ts"))).toBe(false);
|
|
52
53
|
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(40 * 1024);
|
package/src/components/pr-run.ts
CHANGED
|
@@ -99,6 +99,7 @@ import type { CapabilityRegistry, PlannedOutputs } from "./capability";
|
|
|
99
99
|
import type { RunProgressEvent } from "./run-progress";
|
|
100
100
|
import type { RunEventSink } from "../run-event-stream";
|
|
101
101
|
import { capabilitySteps, planWaveComponent, readDeployedOutputs } from "./wave-plan";
|
|
102
|
+
import type { PrMigrationFile } from "../pr-migrations";
|
|
102
103
|
|
|
103
104
|
export interface PrPlanSetOptions {
|
|
104
105
|
/** Every component in the project, not the selection: a planned member's dependency outside it is read too (#4104). */
|
|
@@ -773,6 +774,8 @@ export interface PrReportInput {
|
|
|
773
774
|
waves?: PrWave[];
|
|
774
775
|
/** The rounds the apply planned after an upstream applied (#3999). */
|
|
775
776
|
rounds?: PrRound[];
|
|
777
|
+
/** Plan stage (#4407): the new migration files, classified. */
|
|
778
|
+
migrations?: PrMigrationFile[];
|
|
776
779
|
}
|
|
777
780
|
|
|
778
781
|
/** The report a stage writes. */
|
|
@@ -797,6 +800,7 @@ export function prReport(input: PrReportInput): PrReport {
|
|
|
797
800
|
...(input.set.deployedOutputs ? { deployedOutputs: input.set.deployedOutputs } : {}),
|
|
798
801
|
...(input.set.plannedOutputs ? { plannedOutputs: input.set.plannedOutputs } : {}),
|
|
799
802
|
...(input.rounds && input.rounds.length > 0 ? { rounds: input.rounds } : {}),
|
|
803
|
+
...(input.migrations && input.migrations.length > 0 ? { migrations: input.migrations } : {}),
|
|
800
804
|
approval: input.approval,
|
|
801
805
|
...(input.waveGate ? { waveGate: input.waveGate } : {}),
|
|
802
806
|
...(input.waveGate && input.waves?.length ? { waves: [...input.waves].sort((a, b) => a.wave - b.wave) } : {}),
|
package/src/config.ts
CHANGED
|
@@ -76,6 +76,10 @@ export const ChantConfigSchema = z.object({
|
|
|
76
76
|
// Strict like `run`: a misspelt `lint` would leave the gate on with nothing said.
|
|
77
77
|
graph: z.object({
|
|
78
78
|
lint: z.enum(["gate", "report"]).optional(),
|
|
79
|
+
reader: z.object({
|
|
80
|
+
module: z.string().min(1),
|
|
81
|
+
config: z.record(z.string(), z.unknown()).optional(),
|
|
82
|
+
}).strict().optional(),
|
|
79
83
|
}).strict().optional(),
|
|
80
84
|
buildParams: z.record(
|
|
81
85
|
z.string(),
|
|
@@ -234,6 +238,18 @@ export interface ChantConfig {
|
|
|
234
238
|
*/
|
|
235
239
|
graph?: {
|
|
236
240
|
lint?: "gate" | "report";
|
|
241
|
+
/**
|
|
242
|
+
* A graph reader module (#4413): when set, `chant graph --format ir` loads
|
|
243
|
+
* `module` (an absolute path to an ES module whose default export is
|
|
244
|
+
* `(input: { dir, config }) => GraphIR`) and prints what it returns,
|
|
245
|
+
* instead of discovering lexicon entities. The reader project
|
|
246
|
+
* `chant workspace graph` writes for a kind whose `graph.reader` names a
|
|
247
|
+
* module sets it. A chant older than the key ignores it.
|
|
248
|
+
*/
|
|
249
|
+
reader?: {
|
|
250
|
+
module: string;
|
|
251
|
+
config?: Record<string, unknown>;
|
|
252
|
+
};
|
|
237
253
|
};
|
|
238
254
|
|
|
239
255
|
/**
|
|
@@ -45,6 +45,8 @@ describe("the gate-decision bundle", () => {
|
|
|
45
45
|
"packages/core/src/lifecycle/plan-digest.ts",
|
|
46
46
|
"packages/core/src/notify-verify.ts",
|
|
47
47
|
"packages/core/src/notify.ts",
|
|
48
|
+
"packages/core/src/op/gate-approval-base.ts",
|
|
49
|
+
"packages/core/src/op/gate-plan-context.ts",
|
|
48
50
|
"packages/core/src/op/gate-point.ts",
|
|
49
51
|
"packages/core/src/op/gate-quorum.ts",
|
|
50
52
|
"packages/core/src/op/op-waves.ts",
|
|
@@ -55,8 +57,8 @@ describe("the gate-decision bundle", () => {
|
|
|
55
57
|
"packages/core/src/workspace/jsonc.ts",
|
|
56
58
|
"packages/core/src/workspace/trust/trust-files.ts",
|
|
57
59
|
]);
|
|
58
|
-
// About 49 KB unminified today: 7 KB of it the pinned classes read as data (#3888), 6 KB the approval rules (#4290).
|
|
60
|
+
// About 49 KB unminified today: 7 KB of it the pinned classes read as data (#3888), 6 KB the approval rules (#4290), and the base approval checks (#4404) bring it to about 55 KB.
|
|
59
61
|
const bytes = result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0);
|
|
60
|
-
expect(bytes).toBeLessThan(
|
|
62
|
+
expect(bytes).toBeLessThan(58 * 1024);
|
|
61
63
|
});
|
|
62
64
|
});
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { gateAdmission } from "./workspace/identity";
|
|
20
20
|
import { hasSshKeygen, TestRepo, type Key } from "./workspace/trust/test-repo";
|
|
21
21
|
import { evaluateGate, memoryGateLedgerPort } from "./op/gate";
|
|
22
|
+
import { gatePolicyVersion } from "./op/gate-approval";
|
|
22
23
|
|
|
23
24
|
const D1 = "sha256:" + "a".repeat(64);
|
|
24
25
|
const GATES = { ship: {}, deploy: { class: "human" }, release: { class: "runner" }, ops: { class: "operator" } };
|
|
@@ -227,6 +228,16 @@ describe.skipIf(!hasSshKeygen)("identity.gates read at base, as chant reads it",
|
|
|
227
228
|
expect(here.requirement.quorum).toEqual(gateAdmission(r.dir, "ship", base)!.requirement.quorum);
|
|
228
229
|
expect(identityGateAdmissionAt(r.dir, base, "deploy")!.requirement).toEqual({ gate: "deploy", class: "human" });
|
|
229
230
|
});
|
|
231
|
+
|
|
232
|
+
test("a gate's policy, mode and context at base, as chant reads them (#4404)", () => {
|
|
233
|
+
const text = "permit (principal, action, resource);";
|
|
234
|
+
const policy = { kind: "gate-policy", lexicon: "cedar", name: "ship", version: gatePolicyVersion(text), text };
|
|
235
|
+
const { r, base } = setup(() => ({}), { identity: { gates: { ship: { policy, mode: "enforce", context: { risk: "high" } } } } });
|
|
236
|
+
const here = identityGateAdmissionAt(r.dir, base, "ship")!.requirement;
|
|
237
|
+
const theirs = gateAdmission(r.dir, "ship", base)!.requirement;
|
|
238
|
+
expect(here).toMatchObject({ policy, mode: "enforce", context: { risk: "high" } });
|
|
239
|
+
expect({ policy: here.policy, mode: here.mode, context: here.context }).toEqual({ policy: theirs.policy, mode: theirs.mode, context: theirs.context });
|
|
240
|
+
});
|
|
230
241
|
});
|
|
231
242
|
|
|
232
243
|
describe("admitApprover", () => {
|
package/src/gate-decision.ts
CHANGED
|
@@ -67,6 +67,8 @@ import { diskFiles, findInstalledPackage, mergePinClasses, parseClassDataByHand,
|
|
|
67
67
|
import type { GateResolutionRecord, PendingGateRecord } from "./lifecycle/gate-ledger";
|
|
68
68
|
import type { MergedReview } from "./op/gate-review";
|
|
69
69
|
import { quorumProblems as quorumProblemsOf, type GateQuorumSpec as QuorumSpec } from "./op/gate-quorum";
|
|
70
|
+
import { gateBaseApprovalProblems } from "./op/gate-approval-base";
|
|
71
|
+
import type { GateApprovalMode, GatePolicyRef } from "./op/gate-approval";
|
|
70
72
|
import type { GateApprovalSource } from "./op/op-waves";
|
|
71
73
|
|
|
72
74
|
export { GATE_APPROVAL_SOURCES, type GateApprovalSource } from "./op/op-waves";
|
|
@@ -230,6 +232,14 @@ export interface IdentityGate {
|
|
|
230
232
|
* this name takes in place of its own. Absent when the entry sets none.
|
|
231
233
|
*/
|
|
232
234
|
quorum?: QuorumSpec;
|
|
235
|
+
/**
|
|
236
|
+
* `identity.gates.<gate>.policy`, `.mode` and `.context` (#4404): what an Op
|
|
237
|
+
* gate of this name takes in place of its own `approval` fields. Each is
|
|
238
|
+
* absent when the entry sets none.
|
|
239
|
+
*/
|
|
240
|
+
policy?: GatePolicyRef;
|
|
241
|
+
mode?: GateApprovalMode;
|
|
242
|
+
context?: Record<string, string | number | boolean | string[]>;
|
|
233
243
|
}
|
|
234
244
|
|
|
235
245
|
/** The declaration at `base`, parsed, or null when there is none. Throws when it cannot be parsed. */
|
|
@@ -245,13 +255,23 @@ function declarationAt(repo: string, base: string): { file: string; value: unkno
|
|
|
245
255
|
}
|
|
246
256
|
|
|
247
257
|
function identityGatesOf(declaration: unknown): IdentityGate[] {
|
|
248
|
-
const gates = (declaration as { identity?: { gates?: Record<string, { class?: unknown; quorum?: unknown } | null> } } | null)?.identity?.gates ?? {};
|
|
258
|
+
const gates = (declaration as { identity?: { gates?: Record<string, { class?: unknown; quorum?: unknown; policy?: unknown; mode?: unknown; context?: unknown } | null> } } | null)?.identity?.gates ?? {};
|
|
249
259
|
return Object.entries(gates).map(([gate, entry]) => {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
260
|
+
const out: IdentityGate = { gate, class: typeof entry?.class === "string" ? entry.class : null };
|
|
261
|
+
if (!entry) return out;
|
|
262
|
+
if (entry.quorum !== undefined) {
|
|
263
|
+
// #4324: a quorum chant would refuse leaves the gate undecidable, as chant's own read does.
|
|
264
|
+
const [problem] = quorumProblemsOf(entry.quorum, `identity.gates.${gate}.quorum`, { notAuthor: false });
|
|
265
|
+
if (problem) throw new Error(`chant.workspace.json at base: ${problem.replace(/`/g, "")}, so the gate cannot be decided`);
|
|
266
|
+
out.quorum = structuredClone(entry.quorum) as QuorumSpec;
|
|
267
|
+
}
|
|
268
|
+
// #4404: the same for the policy, mode and context.
|
|
269
|
+
const [rest] = gateBaseApprovalProblems(entry, `identity.gates.${gate}`);
|
|
270
|
+
if (rest) throw new Error(`chant.workspace.json at base: ${rest.replace(/`/g, "")}, so the gate cannot be decided`);
|
|
271
|
+
if (entry.policy !== undefined) out.policy = structuredClone(entry.policy) as GatePolicyRef;
|
|
272
|
+
if (entry.mode !== undefined) out.mode = entry.mode as GateApprovalMode;
|
|
273
|
+
if (entry.context !== undefined) out.context = structuredClone(entry.context) as NonNullable<IdentityGate["context"]>;
|
|
274
|
+
return out;
|
|
255
275
|
});
|
|
256
276
|
}
|
|
257
277
|
|
package/src/lexicon.ts
CHANGED
|
@@ -1742,6 +1742,30 @@ export interface LexiconPlugin {
|
|
|
1742
1742
|
changes: DisruptionQuery[];
|
|
1743
1743
|
}): Record<string, DisruptionVerdict> | Promise<Record<string, DisruptionVerdict>>;
|
|
1744
1744
|
|
|
1745
|
+
/**
|
|
1746
|
+
* Classify the migration files a pull request adds (#4407), offline. Core
|
|
1747
|
+
* lists the new files of a migration-directory workspace member (goose,
|
|
1748
|
+
* golang-migrate, clickhouse-migrations, Flyway) and asks the lexicon whose
|
|
1749
|
+
* dialect they are written in; the lexicon reads each file's up statements
|
|
1750
|
+
* and gives each the class its schema-diff classifier gives the same change.
|
|
1751
|
+
* A statement that alone cannot decide a class comes back `undecided`,
|
|
1752
|
+
* naming the candidate rules. The answer is shown in the plan note; nothing
|
|
1753
|
+
* is applied and no server is contacted. Omit for a lexicon with no
|
|
1754
|
+
* migration dialect.
|
|
1755
|
+
*/
|
|
1756
|
+
classifyMigrations?(options: {
|
|
1757
|
+
/** The member's kind reader: `goose-dir`, `golang-migrate-dir`, `clickhouse-migrations-dir` or `flyway-dir`. */
|
|
1758
|
+
reader: string;
|
|
1759
|
+
/** The SQL dialect the member's migrations are written in. A lexicon answers only for its own dialects (an empty list otherwise). */
|
|
1760
|
+
dialect: string;
|
|
1761
|
+
/** The tree the files are read from (the pull request's head), rooted at the workspace root. */
|
|
1762
|
+
tree: import("./workspace/tree").WorkspaceTree;
|
|
1763
|
+
/** The member's directory in that tree; "" for the root. */
|
|
1764
|
+
dir: string;
|
|
1765
|
+
/** The new files to classify, relative to the workspace root. */
|
|
1766
|
+
files: string[];
|
|
1767
|
+
}): import("./pr-migrations").MigrationFileClassification[] | Promise<import("./pr-migrations").MigrationFileClassification[]>;
|
|
1768
|
+
|
|
1745
1769
|
/**
|
|
1746
1770
|
* Report the undeclared resources this estate *depends on* (#1273), as
|
|
1747
1771
|
* opposed to the ones it manages.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #4404: an Op gate's `policy`, `mode` and `context` are fields of
|
|
3
|
+
* `identity.gates.<gate>` in the workspace declaration at base, as the quorum
|
|
4
|
+
* is (#4324), so a change can't switch its own gate to `log-only` or swap in a
|
|
5
|
+
* permissive policy and be judged by that.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
9
|
+
import type { GateStep, OpConfig } from "./types";
|
|
10
|
+
import type { ActivityFn } from "./activity-registry";
|
|
11
|
+
import { resolveGateApproval, runOpLocally } from "./local-executor";
|
|
12
|
+
import { gitGateLedgerPort, memoryGateLedgerPort, type GateLedgerPort } from "./gate";
|
|
13
|
+
import { gatePolicyVersion, type GatePolicyRef } from "./gate-approval";
|
|
14
|
+
import { stepOutput } from "./step-output-ref";
|
|
15
|
+
import { resetGateOrigin } from "../lifecycle/gate-origin";
|
|
16
|
+
import { hasSshKeygen, TestRepo } from "../workspace/trust/test-repo";
|
|
17
|
+
|
|
18
|
+
const policy = (name: string, text: string): GatePolicyRef => ({ kind: "gate-policy", lexicon: "cedar", name, version: gatePolicyVersion(text), text });
|
|
19
|
+
const STRICT = policy("strict", "forbid unless maintainer");
|
|
20
|
+
const PERMISSIVE = policy("permissive", "permit all");
|
|
21
|
+
|
|
22
|
+
const step = (approval?: GateStep["approval"]): GateStep => ({ kind: "gate", gate: "ship", ...(approval ? { approval } : {}) }) as GateStep;
|
|
23
|
+
|
|
24
|
+
describe("resolveGateApproval reads policy, mode and context at base (#4404)", () => {
|
|
25
|
+
test("the base policy and mode replace the Op's own", () => {
|
|
26
|
+
const opSide = step({ policy: PERMISSIVE, mode: "log-only" });
|
|
27
|
+
const got = resolveGateApproval(opSide, new Map(), undefined, null, { policy: STRICT, mode: "enforce" });
|
|
28
|
+
expect(got).toEqual({ policy: STRICT, mode: "enforce" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a gate whose Op dropped its approval block still takes the base policy and mode", () => {
|
|
32
|
+
expect(resolveGateApproval(step(), new Map(), undefined, null, { policy: STRICT, mode: "enforce" })).toEqual({ policy: STRICT, mode: "enforce" });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a field the entry leaves out comes from the Op", () => {
|
|
36
|
+
const opSide = step({ policy: PERMISSIVE, mode: "enforce" });
|
|
37
|
+
expect(resolveGateApproval(opSide, new Map(), undefined, null, { mode: "log-only" })).toEqual({ policy: PERMISSIVE, mode: "log-only" });
|
|
38
|
+
expect(resolveGateApproval(opSide, new Map(), undefined, null, { policy: STRICT })).toEqual({ policy: STRICT, mode: "enforce" });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("the base context wins key by key, and a key only the Op sets still reaches the policy", () => {
|
|
42
|
+
const opSide = step({ policy: STRICT, context: { risk: "low", team: "payments" } });
|
|
43
|
+
const got = resolveGateApproval(opSide, new Map(), undefined, null, { context: { risk: "high" } });
|
|
44
|
+
expect(got?.context).toEqual({ risk: "high", team: "payments" });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a step output reference in the Op's context still resolves beside a base key", () => {
|
|
48
|
+
const opSide = step({ policy: STRICT, context: { rows: stepOutput("verify", "rows") } });
|
|
49
|
+
const got = resolveGateApproval(opSide, new Map([["verify", { rows: 12 }]]), undefined, null, { context: { risk: "high" } });
|
|
50
|
+
expect(got?.context).toEqual({ risk: "high", rows: 12 });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("with nothing at base, the Op's own applies", () => {
|
|
54
|
+
expect(resolveGateApproval(step({ policy: PERMISSIVE, mode: "enforce" }), new Map(), undefined, null, { policy: null, mode: null, context: null })).toEqual({ policy: PERMISSIVE, mode: "enforce" });
|
|
55
|
+
expect(resolveGateApproval(step(), new Map(), undefined, null, { policy: null, mode: null, context: null })).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe.skipIf(!hasSshKeygen)("an Op gate's policy and mode at base (#4404)", () => {
|
|
60
|
+
const repos: TestRepo[] = [];
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
resetGateOrigin();
|
|
63
|
+
while (repos.length) repos.pop()!.cleanup();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const declaration = (gate: Record<string, unknown>): string => JSON.stringify({ name: "acme", schema: 1, members: [], identity: { gates: { ship: gate } } });
|
|
67
|
+
const config = (approval: NonNullable<GateStep["approval"]>): OpConfig => ({
|
|
68
|
+
name: "release",
|
|
69
|
+
overview: "",
|
|
70
|
+
phases: [
|
|
71
|
+
{ name: "Gate", steps: [{ kind: "gate", gate: "ship", approval }] },
|
|
72
|
+
{ name: "Apply", steps: [{ kind: "activity", fn: "ship", args: {} }] },
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
const activities = new Map<string, ActivityFn>([["ship", async () => ({})]]);
|
|
76
|
+
|
|
77
|
+
test("a change that switches its gate to a permissive log-only policy is judged by the base's", async () => {
|
|
78
|
+
const r = new TestRepo("4404-policy");
|
|
79
|
+
repos.push(r);
|
|
80
|
+
r.git(["config", "user.email", "t@example.test"]);
|
|
81
|
+
r.git(["config", "user.name", "t"]);
|
|
82
|
+
r.git(["config", "commit.gpgsign", "false"]);
|
|
83
|
+
r.write("chant.workspace.json", declaration({ policy: STRICT, mode: "enforce", context: { risk: "high" } }));
|
|
84
|
+
r.commit("base");
|
|
85
|
+
// The pull request rewrites the declaration's entry too; only base counts.
|
|
86
|
+
r.git(["checkout", "-q", "-b", "loosen"]);
|
|
87
|
+
r.write("chant.workspace.json", declaration({ policy: PERMISSIVE, mode: "log-only", context: { risk: "low" } }));
|
|
88
|
+
r.commit("loosen the gate");
|
|
89
|
+
|
|
90
|
+
const git = gitGateLedgerPort({ cwd: r.dir });
|
|
91
|
+
const gates: GateLedgerPort = { ...memoryGateLedgerPort(), approvalRule: git.approvalRule };
|
|
92
|
+
// The Op the change builds says permissive, log-only and low risk.
|
|
93
|
+
const run = await runOpLocally(config({ policy: PERMISSIVE, mode: "log-only", context: { risk: "low" } }), activities, {}, undefined, { gates, now: "2026-10-23T12:00:00.000Z" });
|
|
94
|
+
expect(run.status).toBe("gated");
|
|
95
|
+
expect(run.gate?.approval).toMatchObject({ policy: STRICT, mode: "enforce", context: { risk: "high" } });
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of an Op gate's approval that an `identity.gates.<gate>` entry
|
|
3
|
+
* also carries (#4404): the modes, the policy check and the entry check. Its
|
|
4
|
+
* only runtime import is `node:crypto`, so `gate-decision.ts` (which checks an
|
|
5
|
+
* entry) bundles without the Op runtime or zod (gate-decision-bundle.test.ts).
|
|
6
|
+
* `gate-approval.ts` re-exports everything here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { GATE_PLAN_CONTEXT_KEY } from "./gate-plan-context";
|
|
11
|
+
import type { GateApprovalMode, GatePolicyRef } from "./gate-approval";
|
|
12
|
+
|
|
13
|
+
export const GATE_APPROVAL_MODES: readonly GateApprovalMode[] = ["log-only", "enforce"];
|
|
14
|
+
|
|
15
|
+
// The brand `stepOutput()` stamps (`step-output-ref.ts`), as a global symbol so
|
|
16
|
+
// this module need not import that file and its zod schemas; the same copy
|
|
17
|
+
// `activity-contract.ts` keeps for the same reason.
|
|
18
|
+
const STEP_OUTPUT_REF_BRAND = Symbol.for("chant.op.stepOutputRef");
|
|
19
|
+
function isStepOutputRef(value: unknown): boolean {
|
|
20
|
+
return typeof value === "object" && value !== null && (value as Record<symbol, unknown>)[STEP_OUTPUT_REF_BRAND] === true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The version of a gate policy: `sha256:` and the hex digest of its text. A lexicon's helper stamps it, and OPS015 checks it still matches. */
|
|
24
|
+
export function gatePolicyVersion(text: string): string {
|
|
25
|
+
return `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isGatePolicyRef(value: unknown): value is GatePolicyRef {
|
|
29
|
+
if (typeof value !== "object" || value === null) return false;
|
|
30
|
+
const v = value as Record<string, unknown>;
|
|
31
|
+
return (
|
|
32
|
+
v.kind === "gate-policy" &&
|
|
33
|
+
typeof v.lexicon === "string" && v.lexicon !== "" &&
|
|
34
|
+
typeof v.name === "string" && v.name !== "" &&
|
|
35
|
+
typeof v.version === "string" && v.version !== "" &&
|
|
36
|
+
typeof v.text === "string" && v.text.trim() !== ""
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What is wrong with the `policy`, `mode` and `context` of an
|
|
42
|
+
* `identity.gates.<gate>` entry (#4404), one line each, named by `at`. Each
|
|
43
|
+
* field stands alone: base `mode: "enforce"` with no base `policy` is fine, as
|
|
44
|
+
* the Op's own policy then applies. `chant workspace check` (WSP153) says so.
|
|
45
|
+
*/
|
|
46
|
+
export function gateBaseApprovalProblems(entry: { policy?: unknown; mode?: unknown; context?: unknown }, at: string): string[] {
|
|
47
|
+
const problems: string[] = [];
|
|
48
|
+
if (entry.policy !== undefined) {
|
|
49
|
+
if (!isGatePolicyRef(entry.policy)) problems.push(`\`${at}.policy\` does not resolve to a gate policy set: it needs kind "gate-policy", lexicon, name, version and text`);
|
|
50
|
+
else if (entry.policy.version !== gatePolicyVersion(entry.policy.text)) problems.push(`\`${at}.policy\` "${entry.policy.name}" has a version that is not the digest of its text`);
|
|
51
|
+
}
|
|
52
|
+
if (entry.mode !== undefined && !GATE_APPROVAL_MODES.includes(entry.mode as GateApprovalMode)) {
|
|
53
|
+
problems.push(`\`${at}.mode\` must be one of ${GATE_APPROVAL_MODES.map((m) => `"${m}"`).join(", ")}`);
|
|
54
|
+
}
|
|
55
|
+
if (entry.context !== undefined) {
|
|
56
|
+
if (typeof entry.context !== "object" || entry.context === null || Array.isArray(entry.context)) problems.push(`\`${at}.context\` must be an object of plan attributes`);
|
|
57
|
+
else {
|
|
58
|
+
for (const [key, value] of Object.entries(entry.context)) {
|
|
59
|
+
if (key === GATE_PLAN_CONTEXT_KEY) problems.push(`\`${at}.context.${key}\` is reserved: the run fills it with the plan's change-set summary (#3182)`);
|
|
60
|
+
else if (isStepOutputRef(value) || !isContextValue(value)) problems.push(`\`${at}.context.${key}\` must be a string, number, boolean or list of strings`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return problems;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isContextValue(value: unknown): boolean {
|
|
68
|
+
if (typeof value === "string" || typeof value === "boolean") return true;
|
|
69
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
70
|
+
if (Array.isArray(value)) return value.every((v) => typeof v === "string");
|
|
71
|
+
return isStepOutputRef(value);
|
|
72
|
+
}
|