@intentius/chant 0.124.48 → 0.124.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/dist/cli/handlers/approve.d.ts.map +1 -1
  2. package/dist/cli/handlers/approve.js +20 -12
  3. package/dist/cli/handlers/graph-reader.d.ts +26 -0
  4. package/dist/cli/handlers/graph-reader.d.ts.map +1 -0
  5. package/dist/cli/handlers/graph-reader.js +41 -0
  6. package/dist/cli/handlers/graph.d.ts.map +1 -1
  7. package/dist/cli/handlers/graph.js +14 -0
  8. package/dist/cli/handlers/pr.d.ts.map +1 -1
  9. package/dist/cli/handlers/pr.js +40 -1
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/main.js +22 -0
  12. package/dist/cli/parse-args.d.ts.map +1 -1
  13. package/dist/cli/parse-args.js +11 -0
  14. package/dist/cli/registry.d.ts +8 -0
  15. package/dist/cli/registry.d.ts.map +1 -1
  16. package/dist/components/pr-run.d.ts +3 -0
  17. package/dist/components/pr-run.d.ts.map +1 -1
  18. package/dist/components/pr-run.js +1 -0
  19. package/dist/config.d.ts +16 -0
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js +4 -0
  22. package/dist/gate-decision.d.ts +9 -0
  23. package/dist/gate-decision.d.ts.map +1 -1
  24. package/dist/gate-decision.js +22 -7
  25. package/dist/lexicon.d.ts +23 -0
  26. package/dist/lexicon.d.ts.map +1 -1
  27. package/dist/op/activities/activity-contracts.d.ts +4 -4
  28. package/dist/op/gate-approval-base.d.ts +25 -0
  29. package/dist/op/gate-approval-base.d.ts.map +1 -0
  30. package/dist/op/gate-approval-base.js +71 -0
  31. package/dist/op/gate-approval.d.ts +7 -4
  32. package/dist/op/gate-approval.d.ts.map +1 -1
  33. package/dist/op/gate-approval.js +2 -25
  34. package/dist/op/gate-name.d.ts +13 -0
  35. package/dist/op/gate-name.d.ts.map +1 -1
  36. package/dist/op/gate-name.js +14 -1
  37. package/dist/op/gate.d.ts +4 -3
  38. package/dist/op/gate.d.ts.map +1 -1
  39. package/dist/op/local-executor.d.ts +9 -2
  40. package/dist/op/local-executor.d.ts.map +1 -1
  41. package/dist/op/local-executor.js +24 -5
  42. package/dist/pr-loop.d.ts +10 -0
  43. package/dist/pr-loop.d.ts.map +1 -1
  44. package/dist/pr-loop.js +4 -0
  45. package/dist/pr-migrations.d.ts +62 -0
  46. package/dist/pr-migrations.d.ts.map +1 -0
  47. package/dist/pr-migrations.js +50 -0
  48. package/dist/pr-post.js +1 -1
  49. package/dist/wave-runtime.mjs +219 -218
  50. package/dist/workspace/check.schema.json +4 -2
  51. package/dist/workspace/checks/gate-approval.d.ts +58 -0
  52. package/dist/workspace/checks/gate-approval.d.ts.map +1 -0
  53. package/dist/workspace/checks/gate-approval.js +103 -0
  54. package/dist/workspace/checks.d.ts +1 -0
  55. package/dist/workspace/checks.d.ts.map +1 -1
  56. package/dist/workspace/checks.js +4 -0
  57. package/dist/workspace/declaration.d.ts +7 -0
  58. package/dist/workspace/declaration.d.ts.map +1 -1
  59. package/dist/workspace/declaration.js +13 -1
  60. package/dist/workspace/declaration.schema.json +33 -0
  61. package/dist/workspace/exit-cli.d.ts +44 -0
  62. package/dist/workspace/exit-cli.d.ts.map +1 -0
  63. package/dist/workspace/exit-cli.js +198 -0
  64. package/dist/workspace/exit.d.ts +159 -0
  65. package/dist/workspace/exit.d.ts.map +1 -0
  66. package/dist/workspace/exit.js +480 -0
  67. package/dist/workspace/exit.schema.json +257 -0
  68. package/dist/workspace/graph-cache.d.ts +3 -1
  69. package/dist/workspace/graph-cache.d.ts.map +1 -1
  70. package/dist/workspace/graph-cache.js +12 -1
  71. package/dist/workspace/import-fetch.d.ts +38 -0
  72. package/dist/workspace/import-fetch.d.ts.map +1 -0
  73. package/dist/workspace/import-fetch.js +99 -0
  74. package/dist/workspace/import-init.d.ts +26 -0
  75. package/dist/workspace/import-init.d.ts.map +1 -0
  76. package/dist/workspace/import-init.js +130 -0
  77. package/dist/workspace/import-source.d.ts +147 -0
  78. package/dist/workspace/import-source.d.ts.map +1 -0
  79. package/dist/workspace/import-source.js +370 -0
  80. package/dist/workspace/init.d.ts +4 -0
  81. package/dist/workspace/init.d.ts.map +1 -1
  82. package/dist/workspace/init.js +70 -3
  83. package/dist/workspace/kind-readers.d.ts +6 -0
  84. package/dist/workspace/kind-readers.d.ts.map +1 -1
  85. package/dist/workspace/kind-readers.js +18 -0
  86. package/dist/workspace/kinds.d.ts +24 -1
  87. package/dist/workspace/kinds.d.ts.map +1 -1
  88. package/dist/workspace/kinds.js +64 -4
  89. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  90. package/dist/workspace/lineage-upgrade.js +13 -4
  91. package/dist/workspace/new-migrations.d.ts +47 -0
  92. package/dist/workspace/new-migrations.d.ts.map +1 -0
  93. package/dist/workspace/new-migrations.js +91 -0
  94. package/dist/workspace/pr-report.schema.json +46 -0
  95. package/dist/workspace/promote.d.ts +96 -0
  96. package/dist/workspace/promote.d.ts.map +1 -0
  97. package/dist/workspace/promote.js +263 -0
  98. package/dist/workspace/reason-codes.d.ts +13 -0
  99. package/dist/workspace/reason-codes.d.ts.map +1 -1
  100. package/dist/workspace/reason-codes.js +14 -0
  101. package/dist/workspace/search-contract.d.ts +1 -1
  102. package/dist/workspace/search-contract.d.ts.map +1 -1
  103. package/dist/workspace/workspace-kinds.schema.json +50 -7
  104. package/package.json +1 -1
  105. package/src/cli/handlers/approve.ts +10 -2
  106. package/src/cli/handlers/graph-reader.test.ts +31 -0
  107. package/src/cli/handlers/graph-reader.ts +51 -0
  108. package/src/cli/handlers/graph.ts +14 -0
  109. package/src/cli/handlers/pr.ts +38 -1
  110. package/src/cli/main.test.ts +2 -0
  111. package/src/cli/main.ts +22 -0
  112. package/src/cli/parse-args.ts +8 -0
  113. package/src/cli/registry.ts +8 -0
  114. package/src/components/pr-env-run-bundle.test.ts +1 -0
  115. package/src/components/pr-run.ts +4 -0
  116. package/src/config.ts +16 -0
  117. package/src/gate-decision-bundle.test.ts +4 -2
  118. package/src/gate-decision-identity.test.ts +11 -0
  119. package/src/gate-decision.ts +26 -6
  120. package/src/lexicon.ts +24 -0
  121. package/src/op/gate-approval-at-base.test.ts +97 -0
  122. package/src/op/gate-approval-base.ts +72 -0
  123. package/src/op/gate-approval.ts +10 -21
  124. package/src/op/gate-name.ts +16 -1
  125. package/src/op/gate.ts +4 -3
  126. package/src/op/local-executor.ts +25 -5
  127. package/src/pr-loop-bundle.test.ts +3 -2
  128. package/src/pr-loop.test.ts +52 -0
  129. package/src/pr-loop.ts +12 -0
  130. package/src/pr-migrations.ts +97 -0
  131. package/src/pr-post.ts +1 -1
  132. package/src/workspace/__fixtures__/import/spacelift.json +9 -0
  133. package/src/workspace/__fixtures__/import/tfc.json +13 -0
  134. package/src/workspace/behold-kinds.test.ts +1 -0
  135. package/src/workspace/check-contract.test.ts +3 -2
  136. package/src/workspace/check.schema.json +4 -2
  137. package/src/workspace/checks/gate-approval.test.ts +77 -0
  138. package/src/workspace/checks/gate-approval.ts +121 -0
  139. package/src/workspace/checks.ts +4 -0
  140. package/src/workspace/declaration.schema.json +33 -0
  141. package/src/workspace/declaration.ts +19 -2
  142. package/src/workspace/exit-cli.ts +219 -0
  143. package/src/workspace/exit.schema.json +257 -0
  144. package/src/workspace/exit.test.ts +185 -0
  145. package/src/workspace/exit.ts +560 -0
  146. package/src/workspace/graph-cache.ts +12 -2
  147. package/src/workspace/identity.test.ts +18 -3
  148. package/src/workspace/import-fetch.ts +151 -0
  149. package/src/workspace/import-init.ts +147 -0
  150. package/src/workspace/import-source.test.ts +183 -0
  151. package/src/workspace/import-source.ts +462 -0
  152. package/src/workspace/init.test.ts +55 -1
  153. package/src/workspace/init.ts +66 -4
  154. package/src/workspace/kind-readers.test.ts +36 -0
  155. package/src/workspace/kind-readers.ts +18 -1
  156. package/src/workspace/kinds.test.ts +23 -0
  157. package/src/workspace/kinds.ts +81 -5
  158. package/src/workspace/lineage-upgrade.test.ts +20 -0
  159. package/src/workspace/lineage-upgrade.ts +16 -7
  160. package/src/workspace/new-migrations.test.ts +98 -0
  161. package/src/workspace/new-migrations.ts +109 -0
  162. package/src/workspace/pr-report.schema.json +46 -0
  163. package/src/workspace/promote.test.ts +114 -0
  164. package/src/workspace/promote.ts +302 -0
  165. package/src/workspace/reason-codes.test.ts +4 -0
  166. package/src/workspace/reason-codes.ts +14 -0
  167. package/src/workspace/workspace-kinds.schema.json +50 -7
@@ -132,3 +132,39 @@ describe("workspace graph reads a package kind with a graph block (#2874)", () =
132
132
  expect(planMembers("graph", root, { kinds: registry }).groups[0].units[0].reader).toMatchObject({ lexicon: "fake" });
133
133
  });
134
134
  });
135
+
136
+ describe("a graph reader module from a package that is not a lexicon (#4413)", () => {
137
+ /** A fake chant that prints the reader project's config, so the test sees what the member's chant is handed. */
138
+ const MODULE_CHANT = `#!/bin/sh
139
+ [ "$1" = graph ] || { echo "Error: Unknown command: $1" >&2; exit 1; }
140
+ printf '{"version":1,"nodes":[{"id":"org","kind":"Org","lexicon":"gov","attrs":{"config":'
141
+ cat chant.config.json
142
+ printf ',"linked":%s}}],"edges":[],"groups":{}}\\n' "$([ -e node_modules ] && echo true || echo false)"
143
+ `;
144
+
145
+ test("the reader project names the module and links no lexicon", async () => {
146
+ const root = repo({
147
+ "chant.workspace.json": declaration([{ name: "forge", dir: "gov", kind: "gov" }], { pins: [{ package: "@acme/warden", version: "1.0.0" }] }),
148
+ "gov/governance.yml": "org: acme\n",
149
+ ".gitignore": "node_modules\n",
150
+ "node_modules/.bin/chant": { text: MODULE_CHANT, mode: 0o755 },
151
+ "node_modules/@acme/warden/package.json": JSON.stringify({ name: "@acme/warden", version: "1.0.0", exports: { "./workspace-kinds": "./workspace-kinds.json" } }),
152
+ "node_modules/@acme/warden/graph.mjs": "export default () => ({ nodes: [], edges: [] });\n",
153
+ "node_modules/@acme/warden/workspace-kinds.json": JSON.stringify({
154
+ schema: 1,
155
+ kinds: [{ name: "gov", description: "governance", precedence: 500, probe: { anyFile: ["governance.yml"] }, graph: { reader: "graph.mjs", config: { dir: "{dir}" } } }],
156
+ }),
157
+ });
158
+ const { doc, failed } = await workspaceGraph({ cwd: root });
159
+ const g = result(doc);
160
+ expectValid(g);
161
+ expect(failed).toBe(false);
162
+ expect(g.members.map((m) => [m.name, m.status])).toEqual([["forge", "composed"]]);
163
+ const attrs = g.nodes[0].attrs as { config: Record<string, unknown>; linked: boolean };
164
+ expect(attrs.config).toEqual({
165
+ lexicons: [],
166
+ graph: { lint: "report", reader: { module: join(root, "node_modules/@acme/warden/graph.mjs"), config: { dir: `${root}/gov` } } },
167
+ });
168
+ expect(attrs.linked).toBe(false);
169
+ });
170
+ });
@@ -20,6 +20,12 @@
20
20
  * package, found from the workspace root as the kind's pin was. The lexicon
21
21
  * chant loads is then the one the declaration pins.
22
22
  *
23
+ * A kind whose `graph` names a `reader` module instead (#4413) gets a
24
+ * project with `lexicons: []` and `graph.reader` set to the module, which the
25
+ * supplying package ships, so any package can supply one. The member's own
26
+ * `chant graph` loads it there, under the member's toolchain, and prints the
27
+ * IR it returns. There is no lexicon to link.
28
+ *
23
29
  * Nothing is written under the workspace, and the projects are removed when
24
30
  * the read ends. A lexicon that can't be found where its pin says fails the
25
31
  * member with `command-failed` and says what to install; the other members
@@ -57,6 +63,7 @@ function real(path: string): string {
57
63
  /** Why a unit's lexicon can't be loaded from `root`, or the `node_modules` to link. */
58
64
  function lexiconModules(unit: RunUnit, root: string): { modules: string } | { message: string } {
59
65
  const reader = unit.reader!;
66
+ if (!reader.lexicon) return { message: `kind ${unit.kind} names no lexicon` };
60
67
  const name = lexiconPackageName(reader.lexicon);
61
68
  const installed = findInstalledPackage(name, root);
62
69
  if (!installed) {
@@ -89,6 +96,16 @@ export function prepareKindReaders(plan: MemberPlan, root: string): PreparedRead
89
96
  for (const group of plan.groups) {
90
97
  group.units = group.units.filter((unit) => {
91
98
  if (!unit.reader) return true;
99
+ if (unit.reader.readerFile) {
100
+ // A graph reader module (#4413): no lexicon and no node_modules link. The module sits in the pinned package and resolves its own imports from there.
101
+ scratch ??= mkdtempSync(join(tmpdir(), "chant-ws-reader-"));
102
+ const project = join(scratch, unit.id);
103
+ mkdirSync(project, { recursive: true });
104
+ const config = substituteGraphConfig(unit.reader.config, { member: unit.member, dir: unit.abs, workspace: root });
105
+ writeFileSync(join(project, "chant.config.json"), `${JSON.stringify({ lexicons: [], graph: { lint: "report", reader: { module: unit.reader.readerFile, config } } }, null, 2)}\n`);
106
+ unit.abs = project;
107
+ return true;
108
+ }
92
109
  const found = lexiconModules(unit, root);
93
110
  if ("message" in found) {
94
111
  failed.push({ name: unit.member, dir: unit.dir, kind: unit.kind, reason: { code: "command-failed", message: found.message } });
@@ -101,7 +118,7 @@ export function prepareKindReaders(plan: MemberPlan, root: string): PreparedRead
101
118
  // `graph.lint: "report"` (behold#919): chant did not author a reader
102
119
  // member's source, so a lint error there is a finding about it, carried
103
120
  // on the member's `meta.lint`, not a reason to leave it out of the graph.
104
- writeFileSync(join(project, "chant.config.json"), `${JSON.stringify({ lexicons: [unit.reader.lexicon], [unit.reader.lexicon]: config, graph: { lint: "report" } }, null, 2)}\n`);
121
+ writeFileSync(join(project, "chant.config.json"), `${JSON.stringify({ lexicons: [unit.reader.lexicon!], [unit.reader.lexicon!]: config, graph: { lint: "report" } }, null, 2)}\n`);
105
122
  symlinkSync(found.modules, join(project, "node_modules"), "dir");
106
123
  unit.abs = project;
107
124
  return true;
@@ -155,6 +155,29 @@ describe("a kind's graph block (#2874)", () => {
155
155
  expect(bad.registry.get("other-tf")).toBeDefined();
156
156
  });
157
157
 
158
+ test("a graph reader module is accepted from a package that is not a lexicon, and only inside the package (#4413)", () => {
159
+ const reader = { reader: "dist/graph.mjs", config: { dir: "{dir}" } };
160
+ const root = dir({
161
+ ...pkg("@acme/warden", "1.0.0", [{ ...tf, name: "gov", graph: reader }]),
162
+ "node_modules/@acme/warden/dist/graph.mjs": "export default () => ({ nodes: [], edges: [] });\n",
163
+ ...pkg("@acme/gone", "1.0.0", [{ ...tf, name: "gone", graph: reader }]),
164
+ ...pkg("@acme/up", "1.0.0", [{ ...tf, name: "up", graph: { reader: "../x.mjs" } }]),
165
+ });
166
+ const good = loadKindRegistry([{ package: "@acme/warden", version: "1.0.0", path: null }], root);
167
+ expect(good.problems).toEqual([]);
168
+ expect(good.registry.get("gov")?.graph).toEqual({ ...reader, readerFile: join(root, "node_modules/@acme/warden/dist/graph.mjs") });
169
+ const gone = loadKindRegistry([{ package: "@acme/gone", version: "1.0.0", path: null }], root);
170
+ expect(gone.problems.map((p) => p.message)).toEqual([expect.stringMatching(/graph reader dist\/graph.mjs, which does not exist/)]);
171
+ expect(gone.registry.get("gone")?.graph).toBeUndefined();
172
+ const up = loadKindRegistry([{ package: "@acme/up", version: "1.0.0", path: null }], root);
173
+ expect(up.problems.length).toBe(1);
174
+ expect(up.registry.get("up")?.graph).toBeUndefined();
175
+ // Not both, and an absolute path is refused.
176
+ expect(parseKindData(kindsFile([{ ...tf, graph: { reader: "a.mjs", lexicon: "tf", config: {} } }]), "p").problems.length).toBeGreaterThan(0);
177
+ expect(parseKindData(kindsFile([{ ...tf, graph: { reader: "/etc/x.mjs" } }]), "p").problems.length).toBeGreaterThan(0);
178
+ expect(parseKindData(kindsFile([{ ...tf, graph: { reader: "a.mjs" } }]), "p").problems).toEqual([]);
179
+ });
180
+
158
181
  test("the terraform lexicon's kinds read their members with it, named for the member at the workspace root", () => {
159
182
  const lexicon = join(import.meta.dirname, "..", "..", "..", "..", "lexicons", "terraform");
160
183
  const read = readPackageKinds(lexicon);
@@ -17,7 +17,7 @@
17
17
  * fails ({@link resolveKind}).
18
18
  */
19
19
 
20
- import { readFileSync } from "node:fs";
20
+ import { existsSync, readFileSync } from "node:fs";
21
21
  import { isAbsolute, join, relative, resolve } from "node:path";
22
22
  import schema from "./workspace-kinds.schema.json" with { type: "json" };
23
23
  import type { WorkspaceTree } from "./tree";
@@ -63,6 +63,18 @@ export interface FileProbe {
63
63
  * directory of `.sql` files: its files carry `-- +goose Up`.
64
64
  */
65
65
  anyLine?: { in: string[]; starts: string[] };
66
+ /**
67
+ * A file directly in the directory whose name matches one of `in` parses
68
+ * as YAML or JSON (every document of a multi-document file is read), and
69
+ * one document satisfies one of the `match` entries. An entry maps JSON
70
+ * Pointers to the values allowed there, and a document satisfies it when
71
+ * every pointer holds a value that matches one of its patterns. A `*` in
72
+ * a pattern matches any run of characters, so `["*"]` means "has a
73
+ * value". This is how an Argo Application, a Flux Kustomization and a
74
+ * CloudFormation template are found by what they say, not by their file
75
+ * names (#3179). A file that doesn't parse is no evidence either way.
76
+ */
77
+ anyDocument?: { in: string[]; match: Record<string, string[]>[] };
66
78
  }
67
79
 
68
80
  /** A string, integer or boolean field a kind declares for its members (#3151). */
@@ -113,7 +125,15 @@ export type KindOutputs =
113
125
  * chant writes itself. See {@link substituteGraphConfig} for the placeholders.
114
126
  */
115
127
  export interface KindGraph {
116
- lexicon: string;
128
+ /** The lexicon that reads the member; unset when `reader` is. */
129
+ lexicon?: string;
130
+ /**
131
+ * A graph reader module (#4413), as the kinds file names it: a path inside
132
+ * the supplying package, which may be any package. Unset when `lexicon` is.
133
+ */
134
+ reader?: string;
135
+ /** The reader module, absolute; set once the package directory is known. */
136
+ readerFile?: string;
117
137
  config: Record<string, unknown>;
118
138
  }
119
139
 
@@ -280,7 +300,7 @@ export function probeKind(kind: MemberKind, tree: WorkspaceTree, dir: string): b
280
300
  if ("directory" in kind.probe) return true;
281
301
  if ("chantProject" in kind.probe) return holdsChantProject(tree, dir);
282
302
  if ("repo" in kind.probe) return true;
283
- const { anyFile, anyBlock, anyJsonKey, anyLine } = kind.probe;
303
+ const { anyFile, anyBlock, anyJsonKey, anyLine, anyDocument } = kind.probe;
284
304
  if (anyFile && filesMatching(tree, dir, anyFile).length > 0) return true;
285
305
  if (anyJsonKey) {
286
306
  for (const file of filesMatching(tree, dir, anyJsonKey.in)) {
@@ -302,6 +322,15 @@ export function probeKind(kind: MemberKind, tree: WorkspaceTree, dir: string): b
302
322
  }
303
323
  }
304
324
  }
325
+ if (anyDocument) {
326
+ for (const file of filesMatching(tree, dir, anyDocument.in)) {
327
+ try {
328
+ if (documentsOf(tree.read(file)).some((doc) => anyDocument.match.some((m) => documentMatches(doc, m)))) return true;
329
+ } catch {
330
+ // An unreadable file is no evidence either way.
331
+ }
332
+ }
333
+ }
305
334
  if (anyBlock) {
306
335
  const opener = new RegExp(`^\\s*(?:${anyBlock.blocks.map(escapeRegExp).join("|")})\\s*\\{`, "m");
307
336
  for (const file of filesMatching(tree, dir, anyBlock.in)) {
@@ -340,6 +369,37 @@ function filesMatching(tree: WorkspaceTree, dir: string, names: readonly string[
340
369
  return out.sort();
341
370
  }
342
371
 
372
+ /**
373
+ * The documents in `text`, read as YAML (which holds JSON). A CloudFormation
374
+ * short-form tag such as `!Ref` or `!Sub` is not YAML js-yaml knows, so a
375
+ * file that fails to parse is read again with those tags dropped. Text that
376
+ * still doesn't parse has no documents.
377
+ */
378
+ function documentsOf(text: string): unknown[] {
379
+ try {
380
+ return yaml.loadAll(text);
381
+ } catch {
382
+ try {
383
+ return yaml.loadAll(text.replace(/(^|[\s:\-[,{])!(?:[A-Za-z][\w]*:?)+/gm, "$1"));
384
+ } catch {
385
+ return [];
386
+ }
387
+ }
388
+ }
389
+
390
+ /** Whether every pointer in `match` holds a value matching one of its patterns. */
391
+ function documentMatches(doc: unknown, match: Record<string, string[]>): boolean {
392
+ const pointers = Object.entries(match);
393
+ return (
394
+ pointers.length > 0 &&
395
+ pointers.every(([pointer, patterns]) => {
396
+ const value = atPointer(doc, pointer);
397
+ if (value === undefined) return false;
398
+ return patterns.some((pattern) => new RegExp(`^${pattern.split("*").map(escapeRegExp).join(".*")}$`).test(String(value)));
399
+ })
400
+ );
401
+ }
402
+
343
403
  /** The value at JSON Pointer `pointer` in `json`, or undefined when there is none. */
344
404
  function atPointer(json: unknown, pointer: string): unknown {
345
405
  let at: unknown = json;
@@ -476,12 +536,15 @@ export function parseKindData(text: string, source: string): KindData {
476
536
  ...(k.probe.anyBlock ? { anyBlock: { in: [...k.probe.anyBlock.in], blocks: [...k.probe.anyBlock.blocks] } } : {}),
477
537
  ...(k.probe.anyJsonKey ? { anyJsonKey: { in: [...k.probe.anyJsonKey.in], pointers: [...k.probe.anyJsonKey.pointers] } } : {}),
478
538
  ...(k.probe.anyLine ? { anyLine: { in: [...k.probe.anyLine.in], starts: [...k.probe.anyLine.starts] } } : {}),
539
+ ...(k.probe.anyDocument ? { anyDocument: { in: [...k.probe.anyDocument.in], match: k.probe.anyDocument.match.map((m) => structuredClone(m)) } } : {}),
479
540
  },
480
541
  precedence: k.precedence,
481
542
  shape: "member",
482
543
  outputs: { from: "declared", names: [...(k.outputs ?? [])] },
483
544
  source,
484
- ...(k.graph ? { graph: { lexicon: k.graph.lexicon, config: structuredClone(k.graph.config) } } : {}),
545
+ ...(k.graph
546
+ ? { graph: { ...(k.graph.lexicon ? { lexicon: k.graph.lexicon } : {}), ...(k.graph.reader ? { reader: k.graph.reader } : {}), config: structuredClone(k.graph.config ?? {}) } }
547
+ : {}),
485
548
  ...(k.fields ? { fields: structuredClone(k.fields) } : {}),
486
549
  ...(k.reader && (KIND_LINK_READERS as readonly string[]).includes(k.reader) ? { reader: k.reader as KindLinkReaderName } : {}),
487
550
  ...(k.memberFile ? { memberFile: { path: k.memberFile.path, schema: k.memberFile.schema } } : {}),
@@ -541,7 +604,20 @@ export function readPackageKinds(packageDir: string, source?: string): PackageKi
541
604
  const problem = loadMemberFileSchema(k.memberFile, packageDir);
542
605
  if (problem) data.problems.push(`${name}: kind ${k.name} ${problem}; members of the kind fail WSP012 until it is fixed`);
543
606
  }
544
- if (k.graph && packageName !== lexiconPackageName(k.graph.lexicon)) {
607
+ if (k.graph?.reader) {
608
+ // A reader module is any package's own file (#4413): it must sit inside the package and exist. Nothing is imported here.
609
+ const file = resolve(packageDir, ...k.graph.reader.split("/"));
610
+ const inside = relative(packageDir, file);
611
+ if (inside.startsWith("..") || isAbsolute(inside)) {
612
+ data.problems.push(`${name}: kind ${k.name} names graph reader ${k.graph.reader}, which is outside the package; the kind is kept, and workspace graph lists its members with kind-not-run`);
613
+ delete k.graph;
614
+ } else if (!existsSync(file)) {
615
+ data.problems.push(`${name}: kind ${k.name} names graph reader ${k.graph.reader}, which does not exist in the package; the kind is kept, and workspace graph lists its members with kind-not-run`);
616
+ delete k.graph;
617
+ } else {
618
+ k.graph.readerFile = file;
619
+ }
620
+ } else if (k.graph?.lexicon && packageName !== lexiconPackageName(k.graph.lexicon)) {
545
621
  data.problems.push(
546
622
  `${name}: kind ${k.name} reads its members with lexicon ${k.graph.lexicon}, which is ${lexiconPackageName(k.graph.lexicon)}, ` +
547
623
  `and this package is ${packageName ?? "unnamed"}; the kind is kept, and workspace graph lists its members with kind-not-run`,
@@ -497,6 +497,26 @@ describe("chant workspace upgrade", () => {
497
497
  expect(port.pending[0].approval).toEqual({ mode: "log-only", quorum: { count: 2, roles: ["maintainer"] } });
498
498
  });
499
499
 
500
+ test("a gate's quorum at HEAD in identity.gates is the one asked for, and the patch can't lower it (#4404)", async () => {
501
+ // The Op's own quorum is 2 at HEAD and 1 in the upgrade; the declaration at HEAD asks 3 of the gate.
502
+ const op = (overview: string, count: number): string =>
503
+ `export default { props: { name: "release", overview: ${JSON.stringify(overview)}, phases: [{ name: "Ship", steps: [{ kind: "gate", gate: "ship", approval: { quorum: { count: ${count} } } }] }] } };\n`;
504
+ release("v1.1.0", {
505
+ "chant.workspace.json": JSON.stringify({ name: "proj", schema: 1, members: [{ name: "delivery", dir: "delivery", kind: "chant" }], identity: { gates: { ship: { quorum: { count: 3 } } } } }, null, 2) + "\n",
506
+ "delivery/chant.config.ts": "export default {};\n",
507
+ "delivery/ops/release.op.ts": op("v1", 2),
508
+ });
509
+ proj = join(root, "members-4404");
510
+ expect((await initFromCommand({ from: `${tpl}@v1.1.0`, path: proj })).error).toBeUndefined();
511
+ git(proj, ["init", "-q", "-b", "main"]);
512
+ commitProject("init from v1.1");
513
+ release("v2.0.0", { "delivery/ops/release.op.ts": op("v2", 1) });
514
+ const port = ledger();
515
+ const result = await upgradeCommand({ root: proj, to: "v2.0.0", runChant: passing, ledger: port, now: "2026-09-23T10:00:00.000Z" });
516
+ expect(result.outcome).toBe("gated");
517
+ expect(result.staged!.governance).toMatchObject({ approval: { mode: "log-only", quorum: { count: 3 } } });
518
+ });
519
+
500
520
  test("refuses a scope with uncommitted changes, and a scope that does not exist", async () => {
501
521
  put(proj, "README.md", "dirty\n");
502
522
  await expect(stageUpgrade({ root: proj, to: "v1.0.0" })).rejects.toThrow(/uncommitted changes/);
@@ -573,16 +573,25 @@ export function isGovernancePath(path: string): boolean {
573
573
  * one. The Op files are read from the project's tree, which the upgrade
574
574
  * requires to match HEAD for every path the patch touches.
575
575
  *
576
+ * Both come from HEAD, never from the patch: the Op files are read from the
577
+ * clean project tree, and the declaration through `identityGatesAt(repo,
578
+ * head)` (`git show`). A change the upgrade itself makes to an Op or to
579
+ * `chant.workspace.json` cannot lower the approvals it asks for (#4404). A
580
+ * gate whose `identity.gates.<gate>.quorum` at HEAD sets one is asked for that
581
+ * quorum, as a run of the gate is (#4324), not the Op's own.
582
+ *
576
583
  * Discovery runs from each changed Op file's own directory, so it finds the
577
584
  * file under the project that owns it. Run from a workspace root, it would
578
585
  * skip every member's Op files (#2892).
579
586
  */
580
- async function rulesAtHead(repo: string, paths: string[]): Promise<ResolvedGateApproval> {
587
+ async function rulesAtHead(repo: string, paths: string[], head: string): Promise<ResolvedGateApproval> {
581
588
  const opFiles = new Set(paths.filter((p) => GOVERNANCE[0].test(p)));
582
589
  let count = 1;
583
590
  let roles: string[] | undefined;
584
591
  if (opFiles.size > 0) {
585
592
  const { discoverOps } = await import("../op/discover");
593
+ const { identityGatesAt } = await import("../gate-decision");
594
+ const atBase = new Map(identityGatesAt(repo, head).map((g) => [g.gate, g.quorum]));
586
595
  const dirs = new Set([...opFiles].map((p) => dirname(join(repo, p))));
587
596
  for (const dir of dirs) {
588
597
  if (!existsSync(dir)) continue;
@@ -592,7 +601,7 @@ async function rulesAtHead(repo: string, paths: string[]): Promise<ResolvedGateA
592
601
  if (!opFiles.has(rel)) continue;
593
602
  // #4290: a quorum may be a list of rules; the strictest count is asked for.
594
603
  for (const approval of gateApprovals(config)) {
595
- for (const rule of quorumRules(approval.quorum)) {
604
+ for (const rule of quorumRules(atBase.get(approval.gate) ?? approval.quorum)) {
596
605
  if (rule.count > count) {
597
606
  count = rule.count;
598
607
  roles = rule.roles;
@@ -605,12 +614,12 @@ async function rulesAtHead(repo: string, paths: string[]): Promise<ResolvedGateA
605
614
  return { mode: "log-only", quorum: { count, ...(roles && roles.length > 0 ? { roles } : {}) } };
606
615
  }
607
616
 
608
- type StepLike = { kind: string; approval?: { quorum?: GateQuorumSpec }; steps?: StepLike[] };
617
+ type StepLike = { kind: string; gate?: string; signalName?: string; approval?: { quorum?: GateQuorumSpec }; steps?: StepLike[] };
609
618
 
610
- function gateApprovals(config: { phases?: Array<{ steps: StepLike[] }>; onFailure?: Array<{ steps: StepLike[] }> }): Array<{ quorum?: GateQuorumSpec }> {
611
- const out: Array<{ quorum?: GateQuorumSpec }> = [];
619
+ function gateApprovals(config: { phases?: Array<{ steps: StepLike[] }>; onFailure?: Array<{ steps: StepLike[] }> }): Array<{ gate: string; quorum?: GateQuorumSpec }> {
620
+ const out: Array<{ gate: string; quorum?: GateQuorumSpec }> = [];
612
621
  const visit = (step: StepLike): void => {
613
- if (step.kind === "gate") out.push(step.approval ?? {});
622
+ if (step.kind === "gate") out.push({ gate: step.gate ?? step.signalName ?? "", ...(step.approval?.quorum !== undefined ? { quorum: step.approval.quorum } : {}) });
614
623
  if (step.kind === "effect") (step.steps ?? []).forEach(visit);
615
624
  };
616
625
  for (const p of [...(config.phases ?? []), ...(config.onFailure ?? [])]) p.steps.forEach(visit);
@@ -865,7 +874,7 @@ export async function stageUpgrade(options: UpgradeOptions): Promise<StagedUpgra
865
874
  if (touched.length > 0) {
866
875
  throw new UpgradeError(`the upgrade changes governance files that have uncommitted edits (${touched.map((l) => l.slice(3)).join(", ")}). Commit them first: their gates are read at HEAD.`);
867
876
  }
868
- governance = { paths: governed, approval: await rulesAtHead(repo, governed), rules: head };
877
+ governance = { paths: governed, approval: await rulesAtHead(repo, governed, head), rules: head };
869
878
  }
870
879
 
871
880
  // 5. Checks.
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The new migration files of a pull request (#4407): which files count as
3
+ * new, and who classifies them. The trees are in memory; the classes come
4
+ * from the lexicon (`lexicons/sql/src/migration-classify.test.ts`).
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { LINK_READERS, type KindLinkRead } from "./kind-link-readers";
9
+ import { classifyNewMigrations, newMigrationGroups } from "./new-migrations";
10
+ import type { WorkspaceTree } from "./tree";
11
+
12
+ function memoryTree(files: Record<string, string>): WorkspaceTree {
13
+ const dirs = new Set([""]);
14
+ for (const f of Object.keys(files)) {
15
+ const parts = f.split("/");
16
+ for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join("/"));
17
+ }
18
+ return {
19
+ label: "",
20
+ stat: (p) => (p in files ? "file" : dirs.has(p) ? "dir" : undefined),
21
+ list(p) {
22
+ if (!dirs.has(p)) return undefined;
23
+ const prefix = p === "" ? "" : `${p}/`;
24
+ const seen = new Map<string, "file" | "dir">();
25
+ for (const f of Object.keys(files)) {
26
+ if (!f.startsWith(prefix)) continue;
27
+ const rest = f.slice(prefix.length);
28
+ seen.set(rest.split("/")[0]!, rest.includes("/") ? "dir" : "file");
29
+ }
30
+ return [...seen].map(([name, type]) => ({ name, type }));
31
+ },
32
+ read(p) {
33
+ if (!(p in files)) throw new Error(`no such file ${p}`);
34
+ return files[p]!;
35
+ },
36
+ };
37
+ }
38
+
39
+ const GOOSE = (sql: string) => `-- +goose Up\n${sql}\n-- +goose Down\nSELECT 1;\n`;
40
+ const baseFiles = { "db/00001_init.sql": GOOSE("CREATE TABLE t (id bigint);") };
41
+ const headFiles = { ...baseFiles, "db/00002_idx.sql": GOOSE("CREATE INDEX i ON t (id);") };
42
+
43
+ const readGoose = (tree: WorkspaceTree): Map<string, KindLinkRead> =>
44
+ new Map([["ledger", LINK_READERS["goose-dir"]({ member: { name: "ledger" } as never, dir: "db", fields: { dialect: "postgres" }, tree })]]);
45
+ const members = [{ name: "ledger", dir: "db" }];
46
+
47
+ describe("newMigrationGroups", () => {
48
+ test("keeps the files the base lacks, in apply order", () => {
49
+ const groups = newMigrationGroups(members, readGoose(memoryTree(headFiles)), memoryTree(baseFiles));
50
+ expect(groups).toHaveLength(1);
51
+ expect(groups[0]).toMatchObject({ member: "ledger", dir: "db", reader: "goose-dir", dialect: "postgres" });
52
+ expect(groups[0]!.migrations.map((m) => m.file)).toEqual(["db/00002_idx.sql"]);
53
+ });
54
+
55
+ test("a changed file that the base has is not new", () => {
56
+ const head = { ...headFiles, "db/00001_init.sql": GOOSE("CREATE TABLE t (id bigint, a int);") };
57
+ expect(newMigrationGroups(members, readGoose(memoryTree(head)), memoryTree(baseFiles)).flatMap((g) => g.migrations.map((m) => m.file))).toEqual(["db/00002_idx.sql"]);
58
+ });
59
+
60
+ test("nothing new, no group; no base tree means every migration is new; --member limits", () => {
61
+ expect(newMigrationGroups(members, readGoose(memoryTree(baseFiles)), memoryTree(baseFiles))).toEqual([]);
62
+ expect(newMigrationGroups(members, readGoose(memoryTree(headFiles)), undefined)[0]!.migrations).toHaveLength(2);
63
+ expect(newMigrationGroups(members, readGoose(memoryTree(headFiles)), memoryTree(baseFiles), "other")).toEqual([]);
64
+ });
65
+ });
66
+
67
+ describe("classifyNewMigrations", () => {
68
+ const groups = () => newMigrationGroups(members, readGoose(memoryTree(headFiles)), memoryTree(baseFiles));
69
+
70
+ test("asks the lexicon for the new files only, with the head tree and the member's directory", async () => {
71
+ const asked: unknown[] = [];
72
+ const out = await classifyNewMigrations(groups(), memoryTree(headFiles), [
73
+ {
74
+ name: "fake",
75
+ classifyMigrations(o) {
76
+ asked.push({ reader: o.reader, dialect: o.dialect, dir: o.dir, files: o.files });
77
+ return o.files.map((file) => ({ file, statements: [] }));
78
+ },
79
+ },
80
+ ]);
81
+ expect(asked).toEqual([{ reader: "goose-dir", dialect: "postgres", dir: "db", files: ["db/00002_idx.sql"] }]);
82
+ expect(out).toMatchObject([{ member: "ledger", reader: "goose-dir", version: "2", name: "idx", type: "versioned", dialect: "postgres", file: "db/00002_idx.sql" }]);
83
+ });
84
+
85
+ test("a file no lexicon answers for is listed with the reason, and a throwing lexicon does not stop the note", async () => {
86
+ const none = await classifyNewMigrations(groups(), memoryTree(headFiles), []);
87
+ expect(none[0]!.error).toBe("no installed lexicon classifies postgres migrations");
88
+ const threw = await classifyNewMigrations(groups(), memoryTree(headFiles), [
89
+ {
90
+ name: "fake",
91
+ classifyMigrations() {
92
+ throw new Error("boom");
93
+ },
94
+ },
95
+ ]);
96
+ expect(threw[0]!.error).toBe("the fake lexicon could not classify it: boom");
97
+ });
98
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The migration files a pull request adds to a migration-directory workspace
3
+ * member (#4407), for the plan note. Reads the member's migrations at the
4
+ * pull request's head with the kind's own reader (`./kind-link-readers.ts`),
5
+ * keeps the files the base commit lacks, and asks a lexicon to classify
6
+ * them (`LexiconPlugin.classifyMigrations`). It reads git objects and files
7
+ * only: nothing is applied, no server is contacted, and no tool runs.
8
+ */
9
+
10
+ import type { LexiconPlugin } from "../lexicon";
11
+ import type { MigrationFileClassification, PrMigrationFile } from "../pr-migrations";
12
+ import { readDeclaration } from "./declaration";
13
+ import { readKindLinks, type KindLinkRead, type KindReadMigration } from "./kind-link-readers";
14
+ import { loadKindRegistry } from "./kinds";
15
+ import type { WorkspaceTree } from "./tree";
16
+ import { locateWorkspace } from "./which-chant";
17
+
18
+ /** One member's new migrations. */
19
+ export interface NewMigrationGroup {
20
+ member: string;
21
+ /** The member's directory, tree-relative; "" for the workspace root. */
22
+ dir: string;
23
+ reader: string;
24
+ dialect: string;
25
+ /** The new files, in the order the tool applies them. */
26
+ migrations: KindReadMigration[];
27
+ }
28
+
29
+ /**
30
+ * Per member, the migrations whose file is not in `base`. An `undo` file is
31
+ * not an up migration and is left out. With no `base` tree (the workspace did
32
+ * not exist there) every migration is new. `only` limits the answer to one
33
+ * member, for a member's own pipeline.
34
+ */
35
+ export function newMigrationGroups(
36
+ members: readonly { name: string; dir: string }[],
37
+ read: ReadonlyMap<string, KindLinkRead>,
38
+ base: WorkspaceTree | undefined,
39
+ only?: string,
40
+ ): NewMigrationGroup[] {
41
+ const out: NewMigrationGroup[] = [];
42
+ for (const m of members) {
43
+ if (only !== undefined && m.name !== only) continue;
44
+ const r = read.get(m.name);
45
+ if (!r?.migrations) continue;
46
+ const added = r.migrations.filter((mig) => mig.type !== "undo" && base?.stat(mig.file) !== "file");
47
+ if (added.length === 0) continue;
48
+ out.push({ member: m.name, dir: m.dir === "." ? "" : m.dir, reader: r.reader, dialect: added[0]!.dialect, migrations: added });
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * Classify the new files with the first lexicon that answers each. A file no
55
+ * lexicon answers for comes back with an `error` that says which dialect, so
56
+ * the note shows it instead of leaving it out.
57
+ */
58
+ export async function classifyNewMigrations(groups: readonly NewMigrationGroup[], head: WorkspaceTree, plugins: readonly Pick<LexiconPlugin, "name" | "classifyMigrations">[]): Promise<PrMigrationFile[]> {
59
+ const out: PrMigrationFile[] = [];
60
+ for (const g of groups) {
61
+ const files = g.migrations.map((m) => m.file);
62
+ const answers = new Map<string, MigrationFileClassification>();
63
+ for (const p of plugins) {
64
+ if (!p.classifyMigrations || answers.size === files.length) continue;
65
+ const left = files.filter((f) => !answers.has(f));
66
+ try {
67
+ for (const a of await p.classifyMigrations({ reader: g.reader, dialect: g.dialect, tree: head, dir: g.dir, files: left })) answers.set(a.file, a);
68
+ } catch (err) {
69
+ for (const f of left) answers.set(f, { file: f, statements: [], error: `the ${p.name} lexicon could not classify it: ${err instanceof Error ? err.message : String(err)}` });
70
+ }
71
+ }
72
+ for (const m of g.migrations) {
73
+ const a = answers.get(m.file) ?? { file: m.file, statements: [], error: `no installed lexicon classifies ${g.dialect} migrations` };
74
+ out.push({ member: g.member, reader: g.reader, version: m.version, name: m.name, type: m.type, dialect: m.dialect, ...a });
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /**
81
+ * The new migration files of the workspace holding `cwd`, between `base` and
82
+ * `head`. Empty outside a workspace or when the declaration cannot be read.
83
+ */
84
+ export async function newMigrationFilesBetween(
85
+ cwd: string,
86
+ base: string,
87
+ head: string,
88
+ options: { member?: string; plugins: () => Promise<readonly Pick<LexiconPlugin, "name" | "classifyMigrations">[]> },
89
+ ): Promise<PrMigrationFile[]> {
90
+ let located;
91
+ let declaration;
92
+ try {
93
+ located = locateWorkspace(cwd, head);
94
+ declaration = readDeclaration(located.tree, "", { rootChant: true });
95
+ } catch {
96
+ return [];
97
+ }
98
+ const { registry } = loadKindRegistry(declaration.pins, located.rootOnDisk);
99
+ const read = readKindLinks(declaration, located.tree, registry);
100
+ let baseTree: WorkspaceTree | undefined;
101
+ try {
102
+ baseTree = locateWorkspace(cwd, base).tree;
103
+ } catch {
104
+ baseTree = undefined;
105
+ }
106
+ const groups = newMigrationGroups(declaration.members, read, baseTree, options.member);
107
+ if (groups.length === 0) return [];
108
+ return classifyNewMigrations(groups, located.tree, await options.plugins());
109
+ }
@@ -92,6 +92,39 @@
92
92
  }
93
93
  }
94
94
  },
95
+ "migrations": {
96
+ "type": "array",
97
+ "description": "Added within contract version 1 by #4407, written by chant 0.124.49 and newer. Plan stage: the migration files the pull request adds in a migration-directory workspace member (goose, golang-migrate, clickhouse-migrations, Flyway), each statement with the class the lexicon's classifier gives it, read offline. Absent when the pull request adds none.",
98
+ "items": {
99
+ "type": "object",
100
+ "required": ["member", "reader", "file", "version", "name", "type", "dialect", "statements"],
101
+ "properties": {
102
+ "member": { "type": "string" },
103
+ "reader": { "type": "string" },
104
+ "file": { "type": "string", "description": "Relative to the workspace root." },
105
+ "version": { "type": "string" },
106
+ "name": { "type": "string" },
107
+ "type": { "enum": ["versioned", "repeatable", "baseline", "undo"] },
108
+ "dialect": { "type": "string" },
109
+ "error": { "type": "string", "description": "Why the file could not be read; its statements are then empty." },
110
+ "statements": {
111
+ "type": "array",
112
+ "items": {
113
+ "type": "object",
114
+ "required": ["index", "sql", "outcome", "changes"],
115
+ "properties": {
116
+ "index": { "type": "integer", "minimum": 1 },
117
+ "sql": { "type": "string" },
118
+ "outcome": { "enum": ["classified", "undecided", "unclassified", "data"] },
119
+ "changes": { "type": "array", "items": { "$ref": "#/$defs/migrationChange" } },
120
+ "candidates": { "type": "array", "items": { "$ref": "#/$defs/migrationChange" }, "description": "With undecided: the rules that could apply." },
121
+ "note": { "type": "string" }
122
+ }
123
+ }
124
+ }
125
+ }
126
+ }
127
+ },
95
128
  "plannedOutputs": {
96
129
  "type": "object",
97
130
  "description": "Added within contract version 1 by #3999, written by chant 0.124.32 and newer. Per dependency in the change that a member was planned against, the names of the outputs its plan knows, sorted. An output a member reads that is not listed is known only after apply, and the member lists it under unknownInputs. Absent when no member read one.",
@@ -258,6 +291,19 @@
258
291
  }
259
292
  },
260
293
  "$defs": {
294
+ "migrationChange": {
295
+ "type": "object",
296
+ "required": ["rule", "class", "label", "title"],
297
+ "properties": {
298
+ "rule": { "type": "string" },
299
+ "class": { "type": "string" },
300
+ "label": { "type": "string" },
301
+ "title": { "type": "string" },
302
+ "lock": { "type": "string" },
303
+ "destructive": { "type": "boolean" },
304
+ "note": { "type": "string" }
305
+ }
306
+ },
261
307
  "policyFinding": {
262
308
  "type": "object",
263
309
  "required": ["rule", "message"],