@intentius/chant 0.121.1 → 0.123.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/build.d.ts +12 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts +16 -0
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/gate-resume.d.ts +1 -1
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/override-approve.d.ts +17 -0
- package/dist/cli/handlers/override-approve.d.ts.map +1 -0
- package/dist/cli/handlers/run-wave.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +4 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/gate-decision.d.ts +157 -0
- package/dist/gate-decision.d.ts.map +1 -0
- package/dist/lifecycle/gate-applied.d.ts +75 -0
- package/dist/lifecycle/gate-applied.d.ts.map +1 -0
- package/dist/lifecycle/gate-audit.d.ts +145 -0
- package/dist/lifecycle/gate-audit.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger-select.d.ts +54 -0
- package/dist/lifecycle/gate-ledger-select.d.ts.map +1 -0
- package/dist/lifecycle/gate-ledger.d.ts +1 -45
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/override-ledger.d.ts +34 -0
- package/dist/lifecycle/override-ledger.d.ts.map +1 -0
- package/dist/lifecycle/rollback.d.ts.map +1 -1
- package/dist/lifecycle/work-item-id.d.ts +22 -0
- package/dist/lifecycle/work-item-id.d.ts.map +1 -0
- package/dist/lifecycle/work-lease.d.ts +2 -2
- package/dist/lifecycle/work-lease.d.ts.map +1 -1
- package/dist/lint/post-synth.d.ts +20 -1
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/notify-verify.d.ts +62 -0
- package/dist/notify-verify.d.ts.map +1 -0
- package/dist/notify.d.ts +202 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/activities/gh-host.d.ts +37 -0
- package/dist/op/activities/gh-host.d.ts.map +1 -0
- package/dist/op/activities/lexicon-upgrade.d.ts +11 -0
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts +74 -76
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts +17 -1
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +8 -1
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-resume.d.ts +4 -2
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-review.d.ts +2 -1
- package/dist/op/gate-review.d.ts.map +1 -1
- package/dist/op/gate.d.ts +43 -6
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +8 -6
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-waves-notify.d.ts +31 -0
- package/dist/op/op-waves-notify.d.ts.map +1 -0
- package/dist/op/op-waves-run.d.ts +91 -16
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves.d.ts +99 -2
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/operator.d.ts +15 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/work-lease-decl.d.ts.map +1 -1
- package/dist/op/work-lease-run.d.ts.map +1 -1
- package/dist/override-gate.d.ts +224 -0
- package/dist/override-gate.d.ts.map +1 -0
- package/dist/pr-forge.d.ts +198 -19
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/status-gates.d.ts +1 -0
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/trust/gate-seal.d.ts +96 -0
- package/dist/workspace/trust/gate-seal.d.ts.map +1 -0
- package/dist/workspace/trust/policy.d.ts +2 -41
- package/dist/workspace/trust/policy.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +2 -27
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/trust-files.d.ts +57 -0
- package/dist/workspace/trust/trust-files.d.ts.map +1 -0
- package/dist/workspace/work-cli.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/cli/commands/build.ts +37 -18
- package/src/cli/commands/carve-emit-state.test.ts +8 -2
- package/src/cli/commands/lint.test.ts +109 -1
- package/src/cli/commands/lint.ts +165 -1
- package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +2 -2
- package/src/cli/handlers/gate-resume.ts +3 -2
- package/src/cli/handlers/operator.test.ts +12 -1
- package/src/cli/handlers/operator.ts +25 -13
- package/src/cli/handlers/override-approve.test.ts +87 -0
- package/src/cli/handlers/override-approve.ts +103 -0
- package/src/cli/handlers/run-wave.ts +10 -2
- package/src/cli/main.test.ts +8 -0
- package/src/cli/main.ts +15 -0
- package/src/cli/registry.ts +4 -0
- package/src/gate-decision-bundle.test.ts +56 -0
- package/src/gate-decision.test.ts +237 -0
- package/src/gate-decision.ts +321 -0
- package/src/lifecycle/gate-applied.ts +115 -0
- package/src/lifecycle/gate-audit-bundle.test.ts +47 -0
- package/src/lifecycle/gate-audit.test.ts +134 -0
- package/src/lifecycle/gate-audit.ts +438 -0
- package/src/lifecycle/gate-ledger-select.ts +113 -0
- package/src/lifecycle/gate-ledger.test.ts +22 -0
- package/src/lifecycle/gate-ledger.ts +9 -104
- package/src/lifecycle/override-ledger.test.ts +63 -0
- package/src/lifecycle/override-ledger.ts +69 -0
- package/src/lifecycle/rollback.ts +5 -1
- package/src/lifecycle/work-item-id.test.ts +71 -0
- package/src/lifecycle/work-item-id.ts +35 -0
- package/src/lifecycle/work-lease.ts +4 -5
- package/src/lint/post-synth.test.ts +29 -0
- package/src/lint/post-synth.ts +21 -2
- package/src/notify-bundle.test.ts +50 -0
- package/src/notify-verify.test.ts +59 -0
- package/src/notify-verify.ts +93 -0
- package/src/notify.schema.json +72 -0
- package/src/notify.test.ts +279 -0
- package/src/notify.ts +398 -0
- package/src/op/activities/gh-host.test.ts +130 -0
- package/src/op/activities/gh-host.ts +79 -0
- package/src/op/activities/lexicon-upgrade.ts +22 -1
- package/src/op/activities/propose-upgrade.ts +5 -1
- package/src/op/activities/reconcile.test.ts +284 -280
- package/src/op/activities/reconcile.ts +128 -131
- package/src/op/gate-approval.test.ts +175 -7
- package/src/op/gate-approval.ts +27 -1
- package/src/op/gate-name.ts +10 -2
- package/src/op/gate-resume.ts +6 -3
- package/src/op/gate-review.ts +4 -7
- package/src/op/gate.ts +117 -16
- package/src/op/index.ts +20 -5
- package/src/op/op-waves-notify.test.ts +127 -0
- package/src/op/op-waves-notify.ts +101 -0
- package/src/op/op-waves-run.ts +527 -104
- package/src/op/op-waves.test.ts +383 -3
- package/src/op/op-waves.ts +187 -6
- package/src/op/operator.test.ts +29 -0
- package/src/op/operator.ts +27 -2
- package/src/op/run-context.test.ts +2 -2
- package/src/op/work-lease-decl.ts +4 -4
- package/src/op/work-lease-run.test.ts +5 -0
- package/src/op/work-lease-run.ts +4 -3
- package/src/override-gate-bundle.test.ts +47 -0
- package/src/override-gate.test.ts +239 -0
- package/src/override-gate.ts +395 -0
- package/src/pr-forge-bundle.test.ts +40 -0
- package/src/pr-forge.test.ts +303 -0
- package/src/pr-forge.ts +502 -47
- package/src/workspace/records-write-contract.test.ts +3 -2
- package/src/workspace/status-gates.ts +7 -4
- package/src/workspace/trust/gate-seal.test.ts +156 -0
- package/src/workspace/trust/gate-seal.ts +254 -0
- package/src/workspace/trust/policy.ts +10 -165
- package/src/workspace/trust/seal.ts +19 -34
- package/src/workspace/trust/trust-files.test.ts +50 -0
- package/src/workspace/trust/trust-files.ts +245 -0
- package/src/workspace/work-cli.ts +5 -3
- package/src/workspace/work-history.test.ts +7 -0
package/src/cli/commands/lint.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolve, join, relative } from "path";
|
|
1
|
+
import { resolve, join, relative, dirname } from "path";
|
|
2
2
|
import type { BuildParamProvenance } from "../../provenance";
|
|
3
3
|
import { readFileSync, writeFileSync, readdirSync } from "fs";
|
|
4
4
|
import { runLint, parseDisableComments } from "../../lint/engine";
|
|
@@ -24,6 +24,14 @@ import { walkDiscovery, workspaceMemberDirs } from "../../discovery/walk";
|
|
|
24
24
|
import { buildParamValues, resolveBuildParams } from "../../build-params";
|
|
25
25
|
import { setBuildParams } from "../../params";
|
|
26
26
|
import { isNoLexiconDetected } from "../../detectLexicon";
|
|
27
|
+
import { build } from "../../build";
|
|
28
|
+
import { loadChantConfigUpward, resolveOwnershipEnv, resolveOwnershipMarker } from "../../config";
|
|
29
|
+
import { resolveBuildModes, resolveProjectBuildOptions } from "../build-options";
|
|
30
|
+
import { resolveTelemetryAttribution } from "../../telemetry-attribution";
|
|
31
|
+
import { applyConfiguredPreset, applyConfiguredSeverity, resolvePresetIds } from "../../lint/config";
|
|
32
|
+
import { applyInlineSuppressions } from "../../lint/suppressions";
|
|
33
|
+
import { getProvenance } from "../../provenance";
|
|
34
|
+
import { runLexiconPostSynthChecks } from "./build";
|
|
27
35
|
|
|
28
36
|
// Import config loader
|
|
29
37
|
import { loadConfig, resolveRulesForFile, resolveConfiguredSeverity, findProjectRoot } from "../../lint/config";
|
|
@@ -130,6 +138,16 @@ function lexiconResolutionDiagnostic(projectRoot: string, error: Error): LintDia
|
|
|
130
138
|
};
|
|
131
139
|
}
|
|
132
140
|
|
|
141
|
+
/**
|
|
142
|
+
* The diagnostic id `chant lint` reports a lint target that does not build
|
|
143
|
+
* under (chant #3750). The lexicons' post-synth checks read a build result, so
|
|
144
|
+
* when the build fails they cannot run; this says so instead of letting the
|
|
145
|
+
* lint pass in silence. Like {@link LEXICON_RESOLUTION_RULE_ID} it is not a
|
|
146
|
+
* rule: it has no severity setting and no disable form. The message is the
|
|
147
|
+
* build's own.
|
|
148
|
+
*/
|
|
149
|
+
export const BUILD_FAILURE_RULE_ID = "LEX002";
|
|
150
|
+
|
|
133
151
|
/**
|
|
134
152
|
* The plugins of lexicons that the project's source imports from and that are not in `configured`, for their
|
|
135
153
|
* property-kind class names (#3184). A package that is not installed, or that fails to load, is skipped.
|
|
@@ -295,6 +313,13 @@ export interface LintOptions {
|
|
|
295
313
|
* `sandbox` option (../../components/discover.ts).
|
|
296
314
|
*/
|
|
297
315
|
sandbox?: boolean;
|
|
316
|
+
/**
|
|
317
|
+
* chant #3750 — run each loaded lexicon's post-synth checks over an
|
|
318
|
+
* in-memory build of the target (default `true`, what the CLI always does).
|
|
319
|
+
* `false` lints source alone; it exists for test harnesses that check an
|
|
320
|
+
* example's source rules, not for hiding findings from a user.
|
|
321
|
+
*/
|
|
322
|
+
postSynth?: boolean;
|
|
298
323
|
}
|
|
299
324
|
|
|
300
325
|
/**
|
|
@@ -630,6 +655,135 @@ async function runOpCheckDiagnostics(
|
|
|
630
655
|
return { diagnostics, suppressed };
|
|
631
656
|
}
|
|
632
657
|
|
|
658
|
+
/**
|
|
659
|
+
* Run each loaded lexicon's own post-synth checks (#3750), the ones `chant
|
|
660
|
+
* build` runs after it serializes, over an in-memory build of the lint target.
|
|
661
|
+
*
|
|
662
|
+
* The build is `chant build`'s: the same `build()` call with options from the
|
|
663
|
+
* same assembler (`../build-options.ts`), and the same per-lexicon scoping of
|
|
664
|
+
* outputs (`runLexiconPostSynthChecks`, ./build.ts). Nothing is written. It
|
|
665
|
+
* builds `infraPath`, the target the caller asked to lint, never the project
|
|
666
|
+
* root above it, so `chant lint <dir>` costs a build of `<dir>` and nothing
|
|
667
|
+
* more (the same scoping {@link runOpCheckDiagnostics} documents). It is
|
|
668
|
+
* skipped outright when no loaded lexicon ships post-synth checks, which keeps
|
|
669
|
+
* a project with no lexicon, and every core-only fixture, from building at
|
|
670
|
+
* all.
|
|
671
|
+
*
|
|
672
|
+
* A target that does not build reports each build error under
|
|
673
|
+
* {@link BUILD_FAILURE_RULE_ID} and skips the checks. Findings go through
|
|
674
|
+
* `lint.presets`, then `lint.rules`, then inline `chant-ignore` comments, the
|
|
675
|
+
* order `chant build` applies them in. A finding carries no line (see
|
|
676
|
+
* ../../lint/post-synth.ts's `PostSynthDiagnostic`), so it is reported at
|
|
677
|
+
* `1:1` in the file that declared its entity, or at the config file (the
|
|
678
|
+
* project root without one) when it names none.
|
|
679
|
+
*/
|
|
680
|
+
async function runLexiconPostSynthDiagnostics(
|
|
681
|
+
infraPath: string,
|
|
682
|
+
plugins: readonly LexiconPlugin[],
|
|
683
|
+
buildParams?: BuildParamProvenance[],
|
|
684
|
+
sandbox?: boolean,
|
|
685
|
+
): Promise<{ diagnostics: LintDiagnostic[]; suppressed: Array<LintDiagnostic & { reason?: string }> }> {
|
|
686
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
687
|
+
const suppressed: Array<LintDiagnostic & { reason?: string }> = [];
|
|
688
|
+
const withChecks = plugins.filter((plugin) => (plugin.postSynthChecks?.() ?? []).length > 0);
|
|
689
|
+
if (withChecks.length === 0) return { diagnostics, suppressed };
|
|
690
|
+
|
|
691
|
+
const projectRoot = findProjectRoot(infraPath);
|
|
692
|
+
const lintConfig = loadConfig(projectRoot);
|
|
693
|
+
// Where a finding that names no declared entity is reported: the config the
|
|
694
|
+
// build loads once it is known (past a lint-only fragment), else the nearest.
|
|
695
|
+
let fallbackFile = findProjectConfig(projectRoot).configPath ?? projectRoot;
|
|
696
|
+
const buildFailure = (message: string, at?: { file?: string; line?: number; column?: number }): LintDiagnostic => ({
|
|
697
|
+
file: at?.file ?? fallbackFile,
|
|
698
|
+
line: at?.line ?? 1,
|
|
699
|
+
column: at?.column ?? 1,
|
|
700
|
+
ruleId: BUILD_FAILURE_RULE_ID,
|
|
701
|
+
severity: "error",
|
|
702
|
+
message,
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
let result: Awaited<ReturnType<typeof build>>;
|
|
706
|
+
let env: string | undefined;
|
|
707
|
+
let telemetry: Awaited<ReturnType<typeof resolveTelemetryAttribution>>;
|
|
708
|
+
let configDir = infraPath;
|
|
709
|
+
let loadedConfig: Record<string, unknown> | undefined;
|
|
710
|
+
try {
|
|
711
|
+
const loaded = await loadChantConfigUpward(infraPath);
|
|
712
|
+
const config = loaded.config;
|
|
713
|
+
loadedConfig = config as unknown as Record<string, unknown>;
|
|
714
|
+
configDir = loaded.configPath ? dirname(loaded.configPath) : infraPath;
|
|
715
|
+
if (loaded.configPath) fallbackFile = loaded.configPath;
|
|
716
|
+
// The caller's parameters when it resolved them (the CLI does, from the
|
|
717
|
+
// same upward config walk); otherwise the declared defaults and `env`
|
|
718
|
+
// mappings of the config this build loads. Not lintCommand's own fallback:
|
|
719
|
+
// that one reads the config at the lint root, which may be a lint-only
|
|
720
|
+
// fragment with no `buildParams` (a `src/chant.config.json`).
|
|
721
|
+
const params = buildParams ?? resolveBuildParams(config.buildParams, { env: process.env }).provenance;
|
|
722
|
+
env = resolveOwnershipEnv(config, params);
|
|
723
|
+
telemetry = await resolveTelemetryAttribution(configDir, config as unknown as Record<string, unknown>, env);
|
|
724
|
+
// Every lexicon the lint loaded, as `chant build` serializes with every
|
|
725
|
+
// lexicon it loaded: a check may read another lexicon's entities, and the
|
|
726
|
+
// outputs are scoped per plugin below either way.
|
|
727
|
+
result = await build(
|
|
728
|
+
infraPath,
|
|
729
|
+
plugins.map((plugin) => plugin.serializer),
|
|
730
|
+
undefined,
|
|
731
|
+
resolveProjectBuildOptions({
|
|
732
|
+
config,
|
|
733
|
+
configDir,
|
|
734
|
+
plugins,
|
|
735
|
+
modes: resolveBuildModes(config, { sandbox }),
|
|
736
|
+
ownership: resolveOwnershipMarker(config, params),
|
|
737
|
+
buildParams: params,
|
|
738
|
+
telemetry,
|
|
739
|
+
}),
|
|
740
|
+
);
|
|
741
|
+
} catch (err) {
|
|
742
|
+
diagnostics.push(buildFailure(err instanceof Error ? err.message : String(err)));
|
|
743
|
+
return { diagnostics, suppressed };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (result.errors.length > 0) {
|
|
747
|
+
const seen = new Set<string>();
|
|
748
|
+
for (const error of result.errors) {
|
|
749
|
+
const at = error as unknown as { file?: string; line?: number; column?: number };
|
|
750
|
+
const key = `${at.file ?? ""}:${at.line ?? ""}:${error.message}`;
|
|
751
|
+
if (seen.has(key)) continue;
|
|
752
|
+
seen.add(key);
|
|
753
|
+
diagnostics.push(buildFailure(error.message, at));
|
|
754
|
+
}
|
|
755
|
+
return { diagnostics, suppressed };
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const activityContracts = await loadActivityContracts(plugins);
|
|
759
|
+
const fileOf = (entity?: string): string => {
|
|
760
|
+
const declared = entity ? result.entities.get(entity) : undefined;
|
|
761
|
+
return (declared && getProvenance(declared)?.sourceFile) || fallbackFile;
|
|
762
|
+
};
|
|
763
|
+
const toLint = (d: { checkId: string; severity: LintDiagnostic["severity"]; message: string; entity?: string; lexicon?: string }): LintDiagnostic => ({
|
|
764
|
+
file: fileOf(d.entity),
|
|
765
|
+
line: 1,
|
|
766
|
+
column: 1,
|
|
767
|
+
ruleId: d.checkId,
|
|
768
|
+
severity: d.severity,
|
|
769
|
+
message: `${d.entity ? `[${d.entity}] ` : ""}${d.message}`,
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
for (const { plugin, diagnostics: raw } of runLexiconPostSynthChecks(withChecks, result, env, { activityContracts, telemetry, projectDir: configDir, ...(loadedConfig ? { projectConfig: loadedConfig } : {}) })) {
|
|
773
|
+
const presetIds = resolvePresetIds(plugin.lintPresets?.(), lintConfig.presets?.[plugin.name] ?? "recommended");
|
|
774
|
+
const afterPreset = applyConfiguredPreset(raw, presetIds, lintConfig.rules).diagnostics;
|
|
775
|
+
const afterConfig = applyConfiguredSeverity(afterPreset, lintConfig.rules).diagnostics;
|
|
776
|
+
const inline = applyInlineSuppressions(afterConfig, result.entities, lintConfig.rules);
|
|
777
|
+
for (const d of inline.diagnostics) diagnostics.push(toLint(d));
|
|
778
|
+
for (const d of inline.suppressed) suppressed.push({ ...toLint(d), reason: "chant-ignore" });
|
|
779
|
+
for (const m of inline.meta) {
|
|
780
|
+
diagnostics.push({ file: m.file, line: m.line, column: 1, ruleId: m.checkId, severity: m.severity, message: m.message });
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return { diagnostics, suppressed };
|
|
785
|
+
}
|
|
786
|
+
|
|
633
787
|
/**
|
|
634
788
|
* Apply fixes to a file
|
|
635
789
|
*/
|
|
@@ -817,6 +971,16 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
|
|
|
817
971
|
suppressed.push(...postOpResult.suppressed);
|
|
818
972
|
}
|
|
819
973
|
|
|
974
|
+
// chant #3750: each lexicon's own post-synth checks, over an in-memory
|
|
975
|
+
// build of the lint target. Run once, after any `--fix` above, so it sees
|
|
976
|
+
// the source as fixed; a post-synth finding has no fix of its own. Skipped
|
|
977
|
+
// when a lexicon failed to resolve, which LEX001 below already reports.
|
|
978
|
+
if (!loaded.lexiconError && options.postSynth !== false) {
|
|
979
|
+
const lexiconResult = await runLexiconPostSynthDiagnostics(infraPath, loaded.plugins, options.buildParams, options.sandbox);
|
|
980
|
+
diagnostics.push(...lexiconResult.diagnostics);
|
|
981
|
+
suppressed.push(...lexiconResult.suppressed);
|
|
982
|
+
}
|
|
983
|
+
|
|
820
984
|
// chant #2222: a lexicon the project declared but this run could not
|
|
821
985
|
// resolve. Appended here, after the `--fix` re-lint block above has finished
|
|
822
986
|
// reassigning `diagnostics`, so it survives every path; it is a property of
|
|
@@ -12,7 +12,7 @@ env:
|
|
|
12
12
|
jobs:
|
|
13
13
|
shared-alb:
|
|
14
14
|
runs-on: ubuntu-latest
|
|
15
|
-
container: node:22
|
|
15
|
+
container: node:22
|
|
16
16
|
steps:
|
|
17
17
|
- uses: actions/checkout@v7
|
|
18
18
|
- run: chant run --components shared-alb --env staging --dump-outputs shared-alb.outputs.json
|
|
@@ -25,7 +25,7 @@ jobs:
|
|
|
25
25
|
runs-on: ubuntu-latest
|
|
26
26
|
needs:
|
|
27
27
|
- shared-alb
|
|
28
|
-
container: node:22
|
|
28
|
+
container: node:22
|
|
29
29
|
steps:
|
|
30
30
|
- uses: actions/checkout@v7
|
|
31
31
|
- name: Download shared-alb outputs
|
|
@@ -60,6 +60,7 @@ export async function resumeStanding(
|
|
|
60
60
|
standing: PendingGateRecord,
|
|
61
61
|
resolutions: readonly GateResolutionRecord[],
|
|
62
62
|
opts: ResumeGateOptions = {},
|
|
63
|
+
pending: readonly PendingGateRecord[] = [standing],
|
|
63
64
|
): Promise<GateResumeReport> {
|
|
64
65
|
const base = {
|
|
65
66
|
op: standing.op,
|
|
@@ -67,7 +68,7 @@ export async function resumeStanding(
|
|
|
67
68
|
...(standing.environment !== undefined ? { environment: standing.environment } : {}),
|
|
68
69
|
...(standing.resume?.url ? { url: standing.resume.url } : {}),
|
|
69
70
|
};
|
|
70
|
-
const answer = resolutionAnswering(resolutions, standing);
|
|
71
|
+
const answer = resolutionAnswering(resolutions, standing, pending);
|
|
71
72
|
if (!answer) return { ...base, status: "not-approved", message: "no approval answers it yet" };
|
|
72
73
|
const approvedBy = { approvedBy: answer.resolvedBy };
|
|
73
74
|
if (!isGateRunLocator(standing.resume)) {
|
|
@@ -99,7 +100,7 @@ export async function resumeOpGates(op: string, gate: string | undefined, opts:
|
|
|
99
100
|
const reports: GateResumeReport[] = [];
|
|
100
101
|
for (const standing of standingFacts(ledger.pending, gate)) {
|
|
101
102
|
if (isPendingGateExpired(standing, now)) continue;
|
|
102
|
-
reports.push(await resumeStanding(standing, ledger.resolutions, opts));
|
|
103
|
+
reports.push(await resumeStanding(standing, ledger.resolutions, opts, ledger.pending));
|
|
103
104
|
}
|
|
104
105
|
return reports;
|
|
105
106
|
}
|
|
@@ -77,6 +77,7 @@ vi.mock("../../op/gate-approval", async () => {
|
|
|
77
77
|
|
|
78
78
|
// Imported after the mocks above are registered.
|
|
79
79
|
const { runOperator, runOperatorStatus, runOperatorLog, runApprove } = await import("./operator");
|
|
80
|
+
const { gateContextDigest } = await import("../../op/gate-approval");
|
|
80
81
|
|
|
81
82
|
function ctx(args: Partial<ParsedArgs>) {
|
|
82
83
|
return {
|
|
@@ -386,6 +387,16 @@ describe("runApprove", () => {
|
|
|
386
387
|
errSpy.mockRestore();
|
|
387
388
|
});
|
|
388
389
|
|
|
390
|
+
// #3760: --rule and --reason belong to an override of a denied plan.
|
|
391
|
+
test("refuses --rule and --reason on a gate that is not the override gate", async () => {
|
|
392
|
+
seedPending("fountain-apply", "rollout-gate", PLAN_A);
|
|
393
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
394
|
+
expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", rules: ["no-drop"], reason: "why" }))).toBe(1);
|
|
395
|
+
expect(errSpy.mock.calls.flat().join("\n")).toMatch(/--rule and --reason override a denied plan: chant approve policy-override <subject>/);
|
|
396
|
+
expect(appendGateResolutionMock).not.toHaveBeenCalled();
|
|
397
|
+
errSpy.mockRestore();
|
|
398
|
+
});
|
|
399
|
+
|
|
389
400
|
test("appends a gate-resolution record and pushes, resolving --actor over env fallbacks", async () => {
|
|
390
401
|
seedPending("fountain-apply", "rollout-gate", PLAN_A);
|
|
391
402
|
appendGateResolutionMock.mockResolvedValue({
|
|
@@ -1115,7 +1126,7 @@ describe("runApprove — a gate with an approval policy (#2508)", () => {
|
|
|
1115
1126
|
expect(appendGateResolutionMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
1116
1127
|
approver: { kind: "agent" },
|
|
1117
1128
|
policyDecision: {
|
|
1118
|
-
policy: "ship", version: "sha256:v1", mode: "enforce",
|
|
1129
|
+
policy: "ship", version: "sha256:v1", mode: "enforce", contextDigest: gateContextDigest({ risk: "low" }),
|
|
1119
1130
|
decision: "allow", determining: ["agent-low-risk"], errors: [],
|
|
1120
1131
|
},
|
|
1121
1132
|
}));
|
|
@@ -42,13 +42,13 @@ import {
|
|
|
42
42
|
latestResolutionSince, latestPendingGate, isPendingGateExpired,
|
|
43
43
|
resolveApprovalUrl, isApprovalUrl,
|
|
44
44
|
} from "../../lifecycle/gate-ledger";
|
|
45
|
-
import { isPlanDigest } from "../../lifecycle/plan-digest";
|
|
45
|
+
import { isPlanDigest, samePlanDigest } from "../../lifecycle/plan-digest";
|
|
46
46
|
import {
|
|
47
|
-
gatePolicyRequest, loadGatePolicyEvaluator,
|
|
47
|
+
gateContextDigest, gatePolicyRequest, loadGatePolicyEvaluator,
|
|
48
48
|
type GateApprover, type GatePolicyDecision, type GatePolicyEvaluator, type ResolvedGateApproval,
|
|
49
49
|
} from "../../op/gate-approval";
|
|
50
|
-
import { approverOf, tallyGateApprovals } from "../../op/gate";
|
|
51
|
-
import { FAN_OUT_GATE_OP, WORKSPACE_UPGRADE_GATE_OP } from "../../op/gate-name";
|
|
50
|
+
import { approverOf, humanQuorumSince, tallyGateApprovals } from "../../op/gate";
|
|
51
|
+
import { FAN_OUT_GATE_OP, OVERRIDE_GATE_OP, WORKSPACE_UPGRADE_GATE_OP } from "../../op/gate-name";
|
|
52
52
|
import { PR_GATE_OP } from "../../pr-loop";
|
|
53
53
|
import { pushLifecycleReport, requireLifecycleLedger } from "../../lifecycle/git";
|
|
54
54
|
import { LIFECYCLE_LOCAL_NOTE, isLifecycleLocalNote } from "../../lifecycle/local-note";
|
|
@@ -888,6 +888,19 @@ export async function runApprove(ctx: CommandContext): Promise<number> {
|
|
|
888
888
|
return 0;
|
|
889
889
|
}
|
|
890
890
|
|
|
891
|
+
// #3760: an override of a denied plan names its rules and gives a reason.
|
|
892
|
+
if (opName === OVERRIDE_GATE_OP) {
|
|
893
|
+
const { runOverrideApprove } = await import("./override-approve");
|
|
894
|
+
return runOverrideApprove(ctx);
|
|
895
|
+
}
|
|
896
|
+
if (ctx.args.rules !== undefined || ctx.args.reason !== undefined) {
|
|
897
|
+
console.error(formatError({
|
|
898
|
+
message: `--rule and --reason override a denied plan: chant approve ${OVERRIDE_GATE_OP} <subject> --rule <id> --reason <text>`,
|
|
899
|
+
hint: `To approve gate "${gate}" on "${opName}", put any prose in --note.`,
|
|
900
|
+
}));
|
|
901
|
+
return 1;
|
|
902
|
+
}
|
|
903
|
+
|
|
891
904
|
// `--approver` wins over `--actor`, the same precedence `chant run approve`
|
|
892
905
|
// has applied since #2126 (`../handlers/run.ts`'s `runOpApprove`). Reading
|
|
893
906
|
// only `--actor` here meant every README that teaches `--approver you`
|
|
@@ -1016,7 +1029,7 @@ export async function recordGateApproval(
|
|
|
1016
1029
|
// `fan-out` is the op name every `chant components fan-out` gate is recorded
|
|
1017
1030
|
// under (../handlers/fan-out.ts). It is a command rather than a declaration,
|
|
1018
1031
|
// so there is no `*.op.ts` to find and nothing is wrong when none is there.
|
|
1019
|
-
if (!ops.has(opName) && opName !== FAN_OUT_GATE_OP && opName !== WORKSPACE_UPGRADE_GATE_OP) {
|
|
1032
|
+
if (!ops.has(opName) && opName !== FAN_OUT_GATE_OP && opName !== WORKSPACE_UPGRADE_GATE_OP && opName !== OVERRIDE_GATE_OP) {
|
|
1020
1033
|
console.error(formatWarning({
|
|
1021
1034
|
message: `Op "${opName}" was not found among discovered *.op.ts declarations — recording the resolution anyway`,
|
|
1022
1035
|
}));
|
|
@@ -1260,6 +1273,7 @@ export async function recordGateApproval(
|
|
|
1260
1273
|
policy: approval.policy.name,
|
|
1261
1274
|
version: approval.policy.version,
|
|
1262
1275
|
mode: approval.mode,
|
|
1276
|
+
contextDigest: gateContextDigest(approval.context),
|
|
1263
1277
|
decision: answer.decision,
|
|
1264
1278
|
determining: answer.determining,
|
|
1265
1279
|
errors: answer.errors,
|
|
@@ -1310,7 +1324,7 @@ export async function recordGateApproval(
|
|
|
1310
1324
|
));
|
|
1311
1325
|
}
|
|
1312
1326
|
if (approval) {
|
|
1313
|
-
for (const line of describeApprovalProgress(approval, record, ledger.resolutions, standingForOrigin)) {
|
|
1327
|
+
for (const line of describeApprovalProgress(approval, record, ledger.resolutions, standingForOrigin, ledger.pending)) {
|
|
1314
1328
|
console.error(formatInfo(line));
|
|
1315
1329
|
}
|
|
1316
1330
|
}
|
|
@@ -1328,6 +1342,7 @@ export function describeApprovalProgress(
|
|
|
1328
1342
|
record: GateResolutionRecord,
|
|
1329
1343
|
prior: GateResolutionRecord[],
|
|
1330
1344
|
standing: PendingGateRecord | undefined,
|
|
1345
|
+
pending: readonly PendingGateRecord[] = standing ? [standing] : [],
|
|
1331
1346
|
): string[] {
|
|
1332
1347
|
const lines: string[] = [];
|
|
1333
1348
|
const decision = record.policyDecision;
|
|
@@ -1343,13 +1358,10 @@ export function describeApprovalProgress(
|
|
|
1343
1358
|
for (const error of decision.errors) lines.push(`Policy evaluation error: ${error}`);
|
|
1344
1359
|
}
|
|
1345
1360
|
|
|
1346
|
-
const
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
record.planDigest,
|
|
1351
|
-
approval,
|
|
1352
|
-
);
|
|
1361
|
+
const since = standing?.timestamp ?? new Date(0).toISOString();
|
|
1362
|
+
// #3206: count the humans the way the run will, back past facts re-recorded for this plan.
|
|
1363
|
+
const humansSince = standing && samePlanDigest(standing.planDigest, record.planDigest) ? humanQuorumSince(pending, standing) : since;
|
|
1364
|
+
const tally = tallyGateApprovals([...prior, record], record.gate, since, record.planDigest, approval, humansSince);
|
|
1353
1365
|
const roles = approval.quorum?.roles ? ` with role ${approval.quorum.roles.join(" or ")}` : "";
|
|
1354
1366
|
const who = tally.counted.map((r) => r.resolvedBy).join(", ") || "none yet";
|
|
1355
1367
|
lines.push(`Quorum: ${tally.counted.length} of ${tally.need} human approval(s)${roles} for this plan (${who}).`);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3760: `chant approve policy-override <subject> --rule <id> --reason <text>`
|
|
3
|
+
* records an override of the subject's recorded denial, for exactly that
|
|
4
|
+
* plan and those rules.
|
|
5
|
+
*/
|
|
6
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
7
|
+
import type { ParsedArgs } from "../registry";
|
|
8
|
+
import { overrideDigest, overridePending, OVERRIDE_OP } from "../../override-gate";
|
|
9
|
+
|
|
10
|
+
const recordGateApprovalMock = vi.fn();
|
|
11
|
+
const readOverrideLedgerMock = vi.fn();
|
|
12
|
+
const requireLifecycleLedgerMock = vi.fn();
|
|
13
|
+
const originMock = vi.fn(() => "cli");
|
|
14
|
+
|
|
15
|
+
vi.mock("./operator", () => ({ recordGateApproval: (...a: unknown[]) => recordGateApprovalMock(...a) }));
|
|
16
|
+
vi.mock("../../lifecycle/override-ledger", () => ({ readOverrideLedger: (...a: unknown[]) => readOverrideLedgerMock(...a) }));
|
|
17
|
+
vi.mock("../../lifecycle/git", () => ({ requireLifecycleLedger: (...a: unknown[]) => requireLifecycleLedgerMock(...a) }));
|
|
18
|
+
vi.mock("../../lifecycle/gate-origin", async () => {
|
|
19
|
+
const actual = await vi.importActual<typeof import("../../lifecycle/gate-origin")>("../../lifecycle/gate-origin");
|
|
20
|
+
return { ...actual, currentGateOrigin: () => originMock() };
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const { runOverrideApprove } = await import("./override-approve");
|
|
24
|
+
|
|
25
|
+
const PLAN = `sha256:${"a".repeat(64)}`;
|
|
26
|
+
const DENIAL = overridePending({ subject: "prod", planDigest: PLAN, rules: ["no-drop", "no-lock"], timestamp: "2026-10-11T10:00:00.000Z", expiresAt: "2099-01-01T00:00:00.000Z" });
|
|
27
|
+
|
|
28
|
+
function ctx(args: Partial<ParsedArgs>) {
|
|
29
|
+
return {
|
|
30
|
+
args: { command: "approve", path: OVERRIDE_OP, extraPositional: "prod", format: "", fix: false, watch: false, verbose: false, help: false, live: false, ...args } as ParsedArgs,
|
|
31
|
+
plugins: [],
|
|
32
|
+
serializers: [],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let err: ReturnType<typeof vi.spyOn>;
|
|
37
|
+
const printed = () => err.mock.calls.flat().join("\n");
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.clearAllMocks();
|
|
41
|
+
err = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
42
|
+
originMock.mockReturnValue("cli");
|
|
43
|
+
requireLifecycleLedgerMock.mockResolvedValue(undefined);
|
|
44
|
+
readOverrideLedgerMock.mockResolvedValue({ resolutions: [], pending: [DENIAL], applied: [] });
|
|
45
|
+
recordGateApprovalMock.mockResolvedValue({ ok: true, pushed: true, record: { version: 1, op: OVERRIDE_OP, gate: "prod", resolvedBy: "github:alice", timestamp: "2026-10-11T11:00:00.000Z" } });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("chant approve policy-override", () => {
|
|
49
|
+
test("approves the denial's override digest with the reason as its note", async () => {
|
|
50
|
+
const code = await runOverrideApprove(ctx({ rules: ["no-lock", "no-drop"], reason: " the table is empty ", actor: "github:alice", sign: true }));
|
|
51
|
+
expect(code).toBe(0);
|
|
52
|
+
expect(recordGateApprovalMock).toHaveBeenCalledWith(OVERRIDE_OP, "prod", expect.objectContaining({
|
|
53
|
+
actor: "github:alice", note: "the table is empty", plan: overrideDigest("prod", PLAN, ["no-drop", "no-lock"]), sign: true,
|
|
54
|
+
}));
|
|
55
|
+
expect(printed()).toMatch(/overrides the denial of prod by no-drop, no-lock for the plan sha256:a{64}/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("--note is the same field as --reason", async () => {
|
|
59
|
+
expect(await runOverrideApprove(ctx({ rules: ["no-drop", "no-lock"], note: "empty table" }))).toBe(0);
|
|
60
|
+
expect(recordGateApprovalMock).toHaveBeenCalledWith(OVERRIDE_OP, "prod", expect.objectContaining({ note: "empty table" }));
|
|
61
|
+
expect(await runOverrideApprove(ctx({ rules: ["no-drop", "no-lock"], note: "a", reason: "b" }))).toBe(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test.each<[string, Partial<ParsedArgs>, RegExp]>([
|
|
65
|
+
["no rule", { reason: "why" }, /names each rule it overrides with --rule/],
|
|
66
|
+
["no reason", { rules: ["no-drop", "no-lock"], reason: " " }, /needs --reason/],
|
|
67
|
+
["other rules", { rules: ["no-drop"], reason: "why" }, /denied by no-drop, no-lock; an override names exactly those rules, and --rule gave no-drop/],
|
|
68
|
+
["another plan", { rules: ["no-drop", "no-lock"], reason: "why", plan: `sha256:${"b".repeat(64)}` }, /is not the plan denied for prod/],
|
|
69
|
+
["an agent", { rules: ["no-drop", "no-lock"], reason: "why", agent: true }, /An override is a person.s/],
|
|
70
|
+
["no denial", { rules: ["x"], reason: "why", extraPositional: "staging" }, /No denial of staging is recorded on policy-override; denied: prod/],
|
|
71
|
+
])("refuses %s, and records nothing", async (_name, args, why) => {
|
|
72
|
+
expect(await runOverrideApprove(ctx(args))).toBe(1);
|
|
73
|
+
expect(printed()).toMatch(why);
|
|
74
|
+
expect(recordGateApprovalMock).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("is refused over MCP", async () => {
|
|
78
|
+
originMock.mockReturnValue("mcp");
|
|
79
|
+
expect(await runOverrideApprove(ctx({ rules: ["no-drop", "no-lock"], reason: "why" }))).toBe(1);
|
|
80
|
+
expect(recordGateApprovalMock).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("--plan may name the denied plan or the override digest", async () => {
|
|
84
|
+
expect(await runOverrideApprove(ctx({ rules: ["no-drop", "no-lock"], reason: "why", plan: PLAN }))).toBe(0);
|
|
85
|
+
expect(await runOverrideApprove(ctx({ rules: ["no-drop", "no-lock"], reason: "why", plan: DENIAL.planDigest }))).toBe(0);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant approve policy-override <subject> --rule <id>... --reason <text>`
|
|
3
|
+
* (#3760): record a person's override of the denial a run recorded for
|
|
4
|
+
* `subject`. The override is an approval of the denial's override digest
|
|
5
|
+
* (`../../override-gate.ts`), with the reason as its note, written by the
|
|
6
|
+
* same `recordGateApproval` every `chant approve` uses, so `--sign`,
|
|
7
|
+
* `--actor`, `--relayed-by` and `--url` work as they do there.
|
|
8
|
+
*
|
|
9
|
+
* It names the rules it overrides, which must be exactly the rules the denial
|
|
10
|
+
* recorded: the approver says what they are letting through. Whether the
|
|
11
|
+
* override counts is decided by the run, against the overriders and signers
|
|
12
|
+
* at base; this command only warns when the checkout's own overriders file
|
|
13
|
+
* would not count it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { currentGateOrigin, isModelAuthored } from "../../lifecycle/gate-origin";
|
|
17
|
+
import { requireLifecycleLedger } from "../../lifecycle/git";
|
|
18
|
+
import { readOverrideLedger } from "../../lifecycle/override-ledger";
|
|
19
|
+
import { samePlanDigest } from "../../lifecycle/plan-digest";
|
|
20
|
+
import { OVERRIDE_OP, OVERRIDERS_PATH, overrideCommand, overridersAt, recordedDenials, sortedRules } from "../../override-gate";
|
|
21
|
+
import { formatError, formatInfo, formatWarning } from "../format";
|
|
22
|
+
import type { CommandContext } from "../registry";
|
|
23
|
+
import { recordGateApproval } from "./operator";
|
|
24
|
+
|
|
25
|
+
const norm = (s: string): string => s.normalize("NFKC").trim().toLowerCase();
|
|
26
|
+
|
|
27
|
+
export async function runOverrideApprove(ctx: CommandContext): Promise<number> {
|
|
28
|
+
const subject = ctx.args.extraPositional!;
|
|
29
|
+
const usage = overrideCommand(subject, ctx.args.rules?.length ? ctx.args.rules : ["<id>"]);
|
|
30
|
+
if (ctx.args.reason !== undefined && ctx.args.note !== undefined && ctx.args.reason.trim() !== ctx.args.note.trim()) {
|
|
31
|
+
console.error(formatError({ message: "Give the override's reason once: --reason <text> (--note is the same field)", hint: usage }));
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
const reason = (ctx.args.reason ?? ctx.args.note)?.trim();
|
|
35
|
+
const rules = sortedRules(ctx.args.rules ?? []);
|
|
36
|
+
if (rules.length === 0) {
|
|
37
|
+
console.error(formatError({ message: `An override names each rule it overrides with --rule <id>`, hint: usage }));
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
if (!reason) {
|
|
41
|
+
console.error(formatError({ message: `An override needs --reason <text>: why this plan goes out although the policy denies it`, hint: usage }));
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
if (ctx.args.agent || isModelAuthored(currentGateOrigin())) {
|
|
45
|
+
console.error(formatError({ message: `An override is a person's: it is never recorded by an agent or over MCP or ACP`, hint: `${usage}, at a shell.` }));
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await requireLifecycleLedger();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error(formatError({ message: `Cannot record the override: ${err instanceof Error ? err.message : String(err)}` }));
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
const ledger = await readOverrideLedger();
|
|
56
|
+
const denials = recordedDenials(ledger.pending);
|
|
57
|
+
const denial = denials.find((d) => d.gate === subject);
|
|
58
|
+
if (!denial) {
|
|
59
|
+
console.error(formatError({
|
|
60
|
+
message: `No denial of ${subject} is recorded on ${OVERRIDE_OP}` + (denials.length ? `; denied: ${denials.map((d) => d.gate).join(", ")}` : ""),
|
|
61
|
+
hint: "A run records one when its policy denies the plan. An override answers that denial, for that plan.",
|
|
62
|
+
}));
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
if (denial.rules.join("\n") !== rules.join("\n")) {
|
|
66
|
+
console.error(formatError({
|
|
67
|
+
message: `${subject} was denied by ${denial.rules.join(", ") || "no rule it named"}; an override names exactly those rules, and --rule gave ${rules.join(", ")}`,
|
|
68
|
+
hint: overrideCommand(subject, denial.rules),
|
|
69
|
+
}));
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
if (ctx.args.plan !== undefined && !samePlanDigest(ctx.args.plan, denial.plan) && !samePlanDigest(ctx.args.plan, denial.planDigest)) {
|
|
73
|
+
console.error(formatError({
|
|
74
|
+
message: `--plan ${ctx.args.plan} is not the plan denied for ${subject}: the newest denial is of ${denial.plan || denial.planDigest}`,
|
|
75
|
+
hint: "Run the plan again, or override the denial that stands.",
|
|
76
|
+
}));
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const outcome = await recordGateApproval(OVERRIDE_OP, subject, {
|
|
81
|
+
actor: ctx.args.approver ?? ctx.args.actor,
|
|
82
|
+
note: reason,
|
|
83
|
+
url: ctx.args.url,
|
|
84
|
+
plan: denial.planDigest!,
|
|
85
|
+
allowSameOrigin: ctx.args.allowSameOrigin,
|
|
86
|
+
...(ctx.args.roles ? { roles: ctx.args.roles } : {}),
|
|
87
|
+
...(ctx.args.sign !== undefined ? { sign: ctx.args.sign } : {}),
|
|
88
|
+
...(ctx.args.relayedBy !== undefined ? { relayedBy: ctx.args.relayedBy } : {}),
|
|
89
|
+
});
|
|
90
|
+
if (!outcome.ok) return 1;
|
|
91
|
+
|
|
92
|
+
console.error(formatInfo(
|
|
93
|
+
`This overrides the denial of ${subject} by ${rules.join(", ")} for the plan ${denial.plan || denial.planDigest}. ` +
|
|
94
|
+
`The next run that plans it applies it once; a plan that moves, or rules that change, need their own override.`,
|
|
95
|
+
));
|
|
96
|
+
const here = overridersAt(process.cwd(), "HEAD");
|
|
97
|
+
if (!here.active) {
|
|
98
|
+
console.error(formatWarning({ message: `This checkout has no ${OVERRIDERS_PATH}: unless the run reads its overriders from elsewhere at base, no override counts` }));
|
|
99
|
+
} else if (!here.principals.map(norm).includes(norm(outcome.record.resolvedBy))) {
|
|
100
|
+
console.error(formatWarning({ message: `${outcome.record.resolvedBy} is not in ${OVERRIDERS_PATH} here (${here.principals.join(", ") || "nobody"}), so a run that reads it at base does not count this override` }));
|
|
101
|
+
}
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import { readFileSync } from "node:fs";
|
|
7
7
|
import { relative, resolve } from "node:path";
|
|
8
|
+
import { notifyOpWave } from "../../op/op-waves-notify";
|
|
8
9
|
import { parseOpWavesSpec, recordOpWaveHeadPlans, runOpWave } from "../../op/op-waves-run";
|
|
10
|
+
import type { OpWavesSpec } from "../../op/op-waves";
|
|
9
11
|
import { formatError } from "../format";
|
|
10
12
|
import type { CommandContext } from "../registry";
|
|
11
13
|
|
|
@@ -35,9 +37,11 @@ export async function runOpWaveCommand(ctx: CommandContext): Promise<number> {
|
|
|
35
37
|
console.error(formatError({ message: "--decide and --share are two different jobs; pass one" }));
|
|
36
38
|
return 1;
|
|
37
39
|
}
|
|
40
|
+
let spec: OpWavesSpec | undefined;
|
|
41
|
+
const job = args.share !== undefined ? { share: args.share } : {};
|
|
38
42
|
try {
|
|
39
43
|
const path = resolve(specFile);
|
|
40
|
-
|
|
44
|
+
spec = parseOpWavesSpec(readFileSync(path, "utf-8"), `--spec ${specFile}`);
|
|
41
45
|
const result = await runOpWave({
|
|
42
46
|
spec,
|
|
43
47
|
specFile: relative(process.cwd(), path),
|
|
@@ -47,9 +51,13 @@ export async function runOpWaveCommand(ctx: CommandContext): Promise<number> {
|
|
|
47
51
|
...(args.base ? { base: args.base } : {}),
|
|
48
52
|
});
|
|
49
53
|
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
54
|
+
// #3761: tell people, when the spec says where. Delivery never changes the exit code.
|
|
55
|
+
await notifyOpWave(spec, args.wave, { result }, job);
|
|
50
56
|
return result.exitCode;
|
|
51
57
|
} catch (err) {
|
|
52
|
-
|
|
58
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
59
|
+
console.error(formatError({ message }));
|
|
60
|
+
if (spec) await notifyOpWave(spec, args.wave, { error: message }, job);
|
|
53
61
|
return 1;
|
|
54
62
|
}
|
|
55
63
|
}
|
package/src/cli/main.test.ts
CHANGED
|
@@ -346,6 +346,14 @@ describe("parseArgs", () => {
|
|
|
346
346
|
expect(result.url).toBe("https://github.com/org/repo/pull/1");
|
|
347
347
|
});
|
|
348
348
|
|
|
349
|
+
test("parses --rule (repeated) and --reason for an override (#3760)", () => {
|
|
350
|
+
const result = parseArgs(["approve", "policy-override", "prod", "--rule", "no-drop", "--rule", "no-lock", "--reason", "the table is empty"]);
|
|
351
|
+
expect(result.rules).toEqual(["no-drop", "no-lock"]);
|
|
352
|
+
expect(result.reason).toBe("the table is empty");
|
|
353
|
+
expect(() => parseArgs(["approve", "policy-override", "prod", "--rule"])).toThrow(/--rule needs a rule id/);
|
|
354
|
+
expect(() => parseArgs(["approve", "policy-override", "prod", "--reason"])).toThrow(/--reason needs the text/);
|
|
355
|
+
});
|
|
356
|
+
|
|
349
357
|
// ── --no-release-record (#597) ───────────────────────────────────────────
|
|
350
358
|
|
|
351
359
|
test("--promote-to <env> sets promoteTo, and a missing value is refused (#2575)", () => {
|
package/src/cli/main.ts
CHANGED
|
@@ -696,6 +696,15 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
696
696
|
result[arg.slice(2) as "unit" | "decision"] = value;
|
|
697
697
|
} else if (arg === "--note") {
|
|
698
698
|
result.note = args[++i];
|
|
699
|
+
} else if (arg === "--reason") {
|
|
700
|
+
// `chant approve policy-override <subject> --reason <text>` (#3760): why the denied plan goes out anyway.
|
|
701
|
+
result.reason = args[++i];
|
|
702
|
+
if (result.reason === undefined || result.reason.startsWith("-")) throw new Error("--reason needs the text of the reason: --reason <text>");
|
|
703
|
+
} else if (arg === "--rule") {
|
|
704
|
+
// `chant approve policy-override <subject> --rule <id>` (#3760), once per rule that denied the plan.
|
|
705
|
+
const value = args[++i];
|
|
706
|
+
if (!value || value.startsWith("-")) throw new Error("--rule needs a rule id: --rule <id>");
|
|
707
|
+
(result.rules ??= []).push(value);
|
|
699
708
|
} else if (arg === "--expire") {
|
|
700
709
|
result.expire = true;
|
|
701
710
|
} else if (arg === "--role") {
|
|
@@ -999,6 +1008,12 @@ Ops:
|
|
|
999
1008
|
--resume then starts the CI job that waits at the
|
|
1000
1009
|
gate again (GitHub re-run, GitLab retry, Forgejo
|
|
1001
1010
|
dispatch) with CHANT_FORGE_TOKEN
|
|
1011
|
+
On op policy-override the gate is the subject a
|
|
1012
|
+
policy denied: --rule <id> names each rule that
|
|
1013
|
+
denied it and --reason <text> says why the plan goes
|
|
1014
|
+
out anyway; the next run that plans the same digest
|
|
1015
|
+
applies it once, if .chant/overriders at base lists
|
|
1016
|
+
--actor
|
|
1002
1017
|
|
|
1003
1018
|
graph Show Op dependency graph (--stacks for cross-stack order,
|
|
1004
1019
|
--format ir|mermaid|dot|layout for the lint-gated graph IR,
|
package/src/cli/registry.ts
CHANGED
|
@@ -598,6 +598,10 @@ export interface ParsedArgs {
|
|
|
598
598
|
once?: boolean;
|
|
599
599
|
/** `chant approve <op> <gate> --note <text>` (#1485) — optional free-text prose recorded on the gate-resolution fact. The PR link belongs in `--url` since #2028; this is for everything that isn't the link. */
|
|
600
600
|
note?: string;
|
|
601
|
+
/** `chant approve policy-override <subject> --reason <text>` (#3760): why a denied plan goes out anyway, recorded as the override's note. */
|
|
602
|
+
reason?: string;
|
|
603
|
+
/** `chant approve policy-override <subject> --rule <id>` (#3760), repeated: the rules that denied the plan, which the override names. */
|
|
604
|
+
rules?: string[];
|
|
601
605
|
/** `chant approve <op> <gate> --expire` (#2119) — clear the gate's standing pending fact instead of approving it, so the next run decides the gate from scratch and records a fresh one. Writes no resolution: nothing is approved, the wait is only restarted. */
|
|
602
606
|
expire?: boolean;
|
|
603
607
|
/**
|