@intentius/chant 0.124.44 → 0.124.46
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/capabilities/aws.paths +148 -0
- package/dist/capabilities/base-mappings.json +122 -0
- package/dist/capabilities/catalog.d.ts +89 -0
- package/dist/capabilities/catalog.d.ts.map +1 -0
- package/dist/capabilities/catalog.js +204 -0
- package/dist/capabilities/language.d.ts +103 -0
- package/dist/capabilities/language.d.ts.map +1 -0
- package/dist/capabilities/language.js +335 -0
- package/dist/capabilities/matcher.d.ts +65 -0
- package/dist/capabilities/matcher.d.ts.map +1 -0
- package/dist/capabilities/matcher.js +299 -0
- package/dist/capabilities/view.d.ts +207 -0
- package/dist/capabilities/view.d.ts.map +1 -0
- package/dist/capabilities/view.js +484 -0
- package/dist/ci-identity.d.ts +183 -12
- package/dist/ci-identity.d.ts.map +1 -1
- package/dist/ci-identity.js +362 -53
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/lint.js +1 -0
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +17 -1
- package/dist/cli/handlers/declared-graph.d.ts +44 -0
- package/dist/cli/handlers/declared-graph.d.ts.map +1 -0
- package/dist/cli/handlers/declared-graph.js +87 -0
- package/dist/cli/handlers/graph.d.ts +25 -0
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/graph.js +93 -87
- package/dist/cli/handlers/pr-env.d.ts +8 -2
- package/dist/cli/handlers/pr-env.d.ts.map +1 -1
- package/dist/cli/handlers/pr-env.js +127 -225
- package/dist/cli/handlers/pr.d.ts +3 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +1 -0
- package/dist/cli/handlers/search-count.d.ts +89 -0
- package/dist/cli/handlers/search-count.d.ts.map +1 -0
- package/dist/cli/handlers/search-count.js +132 -0
- package/dist/cli/handlers/search.d.ts +358 -6
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/handlers/search.js +1065 -155
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +23 -3
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/server.js +3 -0
- package/dist/cli/mcp/tools/capabilities.d.ts +7 -0
- package/dist/cli/mcp/tools/capabilities.d.ts.map +1 -0
- package/dist/cli/mcp/tools/capabilities.js +27 -0
- package/dist/cli/mcp/workspace-tools.d.ts +3 -1
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.js +47 -2
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +64 -17
- package/dist/cli/registry.d.ts +22 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +2 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/cli-support.js +4 -2
- package/dist/components/fan-out-support.d.ts +3 -0
- package/dist/components/fan-out-support.d.ts.map +1 -1
- package/dist/components/fan-out-support.js +1 -1
- package/dist/components/pr-env-ports.d.ts +29 -0
- package/dist/components/pr-env-ports.d.ts.map +1 -0
- package/dist/components/pr-env-ports.js +77 -0
- package/dist/components/pr-env-run.d.ts +254 -0
- package/dist/components/pr-env-run.d.ts.map +1 -0
- package/dist/components/pr-env-run.js +376 -0
- package/dist/components/pr-environment.d.ts +91 -15
- package/dist/components/pr-environment.d.ts.map +1 -1
- package/dist/components/pr-environment.js +95 -9
- package/dist/components/pr-pipeline.d.ts +10 -1
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/config-environments.d.ts +92 -0
- package/dist/config-environments.d.ts.map +1 -0
- package/dist/config-environments.js +102 -0
- package/dist/config.d.ts +24 -84
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +9 -96
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +15 -3
- package/dist/graph-effective.d.ts +33 -2
- package/dist/graph-effective.d.ts.map +1 -1
- package/dist/graph-effective.js +215 -68
- package/dist/graph-ir.d.ts +23 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-ir.js +130 -0
- package/dist/graph-sub-template.d.ts +11 -0
- package/dist/graph-sub-template.d.ts.map +1 -0
- package/dist/graph-sub-template.js +19 -0
- package/dist/lexicon.d.ts +9 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +28 -0
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/observe.js +23 -1
- package/dist/lint/rule.d.ts +6 -0
- package/dist/lint/rule.d.ts.map +1 -1
- package/dist/live-endpoint.d.ts +1 -1
- package/dist/live-endpoint.d.ts.map +1 -1
- package/dist/live-endpoint.js +1 -1
- package/dist/observation.d.ts +48 -1
- package/dist/observation.d.ts.map +1 -1
- package/dist/observation.js +21 -5
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/op-waves.d.ts +17 -0
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/op-waves.js +23 -1
- package/dist/pr-forge.d.ts +15 -1
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +28 -16
- package/dist/pr-requires.d.ts +5 -1
- package/dist/pr-requires.d.ts.map +1 -1
- package/dist/wave-runtime.mjs +251 -250
- package/dist/workspace/capabilities-contract.d.ts +66 -0
- package/dist/workspace/capabilities-contract.d.ts.map +1 -0
- package/dist/workspace/capabilities-contract.js +107 -0
- package/dist/workspace/capabilities.schema.json +229 -0
- package/dist/workspace/ci-cloud-identity.d.ts +3 -1
- package/dist/workspace/ci-cloud-identity.d.ts.map +1 -1
- package/dist/workspace/ci-cloud-identity.js +16 -1
- package/dist/workspace/ci-run.d.ts +35 -8
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +95 -21
- package/dist/workspace/ci-secrets.d.ts.map +1 -1
- package/dist/workspace/ci-secrets.js +4 -3
- package/dist/workspace/compose-graph.d.ts +9 -0
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/compose-graph.js +3 -1
- package/dist/workspace/conformance/index.d.ts +1 -1
- package/dist/workspace/conformance/index.d.ts.map +1 -1
- package/dist/workspace/conformance/index.js +39 -2
- package/dist/workspace/declaration.d.ts +10 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +17 -1
- package/dist/workspace/declaration.schema.json +36 -1
- package/dist/workspace/graph-cache.d.ts +8 -0
- package/dist/workspace/graph-cache.d.ts.map +1 -1
- package/dist/workspace/graph-cache.js +27 -3
- package/dist/workspace/graph-cli.d.ts +39 -2
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/graph-cli.js +66 -24
- package/dist/workspace/graph.schema.json +148 -0
- package/dist/workspace/kind-readers.d.ts +3 -0
- package/dist/workspace/kind-readers.d.ts.map +1 -1
- package/dist/workspace/kind-readers.js +7 -1
- package/dist/workspace/member-commands.d.ts +10 -1
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/member-commands.js +17 -1
- package/dist/workspace/nested-graph.d.ts +2 -0
- package/dist/workspace/nested-graph.d.ts.map +1 -1
- package/dist/workspace/nested-graph.js +32 -1
- package/dist/workspace/reason-codes.d.ts +10 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +12 -0
- package/dist/workspace/search-cli.d.ts +204 -0
- package/dist/workspace/search-cli.d.ts.map +1 -0
- package/dist/workspace/search-cli.js +466 -0
- package/dist/workspace/search-contract.d.ts +56 -0
- package/dist/workspace/search-contract.d.ts.map +1 -0
- package/dist/workspace/search-contract.js +70 -0
- package/dist/workspace/search.schema.json +822 -0
- package/package.json +1 -1
- package/src/__snapshots__/okf.test.ts.snap +10 -0
- package/src/capabilities/aws.paths +148 -0
- package/src/capabilities/base-mappings.json +122 -0
- package/src/capabilities/catalog.ts +230 -0
- package/src/capabilities/language.test.ts +191 -0
- package/src/capabilities/language.ts +373 -0
- package/src/capabilities/matcher.ts +315 -0
- package/src/capabilities/view-compose.test.ts +76 -0
- package/src/capabilities/view.ts +631 -0
- package/src/ci-identity-targets.test.ts +275 -0
- package/src/ci-identity.ts +394 -61
- package/src/cli/commands/lint.ts +1 -0
- package/src/cli/handlers/build.ts +15 -1
- package/src/cli/handlers/declared-graph.ts +99 -0
- package/src/cli/handlers/graph.test.ts +94 -3
- package/src/cli/handlers/graph.ts +123 -96
- package/src/cli/handlers/pr-env.ts +135 -227
- package/src/cli/handlers/pr.ts +4 -0
- package/src/cli/handlers/search-build-roots.test.ts +144 -0
- package/src/cli/handlers/search-count.test.ts +191 -0
- package/src/cli/handlers/search-count.ts +165 -0
- package/src/cli/handlers/search-json.test.ts +333 -0
- package/src/cli/handlers/search-live.test.ts +387 -0
- package/src/cli/handlers/search-paths.test.ts +235 -0
- package/src/cli/handlers/search.ts +1312 -166
- package/src/cli/main.test.ts +28 -3
- package/src/cli/main.ts +23 -3
- package/src/cli/mcp/server.test.ts +3 -3
- package/src/cli/mcp/server.ts +3 -0
- package/src/cli/mcp/tools/capabilities.ts +32 -0
- package/src/cli/mcp/workspace-tools.e2e.test.ts +21 -1
- package/src/cli/mcp/workspace-tools.ts +47 -2
- package/src/cli/parse-args.ts +52 -13
- package/src/cli/registry.ts +22 -0
- package/src/components/cli-support.ts +6 -1
- package/src/components/fan-out-support.ts +4 -1
- package/src/components/pr-env-ports.ts +89 -0
- package/src/components/pr-env-run-bundle.test.ts +54 -0
- package/src/components/pr-env-run.test.ts +290 -0
- package/src/components/pr-env-run.ts +563 -0
- package/src/components/pr-environment.ts +161 -13
- package/src/components/pr-pipeline.ts +10 -1
- package/src/config-environments.ts +133 -0
- package/src/config.ts +37 -124
- package/src/graph-detail.ts +15 -3
- package/src/graph-effective.ts +226 -75
- package/src/graph-ir.test.ts +163 -0
- package/src/graph-ir.ts +147 -0
- package/src/graph-sub-template.ts +22 -0
- package/src/lexicon.ts +10 -0
- package/src/lifecycle/observe.test.ts +27 -1
- package/src/lifecycle/observe.ts +43 -1
- package/src/lint/rule.ts +6 -0
- package/src/live-endpoint.ts +1 -1
- package/src/observation.test.ts +25 -0
- package/src/observation.ts +69 -5
- package/src/op/op-waves.ts +38 -1
- package/src/pr-forge-bundle.test.ts +1 -1
- package/src/pr-forge.ts +44 -17
- package/src/pr-requires.test.ts +43 -6
- package/src/pr-requires.ts +5 -1
- package/src/workspace/capabilities-contract.test.ts +88 -0
- package/src/workspace/capabilities-contract.ts +132 -0
- package/src/workspace/capabilities.schema.json +229 -0
- package/src/workspace/ci-cloud-identity.test.ts +20 -0
- package/src/workspace/ci-cloud-identity.ts +14 -1
- package/src/workspace/ci-run.test.ts +74 -1
- package/src/workspace/ci-run.ts +122 -29
- package/src/workspace/ci-secrets.test.ts +1 -1
- package/src/workspace/ci-secrets.ts +4 -3
- package/src/workspace/compose-graph.ts +10 -1
- package/src/workspace/conformance/conformance.e2e.test.ts +14 -1
- package/src/workspace/conformance/index.ts +31 -2
- package/src/workspace/declaration.schema.json +36 -1
- package/src/workspace/declaration.test.ts +7 -0
- package/src/workspace/declaration.ts +24 -2
- package/src/workspace/graph-cache.ts +27 -3
- package/src/workspace/graph-cli.ts +96 -24
- package/src/workspace/graph.schema.json +148 -0
- package/src/workspace/kind-readers.e2e.test.ts +28 -0
- package/src/workspace/kind-readers.test.ts +1 -1
- package/src/workspace/kind-readers.ts +7 -1
- package/src/workspace/member-commands.ts +22 -1
- package/src/workspace/nested-graph.ts +32 -1
- package/src/workspace/reason-codes.test.ts +7 -0
- package/src/workspace/reason-codes.ts +12 -0
- package/src/workspace/search-cli.e2e.test.ts +305 -0
- package/src/workspace/search-cli.ts +591 -0
- package/src/workspace/search-contract.ts +95 -0
- package/src/workspace/search.schema.json +822 -0
package/src/op/op-waves.ts
CHANGED
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
|
|
77
77
|
import type { OpEnvironment, OpSetupStep } from "../lexicon";
|
|
78
78
|
import type { CiRunner } from "../ci-jobs";
|
|
79
|
+
import type { CloudTradeSpec } from "../ci-identity";
|
|
79
80
|
import { gatePointProblems, type GatePoint } from "./gate-point";
|
|
80
81
|
import { quorumProblems, type GateQuorumRule } from "./gate-quorum";
|
|
81
82
|
import { notifyConfigProblem, type NotifyConfig } from "../notify";
|
|
@@ -261,6 +262,13 @@ export interface OpWaveJobGroup {
|
|
|
261
262
|
* is requested.
|
|
262
263
|
*/
|
|
263
264
|
idTokens?: Record<string, string>;
|
|
265
|
+
/**
|
|
266
|
+
* The cloud identities the group's jobs trade the job's OIDC token for
|
|
267
|
+
* (#4332), in place of the wave's: a {@link CloudTradeSpec}, as
|
|
268
|
+
* `cloudIdentityWave` in ../ci-identity.ts gives a group its apply
|
|
269
|
+
* identities. The trade runs before the checkout.
|
|
270
|
+
*/
|
|
271
|
+
cloud?: CloudTradeSpec;
|
|
264
272
|
}
|
|
265
273
|
|
|
266
274
|
/** One wave: runs that apply together, behind one gate. */
|
|
@@ -321,6 +329,15 @@ export interface OpWave {
|
|
|
321
329
|
* job's, and a group's own replace them.
|
|
322
330
|
*/
|
|
323
331
|
idTokens?: Record<string, string>;
|
|
332
|
+
/**
|
|
333
|
+
* The cloud identities this wave's jobs trade the job's OIDC token for
|
|
334
|
+
* (#4332), before the checkout: a {@link CloudTradeSpec}. With `jobs`,
|
|
335
|
+
* the deciding job's, and a group's own `cloud` replaces it.
|
|
336
|
+
* `cloudIdentityWave` in ../ci-identity.ts gives the deciding job every
|
|
337
|
+
* plan identity of the wave's targets. Each later step of the job starts
|
|
338
|
+
* without the variables a token is requested with.
|
|
339
|
+
*/
|
|
340
|
+
cloud?: CloudTradeSpec;
|
|
324
341
|
/**
|
|
325
342
|
* Split the wave's apply into job groups (#3914): a deciding job, which
|
|
326
343
|
* runs with the wave's own settings, plans every run and decides the gate,
|
|
@@ -644,6 +661,7 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
|
|
|
644
661
|
}
|
|
645
662
|
if (wave.runner !== undefined) assertOpWaveRunner(where, `wave "${wave.name}" runner`, wave.runner);
|
|
646
663
|
if (wave.idTokens !== undefined) assertOpWaveIdTokens(where, `wave "${wave.name}" idTokens`, wave.idTokens);
|
|
664
|
+
if (wave.cloud !== undefined) assertOpWaveCloud(where, `wave "${wave.name}" cloud`, wave.cloud);
|
|
647
665
|
if (wave.jobs !== undefined) assertOpWaveJobGroups(where, wave);
|
|
648
666
|
if (wave.discover !== undefined && typeof wave.discover !== "boolean") throw new Error(`${where}: wave "${wave.name}" has discover ${JSON.stringify(wave.discover)}; use true or false.`);
|
|
649
667
|
if (wave.rest !== undefined && typeof wave.rest !== "boolean") throw new Error(`${where}: wave "${wave.name}" has rest ${JSON.stringify(wave.rest)}; use true or false.`);
|
|
@@ -684,6 +702,24 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
|
|
|
684
702
|
|
|
685
703
|
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
686
704
|
|
|
705
|
+
/** The key each cloud of a `cloud` trade names its identity under (`IDENTITY_KEY` in ../ci-identity.ts, kept here so this module imports none of it). */
|
|
706
|
+
const CLOUD_IDENTITY_KEY: Record<string, string> = { aws: "role", gcp: "serviceAccount", azure: "clientId" };
|
|
707
|
+
|
|
708
|
+
/** Refuse a `cloud` (#4332) the trade could not run: an unknown cloud, one that names no identity, or no audience. */
|
|
709
|
+
function assertOpWaveCloud(where: string, field: string, cloud: unknown): void {
|
|
710
|
+
if (cloud === null || typeof cloud !== "object" || Array.isArray(cloud) || Object.keys(cloud).length === 0) throw new Error(`${where}: ${field} must name one or more of aws, gcp and azure.`);
|
|
711
|
+
for (const [name, c] of Object.entries(cloud as Record<string, unknown>)) {
|
|
712
|
+
const key = CLOUD_IDENTITY_KEY[name];
|
|
713
|
+
if (!key) throw new Error(`${where}: ${field}.${name} is not a cloud; use aws, gcp or azure.`);
|
|
714
|
+
const t = c as Record<string, unknown> | null;
|
|
715
|
+
const targets = t?.targets;
|
|
716
|
+
const targetsOk = targets === undefined || (Array.isArray(targets) && targets.every((e) => e && Array.isArray((e as { targets?: unknown }).targets) && typeof (e as Record<string, unknown>)[key] === "string"));
|
|
717
|
+
if (!t || typeof t.audience !== "string" || !targetsOk || (typeof t[key] !== "string" && !(Array.isArray(targets) && targets.length > 0))) {
|
|
718
|
+
throw new Error(`${where}: ${field}.${name} must give an audience and a ${key}, or targets each with targets and a ${key}, as cloudIdentityWave gives them.`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
687
723
|
/** Refuse a `runner` no forge could read (#3914). */
|
|
688
724
|
function assertOpWaveRunner(where: string, field: string, runner: unknown): void {
|
|
689
725
|
const labels = (value: unknown): boolean => Array.isArray(value) && value.length > 0 && value.every((l) => typeof l === "string" && l.trim() !== "");
|
|
@@ -723,7 +759,7 @@ function assertOpWaveJobGroups(where: string, wave: OpWave): void {
|
|
|
723
759
|
if (group.name === "decide" || /^share-\d+$/.test(group.name)) throw new Error(`${where}: ${field} is named like the wave's own jobs (decide, share-<i>); name it another way.`);
|
|
724
760
|
if (names.has(group.name)) throw new Error(`${where}: ${at} names job group "${group.name}" twice.`);
|
|
725
761
|
names.add(group.name);
|
|
726
|
-
const known = new Set(["name", "runs", "shares", "runner", "environment", "variables", "setup", "idTokens"]);
|
|
762
|
+
const known = new Set(["name", "runs", "shares", "runner", "environment", "variables", "setup", "idTokens", "cloud"]);
|
|
727
763
|
const extra = Object.keys(group).find((key) => !known.has(key));
|
|
728
764
|
if (extra !== undefined) throw new Error(`${where}: ${field}.${extra} is not a setting; a job group takes ${[...known].join(", ")}.`);
|
|
729
765
|
if (!Array.isArray(group.runs) || group.runs.length === 0 || !group.runs.every((r) => typeof r === "string" && r.trim() !== "")) {
|
|
@@ -733,6 +769,7 @@ function assertOpWaveJobGroups(where: string, wave: OpWave): void {
|
|
|
733
769
|
if (!Number.isInteger(shares) || shares < 1) throw new Error(`${where}: ${field} has shares ${group.shares}; use a whole number from 1.`);
|
|
734
770
|
if (group.runner !== undefined) assertOpWaveRunner(where, `${field} runner`, group.runner);
|
|
735
771
|
if (group.idTokens !== undefined) assertOpWaveIdTokens(where, `${field} idTokens`, group.idTokens);
|
|
772
|
+
if (group.cloud !== undefined) assertOpWaveCloud(where, `${field} cloud`, group.cloud);
|
|
736
773
|
if (group.variables !== undefined && (group.variables === null || typeof group.variables !== "object" || Array.isArray(group.variables) || !Object.values(group.variables).every((v) => typeof v === "string"))) {
|
|
737
774
|
throw new Error(`${where}: ${field} variables must be { "<name>": "<value>" }.`);
|
|
738
775
|
}
|
|
@@ -34,7 +34,7 @@ describe("the pr-forge bundle", () => {
|
|
|
34
34
|
expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
|
|
35
35
|
}
|
|
36
36
|
expect(inputs.map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual(["packages/core/src/pr-forge.ts"]);
|
|
37
|
-
// About 50 KB today, after the pull request authors (#4000), pullRequestOpen for target locks (#4293), the four requires checks of an apply before merge (#4301)
|
|
37
|
+
// About 50 KB today, after the pull request authors (#4000), pullRequestOpen for target locks (#4293), the four requires checks of an apply before merge (#4301) the approver filter (#4337) and the approved fields (#4287).
|
|
38
38
|
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(52 * 1024);
|
|
39
39
|
});
|
|
40
40
|
});
|
package/src/pr-forge.ts
CHANGED
|
@@ -420,8 +420,11 @@ export interface PrForge {
|
|
|
420
420
|
* counts when it came after the merge request's latest push, read from its
|
|
421
421
|
* system notes, and never from the token's own user. `approvers` narrows
|
|
422
422
|
* who counts (#4337), and a refusal names each approver left out and why.
|
|
423
|
+
* The result also lists who counted and who was left out (#4287), so a
|
|
424
|
+
* caller never parses `reason`. On GitLab the newest diff version must be
|
|
425
|
+
* of `head`, or the check is pending.
|
|
423
426
|
*/
|
|
424
|
-
prApproved?(pr: number, head: string, approvers?: PrApproverFilter): Promise<
|
|
427
|
+
prApproved?(pr: number, head: string, approvers?: PrApproverFilter): Promise<PrApprovalResult>;
|
|
425
428
|
/**
|
|
426
429
|
* The forge says `pr` merges as it stands: no conflicts, and on GitHub no
|
|
427
430
|
* branch protection blocking it. Pending while the forge works it out. On
|
|
@@ -481,19 +484,26 @@ async function countApprovers(
|
|
|
481
484
|
authors: ReadonlySet<string>,
|
|
482
485
|
filter: PrApproverFilter | undefined,
|
|
483
486
|
rule: (login: string) => Promise<string | undefined>,
|
|
484
|
-
): Promise<{ by: string[]; not:
|
|
487
|
+
): Promise<{ by: string[]; not: PrExcludedApprover[] }> {
|
|
485
488
|
const by: string[] = [];
|
|
486
|
-
const not:
|
|
489
|
+
const not: PrExcludedApprover[] = [];
|
|
487
490
|
for (const login of approved) {
|
|
488
491
|
const why = authors.has(login) ? `${login} is one of its authors` : (excludedWhy(filter, login) ?? (filter?.forgeRule ? await rule(login) : undefined));
|
|
489
|
-
if (why) not.push(why);
|
|
492
|
+
if (why) not.push({ login, reason: why });
|
|
490
493
|
else by.push(login);
|
|
491
494
|
}
|
|
492
495
|
return { by, not };
|
|
493
496
|
}
|
|
494
497
|
|
|
495
|
-
/**
|
|
496
|
-
|
|
498
|
+
/** The approved verdict over who counted: a pass naming them, or a refusal naming each one left out and why. */
|
|
499
|
+
function approvalVerdict(by: string[], not: PrExcludedApprover[], passLine: string, noneLine: string): PrApprovalResult {
|
|
500
|
+
const fields = { approvers: by, excluded: not };
|
|
501
|
+
if (by.length > 0) return { ...pass(`${passLine} by ${by.join(", ")}`), ...fields };
|
|
502
|
+
return { ...refuse(not.length > 0 ? `${noneLine}: ${not.map((n) => n.reason).join("; ")}` : noneLine), ...fields };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** An approved refusal before anyone was counted. */
|
|
506
|
+
const notCounted = (r: PrRequireResult): PrApprovalResult => ({ ...r, approvers: [], excluded: [] });
|
|
497
507
|
|
|
498
508
|
/** GitLab's access level for Developer, the least that may push. */
|
|
499
509
|
const GITLAB_DEVELOPER = 30;
|
|
@@ -510,6 +520,18 @@ export interface PrRequireResult {
|
|
|
510
520
|
reason: string;
|
|
511
521
|
}
|
|
512
522
|
|
|
523
|
+
/**
|
|
524
|
+
* What {@link PrForge.prApproved} found (#4287). `approvers` and `excluded`
|
|
525
|
+
* are empty when it refused before counting, such as on a moved head or a
|
|
526
|
+
* request for changes.
|
|
527
|
+
*/
|
|
528
|
+
export interface PrApprovalResult extends PrRequireResult {
|
|
529
|
+
/** The logins whose approval of the head counted, sorted. */
|
|
530
|
+
approvers: string[];
|
|
531
|
+
/** The logins that approved and did not count, each with the reason the refusal gives. */
|
|
532
|
+
excluded: PrExcludedApprover[];
|
|
533
|
+
}
|
|
534
|
+
|
|
513
535
|
const short = (sha: string | undefined): string => (sha ?? "nothing").slice(0, 8);
|
|
514
536
|
const pass = (reason: string): PrRequireResult => ({ passed: true, reason });
|
|
515
537
|
const refuse = (reason: string, pending = false): PrRequireResult => ({ passed: false, ...(pending ? { pending: true } : {}), reason });
|
|
@@ -867,7 +889,7 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
867
889
|
async prApproved(pr, head, approvers) {
|
|
868
890
|
const got = await api.call("GET", `${repo}/pulls/${pr}`);
|
|
869
891
|
const at = headOf(got);
|
|
870
|
-
if (at !== head) return moved(`pull request ${pr}`, head, at);
|
|
892
|
+
if (at !== head) return notCounted(moved(`pull request ${pr}`, head, at));
|
|
871
893
|
// A reviewer's newest deciding review stands, as in approvers; an approval counts only of this head.
|
|
872
894
|
const standing = new Map<string, { state: string; commit?: string; official?: boolean }>();
|
|
873
895
|
for (const r of await api.list(`${repo}/pulls/${pr}/reviews`, pageParams, pageSize)) {
|
|
@@ -879,7 +901,7 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
879
901
|
standing.set(login, { state, ...(commit ? { commit } : {}), ...(typeof r.official === "boolean" ? { official: r.official } : {}) });
|
|
880
902
|
}
|
|
881
903
|
const changes = [...standing].filter(([, r]) => r.state === "CHANGES_REQUESTED" || r.state === "REQUEST_CHANGES").map(([l]) => l).sort();
|
|
882
|
-
if (changes.length > 0) return refuse(`${changes.join(", ")} asked for changes`);
|
|
904
|
+
if (changes.length > 0) return notCounted(refuse(`${changes.join(", ")} asked for changes`));
|
|
883
905
|
const authors = new Set(await authorsOf(pr));
|
|
884
906
|
const approved = [...standing].filter(([, r]) => r.state === "APPROVED" && r.commit === head).map(([l]) => l).sort();
|
|
885
907
|
const rule = async (login: string): Promise<string | undefined> => {
|
|
@@ -895,8 +917,7 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
895
917
|
return p.some((x) => x !== undefined && GITHUB_WRITERS.has(x)) ? undefined : `${login} has no write access`;
|
|
896
918
|
};
|
|
897
919
|
const { by, not } = await countApprovers(approved, authors, approvers, rule);
|
|
898
|
-
|
|
899
|
-
return pass(`approved at ${short(head)} by ${by.join(", ")}`);
|
|
920
|
+
return approvalVerdict(by, not, `approved at ${short(head)}`, `no reviewer other than its authors approved its head ${short(head)}`);
|
|
900
921
|
},
|
|
901
922
|
async prMergeable(pr, head) {
|
|
902
923
|
const got = await api.call("GET", `${repo}/pulls/${pr}`);
|
|
@@ -1262,14 +1283,21 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
1262
1283
|
async prApproved(mr, head, approvers) {
|
|
1263
1284
|
const got = await api.call("GET", `${notes}/${mr}`);
|
|
1264
1285
|
const at = isObject(got) ? str(got.sha) : undefined;
|
|
1265
|
-
if (at !== head) return moved(`merge request !${mr}`, head, at);
|
|
1286
|
+
if (at !== head) return notCounted(moved(`merge request !${mr}`, head, at));
|
|
1266
1287
|
// The token's own user approves for the pipeline, never as a reviewer.
|
|
1267
1288
|
const me = await api.call("GET", "/user");
|
|
1268
1289
|
const self = isObject(me) ? str(me.username) : undefined;
|
|
1269
|
-
if (!self) return refuse("GitLab did not say which user the job's token acts as, so no approval counts");
|
|
1270
|
-
// An approval counts once it came after the latest push: the newest version of the diff.
|
|
1290
|
+
if (!self) return notCounted(refuse("GitLab did not say which user the job's token acts as, so no approval counts"));
|
|
1291
|
+
// An approval counts once it came after the latest push: the newest version of the diff, which must be of head.
|
|
1271
1292
|
const versions = await api.call("GET", `${notes}/${mr}/versions`);
|
|
1272
|
-
|
|
1293
|
+
let since = -Infinity;
|
|
1294
|
+
let latest: string | undefined;
|
|
1295
|
+
for (const v of Array.isArray(versions) ? versions : []) {
|
|
1296
|
+
const when = Date.parse((isObject(v) ? str(v.created_at) : undefined) ?? "");
|
|
1297
|
+
if (Number.isFinite(when) && when > since) [since, latest] = [when, isObject(v) ? str(v.head_commit_sha) : undefined];
|
|
1298
|
+
}
|
|
1299
|
+
// GitLab records a push's version after it moves the head, so an older one is asked again.
|
|
1300
|
+
if (latest !== head) return notCounted(refuse(`merge request !${mr}'s newest diff version is of ${short(latest)}, not ${short(head)}`, true));
|
|
1273
1301
|
const said = new Map<string, { what: string; at: number; id?: number }>();
|
|
1274
1302
|
for (const n of await api.list(`${notes}/${mr}/notes?sort=asc&order_by=created_at`, pageParams, 100)) {
|
|
1275
1303
|
if (!isObject(n) || n.system !== true) continue;
|
|
@@ -1283,7 +1311,7 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
1283
1311
|
if (!before || when >= before.at) said.set(who, { what, at: when, ...(id !== undefined ? { id } : {}) });
|
|
1284
1312
|
}
|
|
1285
1313
|
const changes = [...said].filter(([, v]) => v.what === "changes").map(([w]) => w).sort();
|
|
1286
|
-
if (changes.length > 0) return refuse(`${changes.join(", ")} requested changes`);
|
|
1314
|
+
if (changes.length > 0) return notCounted(refuse(`${changes.join(", ")} requested changes`));
|
|
1287
1315
|
const authors = new Set(await authorsOf(mr));
|
|
1288
1316
|
const approved = [...said].filter(([, v]) => v.what === "approved" && v.at > since).map(([w]) => w).sort();
|
|
1289
1317
|
const least = approvers?.minAccessLevel ?? GITLAB_DEVELOPER;
|
|
@@ -1301,8 +1329,7 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
1301
1329
|
return level >= least ? undefined : `${login} has access level ${level}, under ${least}`;
|
|
1302
1330
|
};
|
|
1303
1331
|
const { by, not } = await countApprovers(approved, authors, approvers, rule);
|
|
1304
|
-
|
|
1305
|
-
return pass(`approved after ${short(head)} by ${by.join(", ")}`);
|
|
1332
|
+
return approvalVerdict(by, not, `approved after ${short(head)}`, `no member other than its authors approved it after its latest push, ${short(head)}`);
|
|
1306
1333
|
},
|
|
1307
1334
|
async prMergeable(mr, head) {
|
|
1308
1335
|
const got = await api.call("GET", `${notes}/${mr}`);
|
package/src/pr-requires.test.ts
CHANGED
|
@@ -32,14 +32,14 @@ describe("github", () => {
|
|
|
32
32
|
|
|
33
33
|
test("approved: a reviewer other than the authors approved this head", async () => {
|
|
34
34
|
const f = forge({ [`GET ${repo}/pulls/7/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD }] });
|
|
35
|
-
expect(await f.prApproved!(7, HEAD)).toEqual({ passed: true, reason: "approved at aaaaaaaa by bob" });
|
|
35
|
+
expect(await f.prApproved!(7, HEAD)).toEqual({ passed: true, reason: "approved at aaaaaaaa by bob", approvers: ["bob"], excluded: [] });
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
test("approved: an approval of an older head, the author's own, or a request for changes does not pass", async () => {
|
|
39
39
|
const older = forge({ [`GET ${repo}/pulls/7/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: OTHER }, { user: { login: "alice" }, state: "APPROVED", commit_id: HEAD }] });
|
|
40
40
|
expect((await older.prApproved!(7, HEAD)).passed).toBe(false);
|
|
41
41
|
const changes = forge({ [`GET ${repo}/pulls/7/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD }, { user: { login: "carol" }, state: "CHANGES_REQUESTED", commit_id: HEAD }] });
|
|
42
|
-
expect(await changes.prApproved!(7, HEAD)).toEqual({ passed: false, reason: "carol asked for changes" });
|
|
42
|
+
expect(await changes.prApproved!(7, HEAD)).toEqual({ passed: false, reason: "carol asked for changes", approvers: [], excluded: [] });
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
test("approved: forgeRule counts only writers, exclude leaves a login out, and the refusal says why (#4337)", async () => {
|
|
@@ -53,10 +53,24 @@ describe("github", () => {
|
|
|
53
53
|
[`GET ${repo}/collaborators/bob/permission`]: { permission: "read", role_name: "triage" },
|
|
54
54
|
[`GET ${repo}/collaborators/Carol/permission`]: { permission: "write", role_name: "maintain" },
|
|
55
55
|
});
|
|
56
|
-
expect(await f.prApproved!(7, HEAD, { forgeRule: true })).toEqual({
|
|
56
|
+
expect(await f.prApproved!(7, HEAD, { forgeRule: true })).toEqual({
|
|
57
|
+
passed: true,
|
|
58
|
+
reason: "approved at aaaaaaaa by Carol",
|
|
59
|
+
approvers: ["Carol"],
|
|
60
|
+
excluded: [
|
|
61
|
+
{ login: "alice", reason: "alice is one of its authors" },
|
|
62
|
+
{ login: "bob", reason: "bob has no write access" },
|
|
63
|
+
],
|
|
64
|
+
});
|
|
57
65
|
expect(await f.prApproved!(7, HEAD, { forgeRule: true, exclude: [{ login: "carol", reason: "carol asked for aaaaaaa" }] })).toEqual({
|
|
58
66
|
passed: false,
|
|
59
67
|
reason: "no reviewer other than its authors approved its head aaaaaaaa: carol asked for aaaaaaa; alice is one of its authors; bob has no write access",
|
|
68
|
+
approvers: [],
|
|
69
|
+
excluded: [
|
|
70
|
+
{ login: "Carol", reason: "carol asked for aaaaaaa" },
|
|
71
|
+
{ login: "alice", reason: "alice is one of its authors" },
|
|
72
|
+
{ login: "bob", reason: "bob has no write access" },
|
|
73
|
+
],
|
|
60
74
|
});
|
|
61
75
|
expect((await f.prApproved!(7, HEAD, { exclude: ["bob"] })).reason).toBe("approved at aaaaaaaa by Carol");
|
|
62
76
|
});
|
|
@@ -112,7 +126,14 @@ describe("forgejo", () => {
|
|
|
112
126
|
const f = forge({ [`GET ${repo}/pulls/4/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD, official: false }, { user: { login: "erin" }, state: "APPROVED", commit_id: HEAD, official: true }] });
|
|
113
127
|
expect((await f.prApproved!(4, HEAD, { forgeRule: true })).reason).toBe("approved at aaaaaaaa by erin");
|
|
114
128
|
const out = await checkPrRequires(f, 4, HEAD, { only: ["approved"], approvers: { forgeRule: true, exclude: ["erin"] } });
|
|
115
|
-
expect(out[0]
|
|
129
|
+
expect(out[0]).toMatchObject({
|
|
130
|
+
reason: "no reviewer other than its authors approved its head aaaaaaaa: bob's review is not official; erin is excluded",
|
|
131
|
+
approvers: [],
|
|
132
|
+
excluded: [
|
|
133
|
+
{ login: "bob", reason: "bob's review is not official" },
|
|
134
|
+
{ login: "erin", reason: "erin is excluded" },
|
|
135
|
+
],
|
|
136
|
+
});
|
|
116
137
|
});
|
|
117
138
|
|
|
118
139
|
test("undiverged: the pull request's merge base is the base branch's tip", async () => {
|
|
@@ -150,7 +171,7 @@ describe("gitlab", () => {
|
|
|
150
171
|
[`GET ${project}/merge_requests/3`]: mr(),
|
|
151
172
|
[`GET ${project}/merge_requests/3/commits`]: [],
|
|
152
173
|
"GET /api/v4/user": { id: 1, username: "chant-bot" },
|
|
153
|
-
[`GET ${project}/merge_requests/3/versions`]: [{ created_at: "2026-10-01T10:00:00Z" }],
|
|
174
|
+
[`GET ${project}/merge_requests/3/versions`]: [{ created_at: "2026-10-01T10:00:00Z", head_commit_sha: HEAD }],
|
|
154
175
|
...routes,
|
|
155
176
|
}),
|
|
156
177
|
});
|
|
@@ -165,7 +186,7 @@ describe("gitlab", () => {
|
|
|
165
186
|
});
|
|
166
187
|
expect((await f.prApproved!(3, HEAD)).passed).toBe(false);
|
|
167
188
|
const g = forge({ [`GET ${project}/merge_requests/3/notes`]: [note("bob", "approved this merge request", "2026-10-01T11:00:00Z")] });
|
|
168
|
-
expect(await g.prApproved!(3, HEAD)).toEqual({ passed: true, reason: "approved after aaaaaaaa by bob" });
|
|
189
|
+
expect(await g.prApproved!(3, HEAD)).toEqual({ passed: true, reason: "approved after aaaaaaaa by bob", approvers: ["bob"], excluded: [] });
|
|
169
190
|
const h = forge({ [`GET ${project}/merge_requests/3/notes`]: [note("bob", "approved this merge request", "2026-10-01T11:00:00Z"), note("carol", "requested changes", "2026-10-01T12:00:00Z")] });
|
|
170
191
|
expect((await h.prApproved!(3, HEAD)).reason).toBe("carol requested changes");
|
|
171
192
|
});
|
|
@@ -185,6 +206,22 @@ describe("gitlab", () => {
|
|
|
185
206
|
expect((await f.prApproved!(3, HEAD, { forgeRule: true, minAccessLevel: 50 })).reason).toBe(
|
|
186
207
|
"no member other than its authors approved it after its latest push, aaaaaaaa: bob has access level 20, under 50; dan has access level 40, under 50; eve is not a member",
|
|
187
208
|
);
|
|
209
|
+
expect((await f.prApproved!(3, HEAD, { forgeRule: true })).excluded).toEqual([
|
|
210
|
+
{ login: "bob", reason: "bob has access level 20, under 30" },
|
|
211
|
+
{ login: "eve", reason: "eve is not a member" },
|
|
212
|
+
]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("approved: pending until GitLab's newest diff version is of the head, as terragucci checked (#4287)", async () => {
|
|
216
|
+
const approval = note("bob", "approved this merge request", "2026-10-01T11:00:00Z");
|
|
217
|
+
const versions = [
|
|
218
|
+
{ created_at: "2026-10-01T10:00:00Z", head_commit_sha: HEAD },
|
|
219
|
+
{ created_at: "2026-10-01T12:00:00Z", head_commit_sha: OTHER },
|
|
220
|
+
];
|
|
221
|
+
const f = forge({ [`GET ${project}/merge_requests/3/notes`]: [approval], [`GET ${project}/merge_requests/3/versions`]: versions });
|
|
222
|
+
expect(await f.prApproved!(3, HEAD)).toEqual({ passed: false, pending: true, reason: "merge request !3's newest diff version is of cccccccc, not aaaaaaaa", approvers: [], excluded: [] });
|
|
223
|
+
const none = forge({ [`GET ${project}/merge_requests/3/notes`]: [approval], [`GET ${project}/merge_requests/3/versions`]: [] });
|
|
224
|
+
expect(await none.prApproved!(3, HEAD)).toMatchObject({ passed: false, pending: true });
|
|
188
225
|
});
|
|
189
226
|
|
|
190
227
|
test("mergeable and undiverged read the merge request", async () => {
|
package/src/pr-requires.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Like ./pr-forge.ts this imports nothing beyond it, so a product bundles it
|
|
14
14
|
* without the TypeScript toolchain.
|
|
15
15
|
*/
|
|
16
|
-
import type { PrApproverFilter, PrForge, PrRequireResult } from "./pr-forge";
|
|
16
|
+
import type { PrApproverFilter, PrExcludedApprover, PrForge, PrRequireResult } from "./pr-forge";
|
|
17
17
|
|
|
18
18
|
/** The four checks, in the order they run and the report lists them. */
|
|
19
19
|
export const PR_APPLY_REQUIRES = ["approved", "mergeable", "undiverged", "checks"] as const;
|
|
@@ -23,6 +23,10 @@ export type PrApplyRequire = (typeof PR_APPLY_REQUIRES)[number];
|
|
|
23
23
|
/** One check as the pull-request report's `beforeMerge.requires` holds it. */
|
|
24
24
|
export interface PrRequireOutcome extends PrRequireResult {
|
|
25
25
|
name: PrApplyRequire;
|
|
26
|
+
/** `approved` only (#4287): the logins that counted, as {@link PrApprovalResult} gives them. */
|
|
27
|
+
approvers?: string[];
|
|
28
|
+
/** `approved` only (#4287): the logins left out, each with why. */
|
|
29
|
+
excluded?: PrExcludedApprover[];
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export interface PrRequiresOptions {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `capabilities` document of the read contract (#4375): its schema, the
|
|
3
|
+
* document `chant search --capabilities --json` prints, and its failure.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import { contract, validSchema } from "./__fixtures__/contract-repo";
|
|
9
|
+
import { READ_CONTRACT_FLOOR, READ_CONTRACT_VERSION, REASONS } from "./reason-codes";
|
|
10
|
+
import { CAPABILITIES_OUTPUT_SCHEMA_ID, CAPABILITY_SUPPORT_CODES, capabilitiesDocument, capabilitiesFailure, formatCapabilities } from "./capabilities-contract";
|
|
11
|
+
import { READ_CONTRACT_COMMANDS, READ_CONTRACT_SCHEMAS, MCP_READ_TOOLS, mcpToolCall } from "./conformance";
|
|
12
|
+
import schema from "./capabilities.schema.json";
|
|
13
|
+
|
|
14
|
+
const { expectValid } = contract(schema);
|
|
15
|
+
|
|
16
|
+
/** The lexicons' mappings as they ship them, read as data: loading a lexicon's plugin takes seconds. */
|
|
17
|
+
const LEXICONS = join(import.meta.dirname, "..", "..", "..", "..", "lexicons");
|
|
18
|
+
const load = async (name: string) => {
|
|
19
|
+
if (name !== "aws" && name !== "terraform") throw new Error(`no lexicon ${name}`);
|
|
20
|
+
return { capabilityPack: { mapping: JSON.parse(readFileSync(join(LEXICONS, name, "src", "capabilities", "mapping.json"), "utf-8")) } };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("capabilities.schema.json", () => {
|
|
24
|
+
test("is a valid draft 2020-12 document at the contract version, naming the floor", () => {
|
|
25
|
+
expect(validSchema(schema)).toBe(true);
|
|
26
|
+
expect(schema.$id).toBe(CAPABILITIES_OUTPUT_SCHEMA_ID);
|
|
27
|
+
expect(schema.$id).toBe(`https://intentius.io/chant/schemas/workspace/capabilities/v${READ_CONTRACT_VERSION}/capabilities.schema.json`);
|
|
28
|
+
for (const branch of ["result", "failure"] as const) {
|
|
29
|
+
const props = (schema.$defs as Record<string, { properties: Record<string, unknown> }>)[branch].properties;
|
|
30
|
+
expect(props.contract).toEqual({ const: READ_CONTRACT_VERSION });
|
|
31
|
+
expect(props.$schema).toEqual({ const: schema.$id });
|
|
32
|
+
}
|
|
33
|
+
expect(schema.description).toContain(`chant ${READ_CONTRACT_FLOOR} and newer`);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("names each support code in the closed list", () => {
|
|
37
|
+
for (const code of CAPABILITY_SUPPORT_CODES) expect(Object.keys(REASONS)).toContain(code);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("is in the conformance suite as workspace search --capabilities, served by the capabilities MCP tool", () => {
|
|
41
|
+
expect(READ_CONTRACT_COMMANDS).toContain("search --capabilities");
|
|
42
|
+
expect(READ_CONTRACT_SCHEMAS["search --capabilities"]).toBe("capabilities.schema.json");
|
|
43
|
+
expect(MCP_READ_TOOLS["search --capabilities"]).toBe("capabilities");
|
|
44
|
+
expect(mcpToolCall(["workspace", "search", "--capabilities", "--json"])).toEqual({ name: "capabilities", arguments: {} });
|
|
45
|
+
expect(mcpToolCall(["workspace", "search", "app", "--capabilities", "--json"])).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("the document", () => {
|
|
50
|
+
test("lists the fifteen capabilities with a support statement for each lexicon asked about", async () => {
|
|
51
|
+
const doc = await capabilitiesDocument(["aws", "terraform", "no-such-lexicon"], load);
|
|
52
|
+
expectValid(doc);
|
|
53
|
+
expect(doc.contract).toBe(1);
|
|
54
|
+
expect(doc.capabilities).toHaveLength(16);
|
|
55
|
+
const reaches = doc.capabilities.find((c) => c.name === "reaches")!;
|
|
56
|
+
expect(reaches).toMatchObject({
|
|
57
|
+
shape: "edge",
|
|
58
|
+
search: "->[cap=reaches]",
|
|
59
|
+
depth: "declared",
|
|
60
|
+
params: [{ name: "action", optional: true }],
|
|
61
|
+
uses: ["can-assume", "grants"],
|
|
62
|
+
lexicons: {
|
|
63
|
+
aws: "supported",
|
|
64
|
+
terraform: "supported",
|
|
65
|
+
"no-such-lexicon": { code: "lexicon-not-loaded" },
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
expect(reaches.rule).toBe("reaches(r, x; action?) := r:Role ->{0,}can-assume ->grants($action) x");
|
|
69
|
+
expect(reaches.notEvaluated).toContain('Resource "*"');
|
|
70
|
+
expect(doc.helpers.map((h) => h.name)).toEqual(["in-sg", "ingress-of", "world-open", "allows", "denies", "pivot"]);
|
|
71
|
+
for (const c of doc.capabilities) {
|
|
72
|
+
expect(c.answers.length, c.name).toBeGreaterThan(0);
|
|
73
|
+
expect(c.search, c.name).toBe(c.shape === "set" ? `cap:${c.name}` : `->[cap=${c.name}]`);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("prints as text, a line per capability with its parameters", async () => {
|
|
78
|
+
const lines = formatCapabilities(await capabilitiesDocument([], load));
|
|
79
|
+
expect(lines[0]).toMatch(/^cap:internet-facing +set +its subnet routes 0\.0\.0\.0\/0 to an internet gateway$/);
|
|
80
|
+
expect(lines).toContainEqual(expect.stringMatching(/^ +port\?: a port the open rule must cover, such as 22$/));
|
|
81
|
+
expect(lines.at(-1)).toBe("— 16 capabilities · --json prints each rule");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a refusal is a failure document with a code", async () => {
|
|
85
|
+
const doc = await capabilitiesFailure({ code: "usage-invalid", message: "m" });
|
|
86
|
+
expectValid(doc);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `capabilities` document of the read contract (#4375, ws-017): the
|
|
3
|
+
* capability catalog `chant search` answers `cap:` terms with, described by
|
|
4
|
+
* `capabilities.schema.json` beside this file.
|
|
5
|
+
*
|
|
6
|
+
* `chant search --capabilities --json` prints it for one project, with a
|
|
7
|
+
* support statement for each lexicon the project loads. `chant workspace
|
|
8
|
+
* search --capabilities --json` prints it from the workspace root's chant,
|
|
9
|
+
* for every lexicon a member loads (a terraform member's reader lexicon
|
|
10
|
+
* included, #4380): capability rules run once, under
|
|
11
|
+
* the workspace's chant, so the catalog is that chant's. chant's MCP
|
|
12
|
+
* `capabilities` tool returns whichever of the two applies where the server
|
|
13
|
+
* runs, unchanged.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { resolve } from "node:path";
|
|
17
|
+
import type { ReasonCode } from "./reason-codes";
|
|
18
|
+
import type { CapabilityEntry } from "../capabilities/catalog";
|
|
19
|
+
|
|
20
|
+
/** The version of the `capabilities` document this chant writes. */
|
|
21
|
+
export const CAPABILITIES_CONTRACT_VERSION = 1;
|
|
22
|
+
|
|
23
|
+
/** `$id` of the JSON Schema for the document, shipped beside this file. */
|
|
24
|
+
export const CAPABILITIES_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/capabilities/v1/capabilities.schema.json";
|
|
25
|
+
|
|
26
|
+
/** Why no catalog was printed. */
|
|
27
|
+
export const CAPABILITIES_ERROR_CODES = ["usage-invalid"] as const satisfies readonly ReasonCode[];
|
|
28
|
+
export type CapabilitiesErrorCode = (typeof CAPABILITIES_ERROR_CODES)[number];
|
|
29
|
+
|
|
30
|
+
/** Why a lexicon can't supply a capability. */
|
|
31
|
+
export const CAPABILITY_SUPPORT_CODES = ["lexicon-has-no-mapping", "capability-not-derivable", "lexicon-not-loaded"] as const satisfies readonly ReasonCode[];
|
|
32
|
+
|
|
33
|
+
interface CapabilitiesHead {
|
|
34
|
+
$schema: typeof CAPABILITIES_OUTPUT_SCHEMA_ID;
|
|
35
|
+
contract: typeof CAPABILITIES_CONTRACT_VERSION;
|
|
36
|
+
/** The chant that wrote the document. */
|
|
37
|
+
chant: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CapabilitiesResult extends CapabilitiesHead {
|
|
41
|
+
capabilities: CapabilityEntry[];
|
|
42
|
+
helpers: { name: string; rule: string }[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CapabilitiesFailure extends CapabilitiesHead {
|
|
46
|
+
error: { code: CapabilitiesErrorCode; message: string; hint?: string };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type CapabilitiesDocument = CapabilitiesResult | CapabilitiesFailure;
|
|
50
|
+
|
|
51
|
+
async function head(): Promise<CapabilitiesHead> {
|
|
52
|
+
const { readChantPackageVersion } = await import("../package-version");
|
|
53
|
+
return { $schema: CAPABILITIES_OUTPUT_SCHEMA_ID, contract: CAPABILITIES_CONTRACT_VERSION, chant: readChantPackageVersion() };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The catalog, with a support statement for each lexicon named. Each is
|
|
58
|
+
* loaded on its own, so one that isn't installed is said to be, and the rest
|
|
59
|
+
* still read.
|
|
60
|
+
*/
|
|
61
|
+
export async function capabilitiesDocument(
|
|
62
|
+
lexicons: string[],
|
|
63
|
+
load?: (name: string) => Promise<{ capabilityPack?: { mapping: unknown } }>,
|
|
64
|
+
): Promise<CapabilitiesResult> {
|
|
65
|
+
const loadPlugin = load ?? (await import("../cli/plugins")).loadPlugin;
|
|
66
|
+
const { capabilityCatalog } = await import("../capabilities/catalog");
|
|
67
|
+
const mappings: Record<string, Parameters<typeof capabilityCatalog>[0][string]> = {};
|
|
68
|
+
const notLoaded: string[] = [];
|
|
69
|
+
for (const name of [...new Set(lexicons)].sort()) {
|
|
70
|
+
try {
|
|
71
|
+
mappings[name] = (await loadPlugin(name)).capabilityPack?.mapping as (typeof mappings)[string];
|
|
72
|
+
} catch {
|
|
73
|
+
notLoaded.push(name);
|
|
74
|
+
mappings[name] = undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const catalog = capabilityCatalog(mappings);
|
|
78
|
+
for (const c of catalog.capabilities) {
|
|
79
|
+
for (const name of notLoaded) {
|
|
80
|
+
c.lexicons[name] = { code: "lexicon-not-loaded", message: `the ${name} lexicon isn't installed where this chant runs, so its mapping wasn't read` };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { ...(await head()), ...catalog };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function capabilitiesFailure(error: CapabilitiesFailure["error"]): Promise<CapabilitiesFailure> {
|
|
87
|
+
return { ...(await head()), error };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The lexicons the members of the workspace at `cwd` load, for its catalog:
|
|
92
|
+
* each chant member's own, and for a member of a package's kind, such as
|
|
93
|
+
* `terraform`, the lexicon its reader project declares (#4380).
|
|
94
|
+
*/
|
|
95
|
+
export async function workspaceLexicons(cwd: string): Promise<string[]> {
|
|
96
|
+
const { listWorkspace } = await import("./ls");
|
|
97
|
+
const { findWorkspaceRoot } = await import("../project-root");
|
|
98
|
+
const { resolveProjectLexicons } = await import("../cli/plugins");
|
|
99
|
+
const { loadKindRegistry } = await import("./kinds");
|
|
100
|
+
const located = findWorkspaceRoot(cwd);
|
|
101
|
+
const doc = listWorkspace({ cwd });
|
|
102
|
+
if (!located || "error" in doc) return [];
|
|
103
|
+
const out = new Set<string>();
|
|
104
|
+
const kinds = doc.members.some((m) => m.kind !== "chant" && m.readable) ? loadKindRegistry(doc.workspace.pins, located.dir).registry : undefined;
|
|
105
|
+
for (const m of doc.members) {
|
|
106
|
+
if (!m.readable) continue;
|
|
107
|
+
const reader = m.kind === "chant" ? undefined : kinds?.get(m.kind)?.graph?.lexicon;
|
|
108
|
+
if (reader) out.add(reader);
|
|
109
|
+
if (m.kind !== "chant") continue;
|
|
110
|
+
try {
|
|
111
|
+
for (const l of await resolveProjectLexicons(resolve(located.dir, m.dir))) out.add(l);
|
|
112
|
+
} catch {
|
|
113
|
+
// A member whose lexicons can't be resolved adds none; its own search says why.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return [...out];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The catalog as text: one capability per line, its search term and meaning, then its parameters. */
|
|
120
|
+
export function formatCapabilities(doc: CapabilitiesResult): string[] {
|
|
121
|
+
const width = Math.max(...doc.capabilities.map((c) => c.search.length));
|
|
122
|
+
const lines: string[] = [];
|
|
123
|
+
for (const c of doc.capabilities) {
|
|
124
|
+
lines.push(`${c.search.padEnd(width)} ${c.shape.padEnd(4)} ${c.meaning}`);
|
|
125
|
+
for (const p of c.params) lines.push(`${"".padEnd(width)} ${p.name}${p.optional ? "?" : ""}: ${p.meaning}`);
|
|
126
|
+
const unsupported = Object.entries(c.lexicons).filter(([, s]) => s !== "supported");
|
|
127
|
+
for (const [lex, s] of unsupported) lines.push(`${"".padEnd(width)} not in ${lex}: ${(s as { message: string }).message}`);
|
|
128
|
+
}
|
|
129
|
+
const lexicons = Object.keys(doc.capabilities[0]?.lexicons ?? {});
|
|
130
|
+
lines.push(`— ${doc.capabilities.length} capabilities${lexicons.length ? ` · lexicons ${lexicons.join(", ")}` : ""} · --json prints each rule`);
|
|
131
|
+
return lines;
|
|
132
|
+
}
|