@intentius/chant 0.124.48 → 0.124.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +20 -12
- package/dist/cli/handlers/graph-reader.d.ts +26 -0
- package/dist/cli/handlers/graph-reader.d.ts.map +1 -0
- package/dist/cli/handlers/graph-reader.js +41 -0
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/graph.js +14 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +40 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +22 -0
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +11 -0
- package/dist/cli/registry.d.ts +8 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/pr-run.d.ts +3 -0
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +1 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -0
- package/dist/gate-decision.d.ts +9 -0
- package/dist/gate-decision.d.ts.map +1 -1
- package/dist/gate-decision.js +22 -7
- package/dist/lexicon.d.ts +23 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/gate-approval-base.d.ts +25 -0
- package/dist/op/gate-approval-base.d.ts.map +1 -0
- package/dist/op/gate-approval-base.js +71 -0
- package/dist/op/gate-approval.d.ts +7 -4
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-approval.js +2 -25
- package/dist/op/gate-name.d.ts +13 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-name.js +14 -1
- package/dist/op/gate.d.ts +4 -3
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +9 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +24 -5
- package/dist/pr-loop.d.ts +10 -0
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +4 -0
- package/dist/pr-migrations.d.ts +62 -0
- package/dist/pr-migrations.d.ts.map +1 -0
- package/dist/pr-migrations.js +50 -0
- package/dist/pr-post.js +1 -1
- package/dist/wave-runtime.mjs +219 -218
- package/dist/workspace/check.schema.json +4 -2
- package/dist/workspace/checks/gate-approval.d.ts +58 -0
- package/dist/workspace/checks/gate-approval.d.ts.map +1 -0
- package/dist/workspace/checks/gate-approval.js +103 -0
- package/dist/workspace/checks.d.ts +1 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/checks.js +4 -0
- package/dist/workspace/declaration.d.ts +7 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +13 -1
- package/dist/workspace/declaration.schema.json +33 -0
- package/dist/workspace/exit-cli.d.ts +44 -0
- package/dist/workspace/exit-cli.d.ts.map +1 -0
- package/dist/workspace/exit-cli.js +198 -0
- package/dist/workspace/exit.d.ts +159 -0
- package/dist/workspace/exit.d.ts.map +1 -0
- package/dist/workspace/exit.js +480 -0
- package/dist/workspace/exit.schema.json +257 -0
- package/dist/workspace/graph-cache.d.ts +3 -1
- package/dist/workspace/graph-cache.d.ts.map +1 -1
- package/dist/workspace/graph-cache.js +12 -1
- package/dist/workspace/import-fetch.d.ts +38 -0
- package/dist/workspace/import-fetch.d.ts.map +1 -0
- package/dist/workspace/import-fetch.js +99 -0
- package/dist/workspace/import-init.d.ts +26 -0
- package/dist/workspace/import-init.d.ts.map +1 -0
- package/dist/workspace/import-init.js +130 -0
- package/dist/workspace/import-source.d.ts +147 -0
- package/dist/workspace/import-source.d.ts.map +1 -0
- package/dist/workspace/import-source.js +370 -0
- package/dist/workspace/init.d.ts +4 -0
- package/dist/workspace/init.d.ts.map +1 -1
- package/dist/workspace/init.js +70 -3
- package/dist/workspace/kind-readers.d.ts +6 -0
- package/dist/workspace/kind-readers.d.ts.map +1 -1
- package/dist/workspace/kind-readers.js +18 -0
- package/dist/workspace/kinds.d.ts +24 -1
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/kinds.js +64 -4
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.js +13 -4
- package/dist/workspace/new-migrations.d.ts +47 -0
- package/dist/workspace/new-migrations.d.ts.map +1 -0
- package/dist/workspace/new-migrations.js +91 -0
- package/dist/workspace/pr-report.schema.json +46 -0
- package/dist/workspace/promote.d.ts +96 -0
- package/dist/workspace/promote.d.ts.map +1 -0
- package/dist/workspace/promote.js +263 -0
- package/dist/workspace/reason-codes.d.ts +13 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +14 -0
- package/dist/workspace/search-contract.d.ts +1 -1
- package/dist/workspace/search-contract.d.ts.map +1 -1
- package/dist/workspace/workspace-kinds.schema.json +50 -7
- package/package.json +1 -1
- package/src/cli/handlers/approve.ts +10 -2
- package/src/cli/handlers/graph-reader.test.ts +31 -0
- package/src/cli/handlers/graph-reader.ts +51 -0
- package/src/cli/handlers/graph.ts +14 -0
- package/src/cli/handlers/pr.ts +38 -1
- package/src/cli/main.test.ts +2 -0
- package/src/cli/main.ts +22 -0
- package/src/cli/parse-args.ts +8 -0
- package/src/cli/registry.ts +8 -0
- package/src/components/pr-env-run-bundle.test.ts +1 -0
- package/src/components/pr-run.ts +4 -0
- package/src/config.ts +16 -0
- package/src/gate-decision-bundle.test.ts +4 -2
- package/src/gate-decision-identity.test.ts +11 -0
- package/src/gate-decision.ts +26 -6
- package/src/lexicon.ts +24 -0
- package/src/op/gate-approval-at-base.test.ts +97 -0
- package/src/op/gate-approval-base.ts +72 -0
- package/src/op/gate-approval.ts +10 -21
- package/src/op/gate-name.ts +16 -1
- package/src/op/gate.ts +4 -3
- package/src/op/local-executor.ts +25 -5
- package/src/pr-loop-bundle.test.ts +3 -2
- package/src/pr-loop.test.ts +52 -0
- package/src/pr-loop.ts +12 -0
- package/src/pr-migrations.ts +97 -0
- package/src/pr-post.ts +1 -1
- package/src/workspace/__fixtures__/import/spacelift.json +9 -0
- package/src/workspace/__fixtures__/import/tfc.json +13 -0
- package/src/workspace/behold-kinds.test.ts +1 -0
- package/src/workspace/check-contract.test.ts +3 -2
- package/src/workspace/check.schema.json +4 -2
- package/src/workspace/checks/gate-approval.test.ts +77 -0
- package/src/workspace/checks/gate-approval.ts +121 -0
- package/src/workspace/checks.ts +4 -0
- package/src/workspace/declaration.schema.json +33 -0
- package/src/workspace/declaration.ts +19 -2
- package/src/workspace/exit-cli.ts +219 -0
- package/src/workspace/exit.schema.json +257 -0
- package/src/workspace/exit.test.ts +185 -0
- package/src/workspace/exit.ts +560 -0
- package/src/workspace/graph-cache.ts +12 -2
- package/src/workspace/identity.test.ts +18 -3
- package/src/workspace/import-fetch.ts +151 -0
- package/src/workspace/import-init.ts +147 -0
- package/src/workspace/import-source.test.ts +183 -0
- package/src/workspace/import-source.ts +462 -0
- package/src/workspace/init.test.ts +55 -1
- package/src/workspace/init.ts +66 -4
- package/src/workspace/kind-readers.test.ts +36 -0
- package/src/workspace/kind-readers.ts +18 -1
- package/src/workspace/kinds.test.ts +23 -0
- package/src/workspace/kinds.ts +81 -5
- package/src/workspace/lineage-upgrade.test.ts +20 -0
- package/src/workspace/lineage-upgrade.ts +16 -7
- package/src/workspace/new-migrations.test.ts +98 -0
- package/src/workspace/new-migrations.ts +109 -0
- package/src/workspace/pr-report.schema.json +46 -0
- package/src/workspace/promote.test.ts +114 -0
- package/src/workspace/promote.ts +302 -0
- package/src/workspace/reason-codes.test.ts +4 -0
- package/src/workspace/reason-codes.ts +14 -0
- 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);
|
package/src/workspace/kinds.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
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"],
|