@intentius/chant 0.124.11 → 0.124.13

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 (50) hide show
  1. package/dist/cli/commands/lint.d.ts.map +1 -1
  2. package/dist/gate-decision.d.ts +36 -4
  3. package/dist/gate-decision.d.ts.map +1 -1
  4. package/dist/lifecycle/gate-ledger.d.ts +8 -0
  5. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  6. package/dist/lifecycle/git.d.ts +30 -14
  7. package/dist/lifecycle/git.d.ts.map +1 -1
  8. package/dist/lifecycle/ledger-port.d.ts +18 -0
  9. package/dist/lifecycle/ledger-port.d.ts.map +1 -0
  10. package/dist/lifecycle/release-ledger.d.ts +10 -9
  11. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  12. package/dist/lifecycle/release-read.d.ts +23 -0
  13. package/dist/lifecycle/release-read.d.ts.map +1 -0
  14. package/dist/lifecycle/wave-plans.d.ts +51 -0
  15. package/dist/lifecycle/wave-plans.d.ts.map +1 -0
  16. package/dist/lint/typescript.d.ts +18 -0
  17. package/dist/lint/typescript.d.ts.map +1 -0
  18. package/dist/op/activities/activity-contracts.d.ts +4 -4
  19. package/dist/op/gate-review.d.ts +1 -20
  20. package/dist/op/gate-review.d.ts.map +1 -1
  21. package/dist/op/op-waves-run.d.ts +1 -1
  22. package/dist/op/op-waves-run.d.ts.map +1 -1
  23. package/dist/workspace/identity-gate-rule.d.ts +65 -0
  24. package/dist/workspace/identity-gate-rule.d.ts.map +1 -0
  25. package/dist/workspace/identity.d.ts +4 -2
  26. package/dist/workspace/identity.d.ts.map +1 -1
  27. package/dist/workspace/principal-classes.d.ts +3 -14
  28. package/dist/workspace/principal-classes.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/cli/commands/lint.ts +33 -6
  31. package/src/gate-decision-bundle.test.ts +3 -2
  32. package/src/gate-decision-identity.test.ts +176 -0
  33. package/src/gate-decision.ts +100 -22
  34. package/src/lifecycle/gate-ledger.ts +8 -0
  35. package/src/lifecycle/git.ts +61 -18
  36. package/src/lifecycle/ledger-port.ts +17 -0
  37. package/src/lifecycle/ledger-readers-bundle.test.ts +47 -0
  38. package/src/lifecycle/ledger-readers.test.ts +139 -0
  39. package/src/lifecycle/release-ledger.ts +15 -42
  40. package/src/lifecycle/release-read.ts +69 -0
  41. package/src/lifecycle/wave-plans.ts +93 -0
  42. package/src/lint/typescript.ts +18 -0
  43. package/src/meta/lexicon-typescript-import.test.ts +42 -0
  44. package/src/op/gate-review.ts +2 -16
  45. package/src/op/op-waves-point.test.ts +1 -0
  46. package/src/op/op-waves-run.ts +8 -14
  47. package/src/op/op-waves.test.ts +14 -0
  48. package/src/workspace/identity-gate-rule.ts +109 -0
  49. package/src/workspace/identity.ts +8 -18
  50. package/src/workspace/principal-classes.ts +4 -29
@@ -45,6 +45,7 @@ import { isNeverOverMcpGateOp } from "../op/gate-name";
45
45
  import { sortedJsonReplacer } from "../utils";
46
46
  import { currentGateOrigin, type GateOrigin } from "./gate-origin";
47
47
  import type { GateApprover, GatePolicyDecision, ResolvedGateApproval } from "../op/gate-approval";
48
+ import type { GateApprovalSource } from "../op/op-waves";
48
49
  import type { GateRunLocator } from "../op/gate-run-locator";
49
50
  import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
50
51
 
@@ -288,6 +289,13 @@ export interface PendingGateRecord {
288
289
  * the run produced, and `chant operator status` to show quorum progress.
289
290
  */
290
291
  approval?: ResolvedGateApproval;
292
+ /**
293
+ * The approval mode an Op wave asked under (#3861): `ledger`, `pr-review` or
294
+ * `sealed`, as read at base. It says which approvals count for the gate, so a
295
+ * reader (behold, gate-audit) decides under the mode the wave used rather
296
+ * than inferring it from config. Not the quorum block in `approval`.
297
+ */
298
+ approvalMode?: GateApprovalSource;
291
299
  /**
292
300
  * The open question this gate waits on, on a gate that asks a decision
293
301
  * point (#3170). It is answered with `chant workspace points answer <id>`,
@@ -5,8 +5,9 @@
5
5
  * no working tree changes.
6
6
  */
7
7
  import { getRuntime } from "../runtime-adapter";
8
- import { resolveMemberLedger } from "./member-ledger";
8
+ import { memberLedgerPrefix, resolveMemberLedger } from "./member-ledger";
9
9
  import { LIFECYCLE_LOCAL_NOTE } from "./local-note";
10
+ import type { LedgerReadPort } from "./ledger-port";
10
11
 
11
12
  const STATE_BRANCH = "chant/lifecycle";
12
13
 
@@ -18,9 +19,29 @@ const STATE_BRANCH = "chant/lifecycle";
18
19
  * build records) goes through here, so none of them names the prefix itself.
19
20
  * See ./member-ledger.ts for who counts as a member.
20
21
  */
21
- export async function ledgerDir(dir: string, opts?: { cwd?: string }): Promise<string> {
22
- const { prefix } = await resolveMemberLedger(opts?.cwd ?? process.cwd());
23
- return `${prefix}${dir}`;
22
+ export async function ledgerDir(dir: string, opts?: LedgerLocation): Promise<string> {
23
+ return `${await ledgerPrefixOf(opts)}${dir}`;
24
+ }
25
+
26
+ /**
27
+ * Where to read, when the caller is not standing in the member it reads
28
+ * (#3865): a reader such as behold's lists many members from one checkout. Both
29
+ * fields override what `cwd` resolves to; `ledgerPrefix` wins over `member`.
30
+ */
31
+ export interface LedgerLocation {
32
+ cwd?: string;
33
+ /** A member's name, or its path of names in a nested workspace, outermost first. `"."` and `[]` name the flat layout. */
34
+ member?: string | readonly string[];
35
+ /** The prefix `resolveMemberLedger` returns: `""`, or `_members/<member>/` per level. */
36
+ ledgerPrefix?: string;
37
+ }
38
+
39
+ async function ledgerPrefixOf(opts?: LedgerLocation): Promise<string> {
40
+ if (opts?.ledgerPrefix !== undefined) return opts.ledgerPrefix;
41
+ if (opts?.member !== undefined) {
42
+ return memberLedgerPrefix((typeof opts.member === "string" ? [opts.member] : [...opts.member]).filter((m) => m !== "."));
43
+ }
44
+ return (await resolveMemberLedger(opts?.cwd ?? process.cwd())).prefix;
24
45
  }
25
46
 
26
47
  /**
@@ -348,7 +369,7 @@ export async function writeLedgerFiles(
348
369
  export async function readBlobFromPath(
349
370
  environment: string,
350
371
  filename: string,
351
- opts?: { cwd?: string },
372
+ opts?: LedgerLocation,
352
373
  ): Promise<string | null> {
353
374
  const rt = getRuntime();
354
375
  const dir = await ledgerDir(environment, opts);
@@ -507,7 +528,7 @@ export async function appendReleaseRecordLine(
507
528
  */
508
529
  export async function readReleaseLedgerLines(
509
530
  environment: string,
510
- opts?: { cwd?: string },
531
+ opts?: LedgerLocation,
511
532
  ): Promise<string[]> {
512
533
  const content = await readBlobFromPath(environment, "releases.jsonl", opts);
513
534
  if (!content) return [];
@@ -520,7 +541,21 @@ export async function readReleaseLedgerLines(
520
541
  * `releases.jsonl` entry. Used by `chant components status` (no env arg) to
521
542
  * discover which environments to report on.
522
543
  */
523
- export async function listLedgerEnvironments(opts?: { cwd?: string }): Promise<string[]> {
544
+ export async function listLedgerEnvironments(opts?: LedgerLocation): Promise<string[]> {
545
+ const envs: string[] = [];
546
+ for (const name of await listLedgerTrees(opts)) {
547
+ const hasLedger = await readBlobFromPath(name, "releases.jsonl", opts);
548
+ if (hasLedger) envs.push(name);
549
+ }
550
+ return envs.sort();
551
+ }
552
+
553
+ /**
554
+ * The directories directly under the ledger's base: the root for the flat
555
+ * layout, `_members/<member>/` for a member. `[]` when the branch, or the
556
+ * base, is absent.
557
+ */
558
+ export async function listLedgerTrees(opts?: LedgerLocation): Promise<string[]> {
524
559
  const tip = await getStateBranchTip(opts?.cwd);
525
560
  if (!tip) return [];
526
561
  const rt = getRuntime();
@@ -535,18 +570,12 @@ export async function listLedgerEnvironments(opts?: { cwd?: string }): Promise<s
535
570
  { cwd: opts?.cwd },
536
571
  );
537
572
  if (rootResult.exitCode !== 0) return [];
538
-
539
- const envs: string[] = [];
540
- const lines = rootResult.stdout.trim().split("\n").filter(Boolean);
541
- for (const line of lines) {
573
+ const trees: string[] = [];
574
+ for (const line of rootResult.stdout.trim().split("\n").filter(Boolean)) {
542
575
  const match = line.match(/^(\d+)\s+(\w+)\s+([0-9a-f]+)\t(.+)$/);
543
- if (!match) continue;
544
- const [, , type, , name] = match;
545
- if (type !== "tree") continue;
546
- const hasLedger = await readBlobFromPath(name, "releases.jsonl", opts);
547
- if (hasLedger) envs.push(name);
576
+ if (match && match[2] === "tree") trees.push(match[4]);
548
577
  }
549
- return envs.sort();
578
+ return trees;
550
579
  }
551
580
 
552
581
  /**
@@ -557,7 +586,7 @@ export async function listLedgerEnvironments(opts?: { cwd?: string }): Promise<s
557
586
  * manifest (`_builds/<digest>.json`) without duplicating this `git ls-tree
558
587
  * --name-only` call.
559
588
  */
560
- export async function listFilesInDir(dir: string, opts?: { cwd?: string }): Promise<string[]> {
589
+ export async function listFilesInDir(dir: string, opts?: LedgerLocation): Promise<string[]> {
561
590
  const tip = await getStateBranchTip(opts?.cwd);
562
591
  if (!tip) return [];
563
592
  const rt = getRuntime();
@@ -1405,3 +1434,17 @@ function baseName(dir: string): string {
1405
1434
  function depth(dir: string): number {
1406
1435
  return dir === "" ? 0 : dir.split("/").length;
1407
1436
  }
1437
+
1438
+ /**
1439
+ * The branch's read side as a {@link LedgerReadPort} for the member `opts`
1440
+ * names (#3865): what the bundle-safe readers (`./release-read.ts`,
1441
+ * `./wave-plans.ts`) are handed. A product with its own way to read the
1442
+ * branch passes its own port instead and never loads this module.
1443
+ */
1444
+ export function gitLedgerReadPort(opts?: LedgerLocation): LedgerReadPort {
1445
+ return {
1446
+ read: (dir, file) => readBlobFromPath(dir, file, opts),
1447
+ list: (dir) => listFilesInDir(dir, opts),
1448
+ trees: () => listLedgerTrees(opts),
1449
+ };
1450
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The read side of `chant/lifecycle` that the bundle-safe readers take
3
+ * (#3865). Paths are relative to one project's ledger: the flat root, or a
4
+ * workspace member's `_members/<member>/`. Which of those a port reads is the
5
+ * port's business; `gitLedgerReadPort` (`./git.ts`) takes a member or a
6
+ * prefix, and a product that reads the branch its own way supplies its own.
7
+ *
8
+ * Types only, so a bundle that imports a reader loads no git code.
9
+ */
10
+ export interface LedgerReadPort {
11
+ /** The file at `<dir>/<file>`, or null when the branch, the directory or the file is absent. */
12
+ read(dir: string, file: string): Promise<string | null>;
13
+ /** The names directly under `<dir>/`, or `[]` when absent. */
14
+ list(dir: string): Promise<string[]>;
15
+ /** The directories directly under the ledger's root: every environment, and `_gates`, `_wave-plans` and the rest. */
16
+ trees(): Promise<string[]>;
17
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The release and wave-plan readers bundle small (#3865, as #3764 did for the
3
+ * gate audit). A product that shows a workspace member's environments or the
4
+ * recorded wave plans bundles one file with no TypeScript toolchain, so
5
+ * `@intentius/chant/lifecycle/release-read` and
6
+ * `@intentius/chant/lifecycle/wave-plans` must not reach `typescript`, esbuild,
7
+ * zod, git, or chant's fold, lint, codegen, workspace and op modules.
8
+ */
9
+
10
+ import { join } from "node:path";
11
+ import { build } from "esbuild";
12
+ import { describe, expect, test } from "vitest";
13
+ import { REPO } from "../workspace/__fixtures__/contract-repo";
14
+
15
+ const BUDGET_BYTES = 16 * 1024;
16
+
17
+ const FORBIDDEN = [/node_modules\//, /\/src\/fold\//, /\/src\/lint\//, /\/src\/codegen\//, /\/src\/workspace\//, /\/lifecycle\/git\.ts$/, /\/lifecycle\/member-ledger\.ts$/, /\/lifecycle\/release-ledger\.ts$/, /\/op\//];
18
+
19
+ async function bundle(entry: string) {
20
+ const result = await build({
21
+ entryPoints: [join(REPO, "packages", "core", "src", "lifecycle", entry)],
22
+ bundle: true,
23
+ platform: "node",
24
+ format: "esm",
25
+ write: false,
26
+ metafile: true,
27
+ outdir: "out",
28
+ logLevel: "silent",
29
+ });
30
+ const inputs = Object.keys(result.metafile.inputs);
31
+ for (const pattern of FORBIDDEN) {
32
+ expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
33
+ }
34
+ const bytes = result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0);
35
+ expect(bytes).toBeLessThan(BUDGET_BYTES);
36
+ return inputs.map((i) => i.replace(/^.*?packages\//, "packages/")).sort();
37
+ }
38
+
39
+ describe("the ledger readers bundles", () => {
40
+ test("the wave plans reader holds itself and nothing else", async () => {
41
+ expect(await bundle("wave-plans.ts")).toEqual(["packages/core/src/lifecycle/wave-plans.ts"]);
42
+ });
43
+
44
+ test("the release reader holds itself and the legacy digest flag", async () => {
45
+ expect(await bundle("release-read.ts")).toEqual(["packages/core/src/lifecycle/legacy-digest.ts", "packages/core/src/lifecycle/release-read.ts"]);
46
+ });
47
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * #3865 — the readers a product uses for a workspace member's release
3
+ * environments and op-waves `_wave-plans` records: pure over a port, and the
4
+ * git port over a member's prefix.
5
+ */
6
+ import { afterAll, describe, expect, test } from "vitest";
7
+ import { spawnSync } from "node:child_process";
8
+ import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import type { LedgerReadPort } from "./ledger-port";
12
+ import { gitLedgerReadPort, listLedgerEnvironments, writeLedgerFiles } from "./git";
13
+ import { listReleaseEnvironmentsOn, parseReleaseLedger, readReleaseRecords } from "./release-read";
14
+ import { listReleaseEnvironments, readReleaseLedger } from "./release-ledger";
15
+ import { listWavePlanSpecs, parseHeadPlans, readAllHeadPlans, readHeadPlanDigests, readHeadPlans } from "./wave-plans";
16
+
17
+ /** A port over a flat map of `<dir>/<file>` to text. */
18
+ function memoryPort(files: Record<string, string>): LedgerReadPort {
19
+ return {
20
+ read: async (dir, file) => files[`${dir}/${file}`] ?? null,
21
+ list: async (dir) => [...new Set(Object.keys(files).filter((p) => p.startsWith(`${dir}/`)).map((p) => p.slice(dir.length + 1).split("/")[0]))],
22
+ trees: async () => [...new Set(Object.keys(files).map((p) => p.split("/")[0]))],
23
+ };
24
+ }
25
+
26
+ const record = (env: string, digest = "sha256:" + "a".repeat(64)) =>
27
+ JSON.stringify({ version: 1, component: "svc", env, digest, gitSha: "abc", runId: "r1", timestamp: "2026-01-01T00:00:00.000Z", actor: "ci" });
28
+
29
+ const plans = (head: string, digests: string[]) =>
30
+ JSON.stringify({ version: 1, name: "ship", head, timestamp: "2026-01-01T00:00:00.000Z", waves: digests.map((digest, i) => ({ wave: i + 1, name: `w${i + 1}`, digest })) });
31
+
32
+ describe("release readers over a port", () => {
33
+ test("list the environments that hold a ledger, sorted, and parse their records", async () => {
34
+ const port = memoryPort({
35
+ "staging/releases.jsonl": record("staging") + "\n",
36
+ "prod/releases.jsonl": record("prod") + "\nnot json\n" + JSON.stringify({ version: 1 }) + "\n",
37
+ "prod/snapshot.json": "{}",
38
+ "_gates/deploy.jsonl": "",
39
+ });
40
+ expect(await listReleaseEnvironmentsOn(port)).toEqual(["prod", "staging"]);
41
+ const { records, malformed } = await readReleaseRecords(port, "prod");
42
+ expect(records.map((r) => r.env)).toEqual(["prod"]);
43
+ expect(malformed).toBe(2);
44
+ expect(await readReleaseRecords(port, "nope")).toEqual({ records: [], malformed: 0 });
45
+ });
46
+
47
+ test("parseReleaseLedger skips a non-object line", () => {
48
+ expect(parseReleaseLedger(["null", "3"]).malformed).toBe(2);
49
+ });
50
+ });
51
+
52
+ describe("wave plans readers", () => {
53
+ const files = {
54
+ "_wave-plans/ship/aaa.json": plans("aaa", ["sha256:one", "sha256:two"]),
55
+ "_wave-plans/ship/bbb.json": plans("bbb", ["sha256:three"]),
56
+ "_wave-plans/ship/broken.json": "{",
57
+ "_wave-plans/other/ccc.json": plans("ccc", ["sha256:four"]),
58
+ };
59
+
60
+ test("read one head, and null for a missing or unreadable one", async () => {
61
+ const port = memoryPort(files);
62
+ expect((await readHeadPlans(port, "ship", "aaa"))?.waves.map((w) => w.digest)).toEqual(["sha256:one", "sha256:two"]);
63
+ expect(await readHeadPlans(port, "ship", "zzz")).toBeNull();
64
+ expect(await readHeadPlans(port, "ship", "broken")).toBeNull();
65
+ });
66
+
67
+ test("read every head of a spec, by head, with the digests by wave", async () => {
68
+ const port = memoryPort(files);
69
+ expect([...(await readAllHeadPlans(port, "ship")).keys()]).toEqual(["aaa", "bbb"]);
70
+ const digests = await readHeadPlanDigests(port, "ship");
71
+ expect(digests.get("aaa")!.get(2)).toBe("sha256:two");
72
+ expect(digests.get("bbb")!.get(1)).toBe("sha256:three");
73
+ expect(await listWavePlanSpecs(port)).toEqual(["other", "ship"]);
74
+ });
75
+
76
+ test("the parser refuses a record of another shape", () => {
77
+ expect(parseHeadPlans(null)).toBeNull();
78
+ expect(parseHeadPlans("[]")).toBeNull();
79
+ expect(parseHeadPlans(JSON.stringify({ version: 2, name: "a", head: "b", timestamp: "t", waves: [] }))).toBeNull();
80
+ expect(parseHeadPlans(JSON.stringify({ version: 1, name: "a", head: "b", timestamp: "t", waves: [{ wave: "1", name: "x", digest: "d" }] }))).toBeNull();
81
+ });
82
+ });
83
+
84
+ describe("a member's records on chant/lifecycle", () => {
85
+ const scratch: string[] = [];
86
+ afterAll(() => {
87
+ for (const d of scratch) rmSync(d, { recursive: true, force: true });
88
+ });
89
+
90
+ async function fixture(): Promise<string> {
91
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-ledger-readers-")));
92
+ scratch.push(root);
93
+ const git = (...args: string[]) => {
94
+ const r = spawnSync("git", args, { cwd: root, encoding: "utf-8" });
95
+ if (r.status !== 0) throw new Error(r.stderr);
96
+ };
97
+ git("init", "-q", "-b", "main");
98
+ git("config", "user.email", "t@chant.dev");
99
+ git("config", "user.name", "T");
100
+ writeFileSync(join(root, "f"), "");
101
+ git("add", "-A");
102
+ git("commit", "-q", "-m", "init");
103
+ await writeLedgerFiles(
104
+ [
105
+ { path: "_members/api/prod/releases.jsonl", content: record("prod") + "\n" },
106
+ { path: "_members/api/staging/releases.jsonl", content: record("staging") + "\n" },
107
+ { path: "_members/web/edge/releases.jsonl", content: record("edge") + "\n" },
108
+ { path: "_members/api/_wave-plans/ship/aaa.json", content: plans("aaa", ["sha256:one"]) },
109
+ { path: "_members/web/_wave-plans/ship/bbb.json", content: plans("bbb", ["sha256:two"]) },
110
+ { path: "legacy/releases.jsonl", content: record("legacy") + "\n" },
111
+ ],
112
+ "seed",
113
+ { cwd: root },
114
+ );
115
+ return root;
116
+ }
117
+
118
+ test("listReleaseEnvironments and readReleaseLedger take a member or its prefix, from outside it", async () => {
119
+ const cwd = await fixture();
120
+ expect(await listReleaseEnvironments({ cwd, member: "api" })).toEqual(["prod", "staging"]);
121
+ expect(await listReleaseEnvironments({ cwd, ledgerPrefix: "_members/web/" })).toEqual(["edge"]);
122
+ expect(await listReleaseEnvironments({ cwd, member: "." })).toEqual(["legacy"]);
123
+ expect(await listLedgerEnvironments({ cwd })).toEqual(["legacy"]);
124
+ expect((await readReleaseLedger("prod", { cwd, member: "api" })).records).toHaveLength(1);
125
+ expect((await readReleaseLedger("prod", { cwd, member: "web" })).records).toHaveLength(0);
126
+ expect((await readReleaseLedger("prod", { cwd, ledgerPrefix: "_members/api/" })).records).toHaveLength(1);
127
+ });
128
+
129
+ test("the git port reads each member's wave plans and nothing of another's", async () => {
130
+ const cwd = await fixture();
131
+ const api = gitLedgerReadPort({ cwd, member: "api" });
132
+ const web = gitLedgerReadPort({ cwd, member: ["web"] });
133
+ expect([...(await readHeadPlanDigests(api, "ship")).entries()].map(([h, m]) => [h, [...m]])).toEqual([["aaa", [[1, "sha256:one"]]]]);
134
+ expect([...(await readAllHeadPlans(web, "ship")).keys()]).toEqual(["bbb"]);
135
+ expect(await listWavePlanSpecs(api)).toEqual(["ship"]);
136
+ expect(await listReleaseEnvironmentsOn(api)).toEqual(["prod", "staging"]);
137
+ expect(await listWavePlanSpecs(gitLedgerReadPort({ cwd, member: "." }))).toEqual([]);
138
+ });
139
+ });
@@ -29,8 +29,13 @@
29
29
  */
30
30
 
31
31
  import { sortedJsonReplacer } from "../utils";
32
- import { flagLegacyDigest, warnOnLegacyDigests, withoutReadFlags, type LegacyDigestFlag } from "./legacy-digest";
33
- import { appendReleaseRecordLine, readReleaseLedgerLines, listLedgerEnvironments as gitListLedgerEnvironments } from "./git";
32
+ import { warnOnLegacyDigests, withoutReadFlags, type LegacyDigestFlag } from "./legacy-digest";
33
+ import { appendReleaseRecordLine, readReleaseLedgerLines, listLedgerEnvironments as gitListLedgerEnvironments, type LedgerLocation } from "./git";
34
+ import { parseReleaseLedger, validateReleaseRecord } from "./release-read";
35
+
36
+ // The pure readers live apart, so a bundle can import them without git (#3865).
37
+ export { validateReleaseRecord, parseReleaseLedger, releaseLedgerLines, readReleaseRecords, listReleaseEnvironmentsOn } from "./release-read";
38
+ export type { LedgerReadPort } from "./ledger-port";
34
39
 
35
40
  /**
36
41
  * The id space a `ReleaseRecord.runId` lives in, and how to reach the run
@@ -172,17 +177,6 @@ export interface ReleaseRecord {
172
177
  redeploys?: ReleaseRef;
173
178
  }
174
179
 
175
- /** Required, non-empty-string fields every `ReleaseRecord` must carry. */
176
- const REQUIRED_STRING_FIELDS: Array<keyof ReleaseRecord> = [
177
- "component",
178
- "env",
179
- "digest",
180
- "gitSha",
181
- "runId",
182
- "timestamp",
183
- "actor",
184
- ];
185
-
186
180
  /** Thrown by `appendReleaseRecord` when the record is missing a required field — a release record can never be recorded partially, since it is never editable afterward. */
187
181
  export class InvalidReleaseRecordError extends Error {
188
182
  constructor(public readonly missing: string[]) {
@@ -191,16 +185,6 @@ export class InvalidReleaseRecordError extends Error {
191
185
  }
192
186
  }
193
187
 
194
- /** Validate that a release record carries every required field as a non-empty string. */
195
- export function validateReleaseRecord(record: Partial<ReleaseRecord>): string[] {
196
- const missing: string[] = [];
197
- for (const field of REQUIRED_STRING_FIELDS) {
198
- const value = record[field];
199
- if (typeof value !== "string" || value.length === 0) missing.push(field);
200
- }
201
- return missing;
202
- }
203
-
204
188
  /** Input a caller supplies to record one deploy — `version` is filled in, everything else is required (see `ReleaseRecord`). */
205
189
  export type ReleaseRecordInput = Omit<ReleaseRecord, "version" | "flags">;
206
190
 
@@ -284,24 +268,9 @@ export async function appendReleaseRecord(
284
268
  */
285
269
  export async function readReleaseLedger(
286
270
  environment: string,
287
- opts?: { cwd?: string },
271
+ opts?: LedgerLocation,
288
272
  ): Promise<{ records: ReleaseRecord[]; malformed: number }> {
289
- const lines = await readReleaseLedgerLines(environment, opts);
290
- const records: ReleaseRecord[] = [];
291
- let malformed = 0;
292
- for (const line of lines) {
293
- try {
294
- const parsed = JSON.parse(line) as ReleaseRecord;
295
- if (validateReleaseRecord(parsed).length > 0) {
296
- malformed++;
297
- continue;
298
- }
299
- // chant #2514 — a digest from before real SHA-256 is flagged, never re-keyed.
300
- records.push(flagLegacyDigest(parsed, [parsed.digest, parsed.manifestDigest, parsed.inputDigest]));
301
- } catch {
302
- malformed++;
303
- }
304
- }
273
+ const { records, malformed } = parseReleaseLedger(await readReleaseLedgerLines(environment, opts));
305
274
  // chant #2514 — say once that this ledger holds values in the old form.
306
275
  warnOnLegacyDigests(
307
276
  records.flatMap((r) => [r.digest, r.manifestDigest, r.inputDigest]),
@@ -311,8 +280,12 @@ export async function readReleaseLedger(
311
280
  return { records, malformed };
312
281
  }
313
282
 
314
- /** List every environment that has release records on the orphan branch. */
315
- export async function listReleaseEnvironments(opts?: { cwd?: string }): Promise<string[]> {
283
+ /**
284
+ * List every environment that has release records on the orphan branch. With
285
+ * `member` or `ledgerPrefix` (#3865) it lists that workspace member's, from
286
+ * wherever `cwd` is; without either, the member `cwd` resolves to.
287
+ */
288
+ export async function listReleaseEnvironments(opts?: LedgerLocation): Promise<string[]> {
316
289
  return gitListLedgerEnvironments(opts);
317
290
  }
318
291
 
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Reading release records without git (#3865). The parsing and the
3
+ * environment listing of `release-ledger.ts`, over a {@link LedgerReadPort}
4
+ * instead of a checkout, so a product bundles them in one file
5
+ * (`release-read-bundle.test.ts` holds the import graph). Pass
6
+ * `gitLedgerReadPort({ member })` from `./git.ts` to read a workspace member's
7
+ * ledger from a checkout, or a port of your own.
8
+ *
9
+ * `release-ledger.ts` reads through these too; its `readReleaseLedger` and
10
+ * `listReleaseEnvironments` also take `member` or `ledgerPrefix`.
11
+ */
12
+ import { flagLegacyDigest } from "./legacy-digest";
13
+ import type { LedgerReadPort } from "./ledger-port";
14
+ import type { ReleaseRecord } from "./release-ledger";
15
+
16
+ /** Required, non-empty-string fields every `ReleaseRecord` must carry. */
17
+ const REQUIRED_STRING_FIELDS: Array<keyof ReleaseRecord> = ["component", "env", "digest", "gitSha", "runId", "timestamp", "actor"];
18
+
19
+ /** The required fields a release record lacks as non-empty strings. */
20
+ export function validateReleaseRecord(record: Partial<ReleaseRecord>): string[] {
21
+ const missing: string[] = [];
22
+ for (const field of REQUIRED_STRING_FIELDS) {
23
+ const value = record[field];
24
+ if (typeof value !== "string" || value.length === 0) missing.push(field);
25
+ }
26
+ return missing;
27
+ }
28
+
29
+ /** The lines of a `releases.jsonl` file: trimmed, blanks dropped. */
30
+ export function releaseLedgerLines(text: string | null): string[] {
31
+ return text ? text.split("\n").map((line) => line.trim()).filter(Boolean) : [];
32
+ }
33
+
34
+ /**
35
+ * Parse release ledger lines, oldest first. A malformed line is counted and
36
+ * skipped; a digest recorded by the old 32-bit hash reads back flagged
37
+ * (#2514), never re-keyed.
38
+ */
39
+ export function parseReleaseLedger(lines: readonly string[]): { records: ReleaseRecord[]; malformed: number } {
40
+ const records: ReleaseRecord[] = [];
41
+ let malformed = 0;
42
+ for (const line of lines) {
43
+ try {
44
+ const parsed = JSON.parse(line) as ReleaseRecord;
45
+ if (parsed === null || typeof parsed !== "object" || validateReleaseRecord(parsed).length > 0) {
46
+ malformed++;
47
+ continue;
48
+ }
49
+ records.push(flagLegacyDigest(parsed, [parsed.digest, parsed.manifestDigest, parsed.inputDigest]));
50
+ } catch {
51
+ malformed++;
52
+ }
53
+ }
54
+ return { records, malformed };
55
+ }
56
+
57
+ /** Every release record of `environment` the port holds, oldest first. */
58
+ export async function readReleaseRecords(port: LedgerReadPort, environment: string): Promise<{ records: ReleaseRecord[]; malformed: number }> {
59
+ return parseReleaseLedger(releaseLedgerLines(await port.read(environment, "releases.jsonl")));
60
+ }
61
+
62
+ /** The environments with a `releases.jsonl`, sorted: the port's root directories that hold one. */
63
+ export async function listReleaseEnvironmentsOn(port: LedgerReadPort): Promise<string[]> {
64
+ const envs: string[] = [];
65
+ for (const name of await port.trees()) {
66
+ if (await port.read(name, "releases.jsonl")) envs.push(name);
67
+ }
68
+ return envs.sort();
69
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The plans an op-waves `pr-review` wave records for a pull request's head
3
+ * (#3684, #3865): `_wave-plans/<name>/<head>.json` on `chant/lifecycle`,
4
+ * written by `chant run wave --record-plans` (`../op/op-waves-run.ts`) and read
5
+ * after the merge, where the review of that head approves the digests it
6
+ * planned and only those (`../op/gate-review.ts`).
7
+ *
8
+ * The parser and the readers here touch no git, no clock and no file: they
9
+ * take a {@link LedgerReadPort}, so a product that shows or checks the
10
+ * records bundles them in one file (`wave-plans-bundle.test.ts` holds the
11
+ * import graph). `gitLedgerReadPort({ member })` from `./git.ts` reads a
12
+ * workspace member's records (`_members/<member>/_wave-plans/...`) from a
13
+ * checkout.
14
+ */
15
+ import type { LedgerReadPort } from "./ledger-port";
16
+
17
+ /** The digests each wave planned at one commit, recorded for a review of it to approve. */
18
+ export interface HeadPlans {
19
+ version: 1;
20
+ /** The Op waves spec's name. */
21
+ name: string;
22
+ /** The commit the waves were planned at: a pull request's head. */
23
+ head: string;
24
+ timestamp: string;
25
+ waves: Array<{ wave: number; name: string; digest: string }>;
26
+ }
27
+
28
+ /** Where {@link HeadPlans} for `head` live on `chant/lifecycle`: `_wave-plans/<name>/<head>.json`. */
29
+ export const HEAD_PLANS_DIR = "_wave-plans";
30
+
31
+ export function headPlansPath(name: string, head: string): { dir: string; file: string } {
32
+ return { dir: `${HEAD_PLANS_DIR}/${name}`, file: `${head}.json` };
33
+ }
34
+
35
+ /**
36
+ * Parse one record. Null when the text is not JSON or not a version 1 plans
37
+ * record: a record that cannot be read approves nothing.
38
+ */
39
+ export function parseHeadPlans(text: string | null): HeadPlans | null {
40
+ if (!text) return null;
41
+ let value: unknown;
42
+ try {
43
+ value = JSON.parse(text);
44
+ } catch {
45
+ return null;
46
+ }
47
+ if (value === null || typeof value !== "object") return null;
48
+ const r = value as Record<string, unknown>;
49
+ if (r.version !== 1 || typeof r.name !== "string" || typeof r.head !== "string" || typeof r.timestamp !== "string" || !Array.isArray(r.waves)) return null;
50
+ const waves: HeadPlans["waves"] = [];
51
+ for (const w of r.waves) {
52
+ if (w === null || typeof w !== "object") return null;
53
+ const { wave, name, digest } = w as Record<string, unknown>;
54
+ if (typeof wave !== "number" || typeof name !== "string" || typeof digest !== "string") return null;
55
+ waves.push({ wave, name, digest });
56
+ }
57
+ return { version: 1, name: r.name, head: r.head, timestamp: r.timestamp, waves };
58
+ }
59
+
60
+ /** The record for `head` of the spec `name`, or null when none was recorded or it cannot be read. */
61
+ export async function readHeadPlans(port: LedgerReadPort, name: string, head: string): Promise<HeadPlans | null> {
62
+ const { dir, file } = headPlansPath(name, head);
63
+ return parseHeadPlans(await port.read(dir, file));
64
+ }
65
+
66
+ /** Every record of the spec `name` by head. A file that is not a record is left out. */
67
+ export async function readAllHeadPlans(port: LedgerReadPort, name: string): Promise<Map<string, HeadPlans>> {
68
+ const out = new Map<string, HeadPlans>();
69
+ const { dir } = headPlansPath(name, "");
70
+ for (const file of (await port.list(dir)).sort()) {
71
+ if (!file.endsWith(".json")) continue;
72
+ const plans = parseHeadPlans(await port.read(dir, file));
73
+ if (plans) out.set(file.slice(0, -".json".length), plans);
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /** The digests a record holds by wave number. */
79
+ export function waveDigests(plans: HeadPlans): Map<number, string> {
80
+ return new Map(plans.waves.map((w) => [w.wave, w.digest]));
81
+ }
82
+
83
+ /** The digests recorded for each head of the spec `name`: head, then wave number, to digest. */
84
+ export async function readHeadPlanDigests(port: LedgerReadPort, name: string): Promise<Map<string, Map<number, string>>> {
85
+ const out = new Map<string, Map<number, string>>();
86
+ for (const [head, plans] of await readAllHeadPlans(port, name)) out.set(head, waveDigests(plans));
87
+ return out;
88
+ }
89
+
90
+ /** The names of the specs that have records: the directories under `_wave-plans/`. */
91
+ export async function listWavePlanSpecs(port: LedgerReadPort): Promise<string[]> {
92
+ return (await port.list(HEAD_PLANS_DIR)).sort();
93
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The TypeScript compiler API chant parses with, for lexicon lint rules and
3
+ * language services (#3864).
4
+ *
5
+ * Core parses every source file with its own `typescript` dependency, which
6
+ * stays on 5.x. A lexicon that imported `typescript` itself got the project's
7
+ * copy instead, and a project on TypeScript 7 has no JS compiler API there:
8
+ * `ts.isImportDeclaration` is undefined and every rule threw. Even when two
9
+ * 5.x copies load, their `SyntaxKind` numbers can differ between minors, so a
10
+ * rule must test nodes with the same module that built them.
11
+ *
12
+ * Lexicons import `ts` from here, never from "typescript":
13
+ *
14
+ * import { ts } from "@intentius/chant/lint/typescript";
15
+ */
16
+ import * as ts from "typescript";
17
+
18
+ export { ts };