@intentius/chant 0.124.8 → 0.124.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/lifecycle/gate-audit.d.ts +6 -0
  2. package/dist/lifecycle/gate-audit.d.ts.map +1 -1
  3. package/dist/op/gate.d.ts +16 -1
  4. package/dist/op/gate.d.ts.map +1 -1
  5. package/dist/op/op-waves-notify.d.ts +2 -1
  6. package/dist/op/op-waves-notify.d.ts.map +1 -1
  7. package/dist/op/op-waves-run.d.ts +57 -7
  8. package/dist/op/op-waves-run.d.ts.map +1 -1
  9. package/dist/op/op-waves.d.ts +42 -0
  10. package/dist/op/op-waves.d.ts.map +1 -1
  11. package/dist/workspace/identity.d.ts +3 -2
  12. package/dist/workspace/identity.d.ts.map +1 -1
  13. package/dist/workspace/write-scope.d.ts +3 -2
  14. package/dist/workspace/write-scope.d.ts.map +1 -1
  15. package/package.json +1 -1
  16. package/src/codegen/publish-order.test.ts +20 -4
  17. package/src/components/pr-run.ts +1 -1
  18. package/src/lifecycle/converge-ledger.ts +1 -1
  19. package/src/lifecycle/gate-audit.test.ts +6 -0
  20. package/src/lifecycle/gate-audit.ts +8 -2
  21. package/src/lifecycle/gate-ledger.test.ts +37 -0
  22. package/src/lifecycle/gate-ledger.ts +1 -1
  23. package/src/lifecycle/git.ts +1 -1
  24. package/src/lifecycle/override-ledger.ts +1 -1
  25. package/src/lifecycle/run-ledger.ts +1 -1
  26. package/src/lifecycle/work-lease.ts +1 -1
  27. package/src/op/gate-review.test.ts +53 -1
  28. package/src/op/gate.ts +31 -9
  29. package/src/op/op-waves-notify.ts +13 -1
  30. package/src/op/op-waves-point.test.ts +254 -0
  31. package/src/op/op-waves-run.ts +234 -42
  32. package/src/op/op-waves.test.ts +100 -3
  33. package/src/op/op-waves.ts +68 -0
  34. package/src/workspace/identity.test.ts +4 -1
  35. package/src/workspace/identity.ts +4 -3
  36. package/src/workspace/write-scope.ts +4 -3
@@ -788,8 +788,8 @@ describe("gates and holds a product's wave needs (#3821)", () => {
788
788
  const show: OpWaveShowAtBase = () => ({ sha: "b".repeat(40), text: JSON.stringify(opts.atBase ?? tg) });
789
789
  return { cwd, calls, envs, exec, show, done: () => rmSync(cwd, { recursive: true, force: true }) };
790
790
  }
791
- const run = (h: ReturnType<typeof harness>, gates = memoryGateLedgerPort(), spec: OpWavesSpec = tg) =>
792
- runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00.000Z", log: () => {} });
791
+ const run = (h: ReturnType<typeof harness>, gates = memoryGateLedgerPort(), spec: OpWavesSpec = tg, log: (m: string) => void = () => {}) =>
792
+ runOpWave({ spec, specFile: "waves.json", wave: 1, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00.000Z", log });
793
793
 
794
794
  test("the spec checks after.gate hooks and override", () => {
795
795
  expect(() => assertOpWavesSpec(tg)).not.toThrow();
@@ -836,6 +836,8 @@ describe("gates and holds a product's wave needs (#3821)", () => {
836
836
  expect(result).toMatchObject({ exitCode: 7, decision: { status: "refused", refused: { reason: "policy", runs: ["vpc"] } } });
837
837
  expect(gates.appended).toHaveLength(1);
838
838
  expect(gates.appended[0]).toMatchObject({ op: "policy-override", gate: "vpc", plan: digest("1"), rules: ["no-public-s3"], members: [{ member: "vpc", planDigest: digest("1") }] });
839
+ // #3839: the denial names its wave as a field.
840
+ expect(gates.appended[0]).toMatchObject({ wave: { number: 1, name: expect.any(String), op: tg.name } });
839
841
  expect(h.envs.find((e) => e.CHANT_HOOK === "after-gate")).toMatchObject({ CHANT_GATE_STATUS: "refused" });
840
842
  expect(h.calls.some((c) => c.startsWith("tg apply"))).toBe(false);
841
843
  const { opWaveNotifyFields } = await import("./op-waves-notify");
@@ -862,13 +864,28 @@ describe("gates and holds a product's wave needs (#3821)", () => {
862
864
  other.done();
863
865
  });
864
866
 
867
+ test("a plans file's overridersSource names the list in the refusal and the decision record (#3848)", async () => {
868
+ const mk = (extra: Record<string, unknown>) => harness({ vpc: { planDigest: digest("1"), denied: { rules: ["no-public-s3"] } }, dns: { planDigest: digest("2") } }, { extra });
869
+ const logs: string[] = [];
870
+ const h = mk({ overriders: ["github:dave"], overridersSource: "policy.override in terragucci.yml" });
871
+ const gates = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "policy-override", gate: "vpc", resolvedBy: "github:carol", timestamp: "2026-10-10T00:00:00Z", planDigest: (await import("../override-gate")).overrideDigest("vpc", digest("1"), ["no-public-s3"]), note: "ok" }] });
872
+ const result = await run(h, gates, tg, (m) => logs.push(m));
873
+ expect(result.decision!.refused).toMatchObject({ reason: "policy", overridersSource: "policy.override in terragucci.yml" });
874
+ expect(logs.join("\n")).toContain("github:carol is not listed in policy.override in terragucci.yml at base");
875
+ h.done();
876
+ const d = mk({ overriders: ["github:dave"] });
877
+ const r2 = await run(d, memoryGateLedgerPort());
878
+ expect(r2.decision!.refused).not.toHaveProperty("overridersSource");
879
+ d.done();
880
+ });
881
+
865
882
  test("a hook holds the wave on another gate: it waits on that gate's digest, then runs again once approved", async () => {
866
883
  const migrate = { name: "migrate", command: ["migrate", "{runs}"], onFailure: "approve" as const };
867
884
  const spec: OpWavesSpec = { ...tg, hooks: { before: { plan: [migrate] }, after: tg.hooks!.after } };
868
885
  const hook = (argv: string[], env: Record<string, string>, dir: string) => {
869
886
  if (argv[0] !== "migrate") return 0;
870
887
  if (env.CHANT_HOLD_APPROVED) return 0;
871
- writeFileSync(join(dir, env.CHANT_HOLD!), JSON.stringify({ op: "tf-migrate", gate: "split-queues", digest: digest("m"), pending: { members: [] } }));
888
+ writeFileSync(join(dir, env.CHANT_HOLD!), JSON.stringify({ op: "tf-migrate", gate: "split-queues", digest: digest("m"), description: "move the queues", pending: { members: [] } }));
872
889
  return 3;
873
890
  };
874
891
  const h = harness({ vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2") } }, { hook, atBase: spec });
@@ -877,6 +894,8 @@ describe("gates and holds a product's wave needs (#3821)", () => {
877
894
  expect(waiting).toMatchObject({ exitCode: 3, decision: { status: "waiting", heldOn: { op: "tf-migrate", gate: "split-queues", digest: digest("m"), status: "waiting" } } });
878
895
  expect(waiting.decision!.approve).toBe(`chant approve tf-migrate split-queues --plan ${digest("m")}`);
879
896
  expect(gates.appended[0]).toMatchObject({ op: "tf-migrate", gate: "split-queues", planDigest: digest("m"), members: [] });
897
+ // #3839: the hold names its wave as a field, even when the hook wrote the description.
898
+ expect(gates.appended[0]).toMatchObject({ description: "move the queues", wave: { number: 1, name: expect.any(String), op: tg.name } });
880
899
  expect(h.calls.some((c) => c.startsWith("tg plan"))).toBe(false);
881
900
  expect(h.envs.find((e) => e.CHANT_HOOK === "after-gate")).toMatchObject({ CHANT_GATE_STATUS: "waiting", CHANT_GATE_OP: "tf-migrate", CHANT_GATE: "split-queues" });
882
901
  h.done();
@@ -891,6 +910,84 @@ describe("gates and holds a product's wave needs (#3821)", () => {
891
910
  expect(result.hooks.filter((o) => o.name === "migrate").map((o) => o.outcome)).toEqual(["held", "passed"]);
892
911
  again.done();
893
912
  });
913
+
914
+ test("an approved re-run holds the wave on a further gate: two migrations wait, then apply in turn (#3847)", async () => {
915
+ const migrate = { name: "migrate", command: ["migrate", "{runs}"], onFailure: "approve" as const };
916
+ const spec: OpWavesSpec = { ...tg, hooks: { before: { plan: [migrate] }, after: tg.hooks!.after } };
917
+ // The first migration holds on its gate; once approved it applies, and the second (whose digest is known only now) holds on its own.
918
+ const applied: string[] = [];
919
+ const hook = (argv: string[], env: Record<string, string>, dir: string) => {
920
+ if (argv[0] !== "migrate") return 0;
921
+ const done = env.CHANT_HOLDS_APPROVED ? (JSON.parse(env.CHANT_HOLDS_APPROVED) as Array<{ gate: string }>).map((a) => a.gate) : [];
922
+ for (const gate of done) if (!applied.includes(gate)) applied.push(gate);
923
+ const next = ["split-queues", "move-topics"].find((g) => !done.includes(g));
924
+ if (!next) return 0;
925
+ writeFileSync(join(dir, env.CHANT_HOLD!), JSON.stringify({ op: "tf-migrate", gate: next, digest: digest(next === "split-queues" ? "m" : "n") }));
926
+ return 3;
927
+ };
928
+ const plans = { vpc: { planDigest: digest("1"), empty: true }, dns: { planDigest: digest("2"), empty: true } };
929
+ const first = { version: 1 as const, op: "tf-migrate", gate: "split-queues", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: digest("m") };
930
+
931
+ // Only the first is approved: it applies, and the wave waits on the second (exit 3), naming it.
932
+ const h = harness(plans, { hook, atBase: spec });
933
+ const gates = memoryGateLedgerPort({ resolutions: [first] });
934
+ const waiting = await run(h, gates, spec);
935
+ expect(waiting.exitCode).toBe(3);
936
+ expect(waiting.decision).toMatchObject({
937
+ status: "waiting",
938
+ approve: `chant approve tf-migrate move-topics --plan ${digest("n")}`,
939
+ heldOn: { op: "tf-migrate", gate: "move-topics", digest: digest("n"), status: "waiting", after: [{ gate: "split-queues", digest: digest("m"), approvedBy: "alice" }] },
940
+ });
941
+ expect(gates.appended).toEqual([expect.objectContaining({ op: "tf-migrate", gate: "move-topics", planDigest: digest("n") })]);
942
+ expect(applied).toEqual(["split-queues"]);
943
+ expect(h.calls.some((c) => c.startsWith("tg plan"))).toBe(false);
944
+ expect(waiting.hooks.filter((o) => o.name === "migrate").map((o) => o.outcome)).toEqual(["held", "held"]);
945
+ h.done();
946
+
947
+ // Both approved: the hook runs three times, the last with both approvals, and the wave applies.
948
+ applied.length = 0;
949
+ const again = harness(plans, { hook, atBase: spec });
950
+ const both = memoryGateLedgerPort({ resolutions: [first, { ...first, gate: "move-topics", resolvedBy: "bob", planDigest: digest("n") }] });
951
+ const result = await run(again, both, spec);
952
+ expect(result.exitCode).toBe(0);
953
+ expect(applied).toEqual(["split-queues", "move-topics"]);
954
+ const reruns = again.envs.filter((_, i) => again.calls[i]!.startsWith("migrate"));
955
+ expect(reruns).toHaveLength(3);
956
+ expect(JSON.parse(reruns[2]!.CHANT_HOLD_APPROVED!)).toMatchObject({ gate: "move-topics", approvedBy: "bob" });
957
+ expect(JSON.parse(reruns[2]!.CHANT_HOLDS_APPROVED!).map((a: { gate: string }) => a.gate)).toEqual(["split-queues", "move-topics"]);
958
+ expect(result.hooks.filter((o) => o.name === "migrate").map((o) => o.outcome)).toEqual(["held", "held", "passed"]);
959
+ again.done();
960
+ });
961
+
962
+ test("an approved re-run that holds again on the same gate and digest, or past the bound, fails the wave (#3847)", async () => {
963
+ const migrate = { name: "migrate", command: ["migrate", "{runs}"], onFailure: "approve" as const };
964
+ const spec: OpWavesSpec = { ...tg, hooks: { before: { plan: [migrate] }, after: tg.hooks!.after } };
965
+ const plans = { vpc: { planDigest: digest("1") }, dns: { planDigest: digest("2") } };
966
+ const same = (argv: string[], env: Record<string, string>, dir: string) => {
967
+ if (argv[0] !== "migrate") return 0;
968
+ writeFileSync(join(dir, env.CHANT_HOLD!), JSON.stringify({ op: "tf-migrate", gate: "split-queues", digest: digest("m") }));
969
+ return 3;
970
+ };
971
+ const h = harness(plans, { hook: same, atBase: spec });
972
+ const gates = memoryGateLedgerPort({ resolutions: [{ version: 1, op: "tf-migrate", gate: "split-queues", resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: digest("m") }] });
973
+ expect((await run(h, gates, spec)).exitCode).toBe(1);
974
+ expect(h.calls.filter((c) => c.startsWith("migrate"))).toHaveLength(2);
975
+ h.done();
976
+
977
+ const { OP_WAVE_MAX_CHAINED_HOLDS } = await import("./op-waves-run");
978
+ let n = 0;
979
+ const endless = (argv: string[], env: Record<string, string>, dir: string) => {
980
+ if (argv[0] !== "migrate") return 0;
981
+ writeFileSync(join(dir, env.CHANT_HOLD!), JSON.stringify({ op: "tf-migrate", gate: `step-${n}`, digest: digest("m") }));
982
+ n++;
983
+ return 3;
984
+ };
985
+ const many = harness(plans, { hook: endless, atBase: spec });
986
+ const all = memoryGateLedgerPort({ resolutions: Array.from({ length: 20 }, (_, i) => ({ version: 1 as const, op: "tf-migrate", gate: `step-${i}`, resolvedBy: "alice", timestamp: "2026-10-10T00:00:00Z", planDigest: digest("m") })) });
987
+ expect((await run(many, all, spec)).exitCode).toBe(1);
988
+ expect(many.calls.filter((c) => c.startsWith("migrate"))).toHaveLength(OP_WAVE_MAX_CHAINED_HOLDS + 1);
989
+ many.done();
990
+ });
894
991
  });
895
992
 
896
993
  describe("discovered waves, a cost member and applies that apply nothing (#3821)", () => {
@@ -53,9 +53,21 @@
53
53
  * apply; like the policy they are read from the spec at the base commit, and
54
54
  * one whose `onFailure` is `approve` holds the wave for an approval whatever
55
55
  * its policy says.
56
+ *
57
+ * ## A gate that asks a decision point (#3836)
58
+ *
59
+ * With `point` (on the spec, or on a wave, whose own wins), read at base like
60
+ * the policy, a wave that needs an approval asks that workspace decision
61
+ * point instead of reading the ledger for a `chant approve`, the way an Op
62
+ * gate with a `point` does (#3170): `gate.planDigest` is the wave's set
63
+ * digest, so each plan is its own question. An open question waits (exit 3,
64
+ * the pending fact cites it), an answer the gate passes on records the
65
+ * resolution that cites it and the wave goes on, and any other answer refuses
66
+ * the wave (exit 8). See `./op-waves-run.ts`.
56
67
  */
57
68
 
58
69
  import type { OpEnvironment, OpSetupStep } from "../lexicon";
70
+ import { gatePointProblems, type GatePoint } from "./gate-point";
59
71
  import { notifyConfigProblem, type NotifyConfig } from "../notify";
60
72
 
61
73
  /**
@@ -92,6 +104,40 @@ export const GATE_APPROVAL_SOURCES: readonly GateApprovalSource[] = ["ledger", "
92
104
  /** What a failing hook does: fail the wave, or hold it for an approval of its set digest. */
93
105
  export type OpWaveHookFailure = "fail" | "approve";
94
106
 
107
+ /**
108
+ * The decision point a wave's gate asks (#3836): a point's name, or `{ name,
109
+ * inputs?, pass?, store?, timeout? }` as an Op gate's `point` takes, plus the
110
+ * pending fact's `timeout` (default 48h). The point must declare the input
111
+ * `gate.planDigest`, which is the wave's set digest.
112
+ */
113
+ export type OpWavePoint = GatePoint & {
114
+ /** How long the pending fact that cites an open question stands, such as `"48h"`. */
115
+ timeout?: string;
116
+ };
117
+
118
+ /** A wave's or a spec's `point` as an {@link OpWavePoint}, or undefined when it names none. */
119
+ export function opWavePointOf(point: string | OpWavePoint | undefined): OpWavePoint | undefined {
120
+ if (point === undefined) return undefined;
121
+ return typeof point === "string" ? { name: point } : point;
122
+ }
123
+
124
+ /** What is wrong with a wave's `point`, empty when nothing is (#3836). */
125
+ export function opWavePointProblems(point: unknown): string[] {
126
+ if (point !== null && typeof point === "object" && !Array.isArray(point) && "timeout" in point) {
127
+ const { timeout, ...rest } = point as Record<string, unknown>;
128
+ const problems = gatePointProblems(rest);
129
+ if (typeof timeout !== "string" || !/^(\d+(ms|s|m|h|d))+$/.test(timeout)) problems.push("`point.timeout` is a duration such as \"48h\"");
130
+ return problems;
131
+ }
132
+ return gatePointProblems(point);
133
+ }
134
+
135
+ /** The point wave `name`'s gate asks (#3836): the wave's own, else the spec's, else none. */
136
+ export function opWavePointAt(spec: Pick<OpWavesSpec, "point" | "waves">, name: string): OpWavePoint | undefined {
137
+ const wave = spec.waves.find((w) => w.name === name);
138
+ return opWavePointOf(wave?.point ?? spec.point);
139
+ }
140
+
95
141
  export const OP_WAVE_HOOK_FAILURES: readonly OpWaveHookFailure[] = ["fail", "approve"];
96
142
 
97
143
  /** One stage hook (#3762): a command run before or after a wave's plan or apply. */
@@ -181,6 +227,11 @@ export interface OpWave {
181
227
  * Like `gate`, the runner reads it from the spec file at the base commit.
182
228
  */
183
229
  approval?: GateApprovalSource;
230
+ /**
231
+ * The decision point this wave's gate asks (#3836), in place of the spec's.
232
+ * Read at base like `gate`. See {@link OpWavesSpec.point}.
233
+ */
234
+ point?: string | OpWavePoint;
184
235
  /**
185
236
  * Split the wave into a deciding job and this many share jobs, each
186
237
  * applying a slice of the runs. Default 1: one job plans, decides and
@@ -266,6 +317,15 @@ export interface OpWavesSpec {
266
317
  * the rules (`@intentius/chant/override-gate`).
267
318
  */
268
319
  override?: OpWavesOverride;
320
+ /**
321
+ * The decision point every wave's gate asks (#3836), unless a wave names its
322
+ * own. Read at the base commit, like the policy: a wave that needs an
323
+ * approval asks the point with `gate.planDigest` set to its set digest, and
324
+ * only an answer the point's `pass` names (default yes) for that digest
325
+ * lets it apply. `chant approve` does not pass such a gate, and the wave's
326
+ * `approval` mode does not apply: the point's deciders and quorum decide.
327
+ */
328
+ point?: string | OpWavePoint;
269
329
  waves: OpWave[];
270
330
  /**
271
331
  * A scheduled job that starts again a wave job whose approval has arrived
@@ -442,6 +502,10 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
442
502
  if (typeof value !== "string" || value.trim() === "") throw new Error(`${where}: override.overriders must be a path, such as ".chant/overriders".`);
443
503
  }
444
504
  }
505
+ if (spec.point !== undefined) {
506
+ const problems = opWavePointProblems(spec.point);
507
+ if (problems.length > 0) throw new Error(`${where}: ${problems.join("; ")}.`);
508
+ }
445
509
  if (spec.neverOverMcp !== undefined && typeof spec.neverOverMcp !== "boolean") throw new Error(`${where}: neverOverMcp must be true or false.`);
446
510
  if (spec.notify !== undefined) {
447
511
  const problem = notifyConfigProblem(spec.notify, "notify");
@@ -461,6 +525,10 @@ export function assertOpWavesSpec(spec: OpWavesSpec): void {
461
525
  throw new Error(`${where}: wave "${wave.name}" has approval "${wave.approval}"; use ${GATE_APPROVAL_SOURCES.join(", ")}.`);
462
526
  }
463
527
  assertOpWaveHooks(`${where}, wave "${wave.name}"`, wave.hooks);
528
+ if (wave.point !== undefined) {
529
+ const problems = opWavePointProblems(wave.point);
530
+ if (problems.length > 0) throw new Error(`${where}: wave "${wave.name}": ${problems.join("; ")}.`);
531
+ }
464
532
  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.`);
465
533
  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.`);
466
534
  if (wave.discover) {
@@ -202,7 +202,10 @@ describe.skipIf(!hasSshKeygen)("identity.gates", () => {
202
202
 
203
203
  const roster = memoryGateLedgerPort({ pending: [pending], resolutions: [approval("alex")] });
204
204
  expect((await evaluateGate(roster, input)).satisfied).toBe(true);
205
- expect((await evaluateGate({ ...roster, approvalRule: async () => rule }, input)).satisfied).toBe(false);
205
+ const held = await evaluateGate({ ...roster, approvalRule: async () => rule }, input);
206
+ expect(held.satisfied).toBe(false);
207
+ // #3841: the approval the rule refused is named, with why.
208
+ expect(!held.satisfied && held.refused?.map((x) => [x.resolution.resolvedBy, x.reason])).toEqual([["alex", expect.stringMatching(/not signed/)]]);
206
209
  const sealed = memoryGateLedgerPort({ pending: [pending], resolutions: [approval("alex"), signed(alice, approval("github:alice"))] });
207
210
  const check = await evaluateGate({ ...sealed, approvalRule: async () => rule }, input);
208
211
  expect(check.satisfied && check.resolution.resolvedBy).toBe("github:alice");
@@ -179,8 +179,9 @@ export function gateAdmissionFrom(source: GateRuleSource, gate: string): GateAdm
179
179
  * The rule for `gate` in the workspace holding `cwd`, read at base (the
180
180
  * target branch tip), or null when there is no declaration or it names none.
181
181
  * What a run, `chant approve` and `workspace status` ask before counting an
182
- * approval.
182
+ * approval. `base` names the commit to read at instead, as an op wave reads
183
+ * every rule at its `--base` (#3841).
183
184
  */
184
- export function gateAdmission(cwd: string, gate: string): GateAdmission | null {
185
- return gateAdmissionFrom(scopeSource(cwd), gate);
185
+ export function gateAdmission(cwd: string, gate: string, base?: string): GateAdmission | null {
186
+ return gateAdmissionFrom(scopeSource(cwd, base), gate);
186
187
  }
@@ -368,11 +368,12 @@ function readAt(cwd: string, at?: string): { declaration: Declaration; rootOnDis
368
368
  * The scope for a write from `cwd`: the declaration and the trust policy at
369
369
  * the base revision (`origin/HEAD`, `main` or `master`), so a write can't
370
370
  * widen its own scope by editing its copy of the declaration. Without a base,
371
- * or without a declaration at it, the working tree's declaration.
371
+ * or without a declaration at it, the working tree's declaration. `at` names
372
+ * the base instead, as an op wave's `--base` does (#3841).
372
373
  */
373
- export function scopeSource(cwd: string): ScopeSource {
374
+ export function scopeSource(cwd: string, at?: string): ScopeSource {
374
375
  const top = gitRoot(cwd);
375
- const base = top ? resolveBase(top) : null;
376
+ const base = top ? resolveBase(top, at) : null;
376
377
  const policy = top && base ? policyAtBase(top, base) : emptyPolicy(null);
377
378
  const withClasses = (read: NonNullable<ReturnType<typeof readAt>>) => {
378
379
  const { tree, ...rest } = read;