@intentius/chant 0.124.18 → 0.124.20
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/commands/import.d.ts +2 -0
- package/dist/cli/commands/import.d.ts.map +1 -1
- package/dist/cli/commands/import.js +6 -2
- package/dist/cli/handlers/graph.js +24 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/handlers/misc.js +2 -1
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +4 -0
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/graph-detail.d.ts.map +1 -1
- package/dist/graph-detail.js +9 -3
- package/dist/graph-dot.d.ts.map +1 -1
- package/dist/graph-dot.js +36 -2
- package/dist/graph-ir.d.ts +98 -3
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-ir.js +71 -0
- package/dist/graph-lens.js +12 -1
- package/dist/graph-mermaid.d.ts +5 -1
- package/dist/graph-mermaid.d.ts.map +1 -1
- package/dist/graph-mermaid.js +46 -2
- package/dist/lexicon.d.ts +22 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/wave-runtime.mjs +169 -169
- package/package.json +1 -1
- package/src/cli/commands/import-live.test.ts +24 -0
- package/src/cli/commands/import.ts +8 -2
- package/src/cli/handlers/graph.test.ts +23 -0
- package/src/cli/handlers/graph.ts +22 -1
- package/src/cli/handlers/misc.ts +2 -1
- package/src/cli/main.test.ts +5 -0
- package/src/cli/parse-args.ts +3 -0
- package/src/cli/registry.ts +2 -0
- package/src/codegen/publish-order.test.ts +118 -4
- package/src/graph-ci-edges.test.ts +97 -0
- package/src/graph-detail.ts +9 -3
- package/src/graph-dot.ts +34 -2
- package/src/graph-ir.ts +156 -3
- package/src/graph-lens.ts +10 -1
- package/src/graph-mermaid.ts +44 -2
- package/src/lexicon.ts +23 -1
package/package.json
CHANGED
|
@@ -143,6 +143,30 @@ describe("liveImportFromPlugins (#114)", () => {
|
|
|
143
143
|
expect(result.error).toContain("No resources exported");
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
// #3911 — an exporter that refuses (the sql lexicon naming objects it cannot
|
|
147
|
+
// declare) is the import's error, not a warning behind "No resources exported".
|
|
148
|
+
test("an exporter's failure is the error when nothing else exported, and --skip-undeclarable reaches it", async () => {
|
|
149
|
+
let seen: unknown;
|
|
150
|
+
const refusing: LexiconPlugin = {
|
|
151
|
+
...fakeExporter("sql", sampleIR),
|
|
152
|
+
async exportResources(opts: { skipUndeclarable?: boolean }) {
|
|
153
|
+
seen = opts.skipUndeclarable;
|
|
154
|
+
if (!opts.skipUndeclarable) throw new Error("the server has an object of a kind chant cannot declare:\n - aggregate app.total(integer)");
|
|
155
|
+
return { ...sampleIR, warnings: ["aggregate app.total(integer) is left out (--skip-undeclarable)"] };
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
const failed = await liveImportFromPlugins([refusing], { environment: "prod", output: outputDir, force: true });
|
|
159
|
+
expect(failed.success).toBe(false);
|
|
160
|
+
expect(failed.error).toContain("sql: live export failed");
|
|
161
|
+
expect(failed.error).toContain(" - aggregate app.total(integer)");
|
|
162
|
+
expect(seen).toBeUndefined();
|
|
163
|
+
|
|
164
|
+
const skipped = await liveImportFromPlugins([refusing], { environment: "prod", output: outputDir, force: true, skipUndeclarable: true });
|
|
165
|
+
expect(skipped.success).toBe(true);
|
|
166
|
+
expect(seen).toBe(true);
|
|
167
|
+
expect(skipped.warnings).toContain("aggregate app.total(integer) is left out (--skip-undeclarable)");
|
|
168
|
+
});
|
|
169
|
+
|
|
146
170
|
// #932 — a multi-stack reconcile imports each stack from its own live
|
|
147
171
|
// CloudFormation stack; the `stack` option must reach `exportResources` so
|
|
148
172
|
// the right stack is queried (not one env-named stack for all of them).
|
|
@@ -645,6 +645,8 @@ export interface LiveImportOptions {
|
|
|
645
645
|
owned?: boolean;
|
|
646
646
|
/** Keep server-defaulted fields instead of stripping to declared shape. */
|
|
647
647
|
verbatim?: boolean;
|
|
648
|
+
/** Import what can be declared, and warn about the live objects no declaration can hold instead of failing. */
|
|
649
|
+
skipUndeclarable?: boolean;
|
|
648
650
|
}
|
|
649
651
|
|
|
650
652
|
/**
|
|
@@ -745,6 +747,8 @@ export async function liveImportFromPlugins(
|
|
|
745
747
|
// Collect IR from every exporter, tagging which lexicon produced output.
|
|
746
748
|
let irParts: ExportedTemplate[] = [];
|
|
747
749
|
let generatorLexicon: LexiconPlugin | undefined;
|
|
750
|
+
// An exporter that fails (an sql import refusing objects it cannot declare, #3911) is the error when nothing else exported.
|
|
751
|
+
const failures: string[] = [];
|
|
748
752
|
for (const plugin of exporters) {
|
|
749
753
|
let ir: ExportedTemplate;
|
|
750
754
|
try {
|
|
@@ -754,9 +758,10 @@ export async function liveImportFromPlugins(
|
|
|
754
758
|
selector: options.selector,
|
|
755
759
|
owned: options.owned,
|
|
756
760
|
verbatim: options.verbatim,
|
|
761
|
+
...(options.skipUndeclarable ? { skipUndeclarable: true } : {}),
|
|
757
762
|
});
|
|
758
763
|
} catch (err) {
|
|
759
|
-
|
|
764
|
+
failures.push(`${plugin.name}: live export failed — ${err instanceof Error ? err.message : String(err)}`);
|
|
760
765
|
continue;
|
|
761
766
|
}
|
|
762
767
|
// What the exporter left out or could not carry (#3336).
|
|
@@ -771,9 +776,10 @@ export async function liveImportFromPlugins(
|
|
|
771
776
|
success: false,
|
|
772
777
|
generatedFiles: [],
|
|
773
778
|
warnings,
|
|
774
|
-
error: `No resources exported from environment "${options.environment}".`,
|
|
779
|
+
error: failures.length > 0 ? failures.join("\n") : `No resources exported from environment "${options.environment}".`,
|
|
775
780
|
};
|
|
776
781
|
}
|
|
782
|
+
warnings.push(...failures);
|
|
777
783
|
|
|
778
784
|
if (exporters.length > 1 && irParts.length > 1) {
|
|
779
785
|
warnings.push("Multiple lexicons exported resources; generated with the first. Use --lexicon to target one.");
|
|
@@ -249,6 +249,29 @@ describe("runGraph", () => {
|
|
|
249
249
|
expect(JSON.parse(stdoutBuf.join("\n")).meta).toEqual({ collector: { pipelines: [] } });
|
|
250
250
|
});
|
|
251
251
|
|
|
252
|
+
// #3910 — a lexicon's graphEdges answer becomes edges and dangling refs.
|
|
253
|
+
test("--format ir carries what a lexicon's graphEdges reports", async () => {
|
|
254
|
+
lintClean(); discovered();
|
|
255
|
+
loadPluginsMock.mockResolvedValue([{
|
|
256
|
+
name: "gitlab",
|
|
257
|
+
serializer: {},
|
|
258
|
+
graphEdges: () => ({
|
|
259
|
+
references: [
|
|
260
|
+
{ from: "pod", to: "vpc", kind: "needs", viaAttr: "needs" },
|
|
261
|
+
{ from: "pod", to: "group/other", kind: "triggers", viaAttr: "trigger", crossPipeline: true, dangling: true },
|
|
262
|
+
],
|
|
263
|
+
pipelines: { ".gitlab-ci.yml": ["pod", "vpc"] },
|
|
264
|
+
}),
|
|
265
|
+
}, { name: "broken", serializer: {}, graphEdges: () => { throw new Error("boom"); } }]);
|
|
266
|
+
resolveLexMock.mockResolvedValue(["gitlab", "broken"]);
|
|
267
|
+
const exit = await runGraph({ args: makeArgs({ format: "ir" }), plugins: [], serializers: [] });
|
|
268
|
+
expect(exit).toBe(0);
|
|
269
|
+
const ir = JSON.parse(stdoutBuf.join("\n"));
|
|
270
|
+
expect(ir.edges).toContainEqual({ from: "pod", to: "vpc", kind: "needs", viaAttr: "needs" });
|
|
271
|
+
expect(ir.dangling).toEqual([{ from: "pod", kind: "triggers", path: "trigger", value: "group/other", crossPipeline: true }]);
|
|
272
|
+
expect(ir.groups.byPipeline).toEqual({ ".gitlab-ci.yml": ["pod", "vpc"] });
|
|
273
|
+
});
|
|
274
|
+
|
|
252
275
|
test("--format ir has no meta when no lexicon reports anything", async () => {
|
|
253
276
|
lintClean(); discovered();
|
|
254
277
|
loadPluginsMock.mockResolvedValue([{ name: "gcp", serializer: {}, graphMeta: () => undefined }, { name: "k8s", serializer: {} }]);
|
|
@@ -2,7 +2,7 @@ import { dirname, resolve } from "node:path";
|
|
|
2
2
|
import { discoverOps } from "../../op/discover";
|
|
3
3
|
import { discover } from "../../discovery/index";
|
|
4
4
|
import { partitionByLexicon, computeStackGraph, build, mergeBuildRootEntities } from "../../build";
|
|
5
|
-
import { buildGraphIr, buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, GRAPH_IR_VERSION, type GraphIR, type IRPipeline, type LiveObservation } from "../../graph-ir";
|
|
5
|
+
import { applyLexiconGraphEdges, buildGraphIr, buildLiveGraphIr, collectUnobserved, overlayGraphs, sourceOverlayGraphs, GRAPH_IR_VERSION, type GraphIR, type IRPipeline, type LexiconGraphEdges, type LiveObservation } from "../../graph-ir";
|
|
6
6
|
import { behaviourOverlay } from "../../behaviour-overlay";
|
|
7
7
|
import type { SerializerResult } from "../../serializer";
|
|
8
8
|
import { applyBehaviourOverlay, behaviourRequestFromIr } from "../../behaviour-graph";
|
|
@@ -726,6 +726,26 @@ function withGraphMeta(ir: GraphIR, plugins: LexiconPlugin[], entities: Map<stri
|
|
|
726
726
|
return Object.keys(added).length === 0 ? ir : { ...ir, meta: { ...(ir.meta ?? {}), ...added } };
|
|
727
727
|
}
|
|
728
728
|
|
|
729
|
+
/**
|
|
730
|
+
* Add the edges the lexicons' `graphEdges` hooks resolve (#3910): CI
|
|
731
|
+
* dependencies written as strings (`needs`, `uses:`, `trigger:`, `include:`,
|
|
732
|
+
* `extends`), which the AttrRef walk in `buildGraphIr` cannot see. A hook that
|
|
733
|
+
* throws costs its own edges and a warning, not the graph.
|
|
734
|
+
*/
|
|
735
|
+
function withLexiconEdges(ir: GraphIR, plugins: LexiconPlugin[], entities: Map<string, import("../../declarable").Declarable>): GraphIR {
|
|
736
|
+
const reports: LexiconGraphEdges[] = [];
|
|
737
|
+
for (const p of plugins) {
|
|
738
|
+
if (!p.graphEdges) continue;
|
|
739
|
+
try {
|
|
740
|
+
const report = p.graphEdges(entities);
|
|
741
|
+
if (report) reports.push(report);
|
|
742
|
+
} catch (err) {
|
|
743
|
+
console.error(formatWarning({ message: `the ${p.name} lexicon's graphEdges failed (${err instanceof Error ? err.message : String(err)}); its edges are left out` }));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return reports.length === 0 ? ir : applyLexiconGraphEdges(ir, reports);
|
|
747
|
+
}
|
|
748
|
+
|
|
729
749
|
/** Returned instead of a graph when the prediction cannot be attempted honestly. */
|
|
730
750
|
const REFUSED = Symbol("behaviour-refused");
|
|
731
751
|
|
|
@@ -867,6 +887,7 @@ async function runGraphView(
|
|
|
867
887
|
const plugins = ctx.plugins.length > 0
|
|
868
888
|
? ctx.plugins
|
|
869
889
|
: await loadPlugins(await projectLexiconsOrNone(projectPath));
|
|
890
|
+
ir = withLexiconEdges(ir, plugins as LexiconPlugin[], result.entities);
|
|
870
891
|
const predicted = await predictOntoIr(ir, plugins as LexiconPlugin[], ctx.args, {
|
|
871
892
|
// No environment on the declared path: the file is not deployed anywhere
|
|
872
893
|
// yet, and naming one would claim it was.
|
package/src/cli/handlers/misc.ts
CHANGED
|
@@ -349,7 +349,7 @@ export async function runImport(ctx: CommandContext): Promise<number> {
|
|
|
349
349
|
const { config } = await loadChantConfig(resolve("."));
|
|
350
350
|
if (config.stacks && config.stacks.length > 0) {
|
|
351
351
|
const perStack = await importFromLiveStacks(
|
|
352
|
-
{ environment: args.migrateFrom, lexicon: args.lexicon, force: args.force, selector, owned: args.owned, verbatim: args.verbatim },
|
|
352
|
+
{ environment: args.migrateFrom, lexicon: args.lexicon, force: args.force, selector, owned: args.owned, verbatim: args.verbatim, skipUndeclarable: args.skipUndeclarable },
|
|
353
353
|
config.stacks,
|
|
354
354
|
);
|
|
355
355
|
let anyFailure = false;
|
|
@@ -369,6 +369,7 @@ export async function runImport(ctx: CommandContext): Promise<number> {
|
|
|
369
369
|
selector,
|
|
370
370
|
owned: args.owned,
|
|
371
371
|
verbatim: args.verbatim,
|
|
372
|
+
skipUndeclarable: args.skipUndeclarable,
|
|
372
373
|
});
|
|
373
374
|
|
|
374
375
|
printImportResult(result);
|
package/src/cli/main.test.ts
CHANGED
|
@@ -16,6 +16,11 @@ describe("parseArgs", () => {
|
|
|
16
16
|
expect(() => parseArgs(["graph", "--traffic"])).toThrow(/--traffic needs a level/);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
+
test("--skip-undeclarable reaches live import (#3911)", () => {
|
|
20
|
+
expect(parseArgs(["import", "--from", "prod"]).skipUndeclarable).toBeUndefined();
|
|
21
|
+
expect(parseArgs(["import", "--from", "prod", "--skip-undeclarable"]).skipUndeclarable).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
19
24
|
test("--fold and --no-fold set the tri-state fold option (#1134)", () => {
|
|
20
25
|
expect(parseArgs(["build", "src"]).fold).toBeUndefined();
|
|
21
26
|
expect(parseArgs(["build", "src", "--fold"]).fold).toBe(true);
|
package/src/cli/parse-args.ts
CHANGED
|
@@ -33,6 +33,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
33
33
|
"--explain",
|
|
34
34
|
"--owned",
|
|
35
35
|
"--verbatim",
|
|
36
|
+
"--skip-undeclarable",
|
|
36
37
|
"--apply-rewrites",
|
|
37
38
|
"--write",
|
|
38
39
|
"--strict",
|
|
@@ -216,6 +217,8 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
216
217
|
if (!result.namespace || result.namespace.startsWith("-")) throw new Error("--namespace needs a namespace: --namespace <ns>");
|
|
217
218
|
} else if (arg === "--verbatim") {
|
|
218
219
|
result.verbatim = true;
|
|
220
|
+
} else if (arg === "--skip-undeclarable") {
|
|
221
|
+
result.skipUndeclarable = true;
|
|
219
222
|
} else if (arg === "--state") {
|
|
220
223
|
result.statePath = args[++i];
|
|
221
224
|
} else if (arg === "--select") {
|
package/src/cli/registry.ts
CHANGED
|
@@ -158,6 +158,8 @@ export interface ParsedArgs {
|
|
|
158
158
|
confirmProd?: boolean;
|
|
159
159
|
/** `chant import --verbatim` — keep server-defaulted fields in live import */
|
|
160
160
|
verbatim?: boolean;
|
|
161
|
+
/** `chant import --skip-undeclarable` — import what can be declared and warn about the live objects that cannot, instead of failing */
|
|
162
|
+
skipUndeclarable?: boolean;
|
|
161
163
|
/** `chant lifecycle … --src <dir>` — build root override for lifecycle commands */
|
|
162
164
|
src?: string;
|
|
163
165
|
/** `--env <name>` — active environment: sets CHANT_ENV so env-aware source
|
|
@@ -12,14 +12,20 @@
|
|
|
12
12
|
* npm, after the tag.
|
|
13
13
|
*
|
|
14
14
|
* publish-packages.sh now derives the order instead of assuming one, as
|
|
15
|
-
* dependency layers that pack
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* dependency layers that pack in parallel (#3857). This asserts the
|
|
16
|
+
* derivation is actually topological, so the next lexicon that depends on an
|
|
17
|
+
* alphabetically earlier one fails here rather than mid-release.
|
|
18
|
+
*
|
|
19
|
+
* With CHANT_STAGED_PUBLISH=1 (#3903) every package publishes under `next`
|
|
20
|
+
* in one batch instead, and the order that reaches consumers is the order
|
|
21
|
+
* `latest` moves in, which `tag_layers` derives from peer and optional edges.
|
|
22
|
+
* Both modes are checked at the end of this file.
|
|
18
23
|
*/
|
|
19
24
|
|
|
20
25
|
import { describe, expect, it } from "vitest";
|
|
21
26
|
import { execFileSync } from "node:child_process";
|
|
22
|
-
import { readFileSync } from "node:fs";
|
|
27
|
+
import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { tmpdir } from "node:os";
|
|
23
29
|
import { join, dirname } from "node:path";
|
|
24
30
|
import { fileURLToPath } from "node:url";
|
|
25
31
|
|
|
@@ -147,3 +153,111 @@ describe("packages built even when their publish is skipped", () => {
|
|
|
147
153
|
expect(built.has("lexicons/forgejo")).toBe(false);
|
|
148
154
|
});
|
|
149
155
|
});
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* #3903: a release is staged under `next` and only becomes visible to range
|
|
159
|
+
* consumers when `latest` moves. The #3856 research found that when a
|
|
160
|
+
* lexicon's `latest` showed before core's, pnpm, bun and yarn installed the
|
|
161
|
+
* new lexicon against the old core with the peer unmet, and exited 0. So
|
|
162
|
+
* `latest` moves in waves, each package after everything it peers on.
|
|
163
|
+
*/
|
|
164
|
+
describe("latest tag order", () => {
|
|
165
|
+
const waves = new Map(
|
|
166
|
+
ask("tag_layers").map((line) => {
|
|
167
|
+
const [wave, dir] = line.split("\t");
|
|
168
|
+
return [dir, Number(wave)] as const;
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
const order = publishOrder();
|
|
172
|
+
|
|
173
|
+
it("covers every package that publishes", () => {
|
|
174
|
+
expect([...waves.keys()].sort()).toEqual([...order].sort());
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("moves a package's latest after every package it peers on or optionally depends on", () => {
|
|
178
|
+
const owner = new Map(order.map((d) => [manifest(d).name as string, d]));
|
|
179
|
+
for (const dir of order) {
|
|
180
|
+
const pkg = manifest(dir);
|
|
181
|
+
for (const name of Object.keys({ ...pkg.peerDependencies, ...pkg.optionalDependencies })) {
|
|
182
|
+
const depDir = owner.get(name);
|
|
183
|
+
if (!depDir || depDir === dir) continue;
|
|
184
|
+
expect(
|
|
185
|
+
waves.get(depDir)!,
|
|
186
|
+
`${pkg.name} would move latest in wave ${waves.get(dir)}, no later than ${name} (wave ${waves.get(depDir)}), ` +
|
|
187
|
+
`so a consumer could get the new ${pkg.name} against the old ${name}`,
|
|
188
|
+
).toBeLessThan(waves.get(dir)!);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("moves core and k8s-client first, and no lexicon with them", () => {
|
|
194
|
+
expect(waves.get("packages/core")).toBe(0);
|
|
195
|
+
expect(waves.get("packages/k8s-client")).toBe(0);
|
|
196
|
+
for (const [dir, wave] of waves) {
|
|
197
|
+
if (dir.startsWith("lexicons/")) expect(wave, `${dir} moves latest with core`).toBeGreaterThan(0);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("moves github before the two lexicons that peer on it", () => {
|
|
202
|
+
expect(waves.get("lexicons/github")).toBeLessThan(waves.get("lexicons/forgejo")!);
|
|
203
|
+
expect(waves.get("lexicons/github")).toBeLessThan(waves.get("lexicons/gitlab")!);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Staging is opt-in until every trusted-publisher record allows dist-tag
|
|
210
|
+
* moves (#3904). Off, the script must publish exactly as it did before
|
|
211
|
+
* #3903: straight to `latest`, layer by layer, with no tag moves.
|
|
212
|
+
*/
|
|
213
|
+
describe("publish modes (#3903)", () => {
|
|
214
|
+
const script = readFileSync(join(REPO, "scripts/publish-packages.sh"), "utf8");
|
|
215
|
+
const publishes = script
|
|
216
|
+
.split("\n")
|
|
217
|
+
.filter((line) => !line.trim().startsWith("#") && /\bnpm publish "\$tgz"/.test(line));
|
|
218
|
+
|
|
219
|
+
it("passes --tag next to npm publish only when CHANT_STAGED_PUBLISH is 1", () => {
|
|
220
|
+
expect(publishes.length).toBeGreaterThan(0);
|
|
221
|
+
for (const line of publishes) {
|
|
222
|
+
expect(line).not.toMatch(/--tag next/);
|
|
223
|
+
expect(line).toContain('${TAG_ARGS[@]+"${TAG_ARGS[@]}"}');
|
|
224
|
+
}
|
|
225
|
+
expect(script).toMatch(/^TAG_ARGS=\(\)$/m);
|
|
226
|
+
expect(script).toMatch(/^\[ -n "\$STAGED" \] && TAG_ARGS=\(--tag next\)$/m);
|
|
227
|
+
expect(script).toMatch(/^\[ "\$\{CHANT_STAGED_PUBLISH:-\}" = "1" \] && STAGED=1$/m);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("moves latest with dist-tag only in the staged flow", () => {
|
|
231
|
+
const legacy = script.match(/^if \[ -z "\$STAGED" \]; then$.*?^fi$/ms);
|
|
232
|
+
expect(legacy, "the default publish block").not.toBeNull();
|
|
233
|
+
expect(legacy![0]).toMatch(/wait_visible/);
|
|
234
|
+
expect(legacy![0]).not.toMatch(/dist-tag|move_latest/);
|
|
235
|
+
expect(legacy![0]).toMatch(/^ exit 0$/m);
|
|
236
|
+
expect(script).toMatch(/dist-tag add "\$name@\$version" latest/);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// A stub npm whose every answer is an old version, so every package plans
|
|
240
|
+
// to publish. DRY_RUN=plan reads the registry and the graph, builds nothing.
|
|
241
|
+
function plan(env: Record<string, string>): string {
|
|
242
|
+
const bin = mkdtempSync(join(tmpdir(), "publish-plan-"));
|
|
243
|
+
writeFileSync(join(bin, "npm"), "#!/bin/sh\necho 0.0.1\n");
|
|
244
|
+
chmodSync(join(bin, "npm"), 0o755);
|
|
245
|
+
return execFileSync("bash", ["scripts/publish-packages.sh"], {
|
|
246
|
+
cwd: REPO,
|
|
247
|
+
encoding: "utf8",
|
|
248
|
+
env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, DRY_RUN: "plan", RUNNER_TEMP: bin, ...env },
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
it("plans a layered publish to latest by default", () => {
|
|
253
|
+
const out = plan({ CHANT_STAGED_PUBLISH: "" });
|
|
254
|
+
expect(out).toMatch(/@intentius\/chant \S+: publish \(live: 0\.0\.1\)/);
|
|
255
|
+
expect(out).not.toMatch(/under next|wave \d/);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("plans a staged publish and the tag waves with CHANT_STAGED_PUBLISH=1", () => {
|
|
259
|
+
const out = plan({ CHANT_STAGED_PUBLISH: "1" });
|
|
260
|
+
expect(out).toMatch(/@intentius\/chant \S+: publish under next \(latest: 0\.0\.1\)/);
|
|
261
|
+
expect(out).toMatch(/^ wave 0: @intentius\/chant @intentius\/chant-k8s-client$/m);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { applyLexiconGraphEdges, type GraphIR } from "./graph-ir";
|
|
3
|
+
import { toMermaid } from "./graph-mermaid";
|
|
4
|
+
import { toDot } from "./graph-dot";
|
|
5
|
+
import { applyDetail } from "./graph-detail";
|
|
6
|
+
import { applyLens, parseLens } from "./graph-lens";
|
|
7
|
+
|
|
8
|
+
// #3910: CI lexicons report their dependency strings through `graphEdges`, and
|
|
9
|
+
// core merges them, keeps the unresolved ones and draws both.
|
|
10
|
+
|
|
11
|
+
const base: GraphIR = {
|
|
12
|
+
nodes: [
|
|
13
|
+
{ id: "ci", kind: "GitHub::Actions::Workflow", lexicon: "github", attrs: {} },
|
|
14
|
+
{ id: "build", kind: "GitHub::Actions::Job", lexicon: "github", attrs: {} },
|
|
15
|
+
{ id: "test", kind: "GitHub::Actions::Job", lexicon: "github", attrs: {} },
|
|
16
|
+
{ id: "shared", kind: "GitHub::Actions::Workflow", lexicon: "github", attrs: {} },
|
|
17
|
+
],
|
|
18
|
+
// The core walk already found `test` holding the build Job under `needs`.
|
|
19
|
+
edges: [{ from: "test", to: "build", kind: "ref", viaAttr: "needs" }],
|
|
20
|
+
groups: { byLexicon: { github: ["build", "ci", "shared", "test"] } },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const merged = applyLexiconGraphEdges(base, [{
|
|
24
|
+
references: [
|
|
25
|
+
{ from: "test", to: "build", kind: "needs", viaAttr: "needs" },
|
|
26
|
+
{ from: "test", to: "shared", kind: "calls", viaAttr: "uses", crossPipeline: true },
|
|
27
|
+
{ from: "test", to: "lint", kind: "needs", viaAttr: "needs", dangling: true },
|
|
28
|
+
{ from: "build", to: "nowhere", kind: "needs", viaAttr: "needs" },
|
|
29
|
+
{ from: "ghost", to: "build", kind: "needs" },
|
|
30
|
+
],
|
|
31
|
+
pipelines: { ci: ["ci", "build", "test", "ghost"], shared: ["shared"] },
|
|
32
|
+
}]);
|
|
33
|
+
|
|
34
|
+
describe("applyLexiconGraphEdges (#3910)", () => {
|
|
35
|
+
test("a lexicon edge replaces the core ref edge on the same from/to/viaAttr", () => {
|
|
36
|
+
expect(merged.edges).toEqual([
|
|
37
|
+
{ from: "test", to: "build", kind: "needs", viaAttr: "needs" },
|
|
38
|
+
{ from: "test", to: "shared", kind: "calls", viaAttr: "uses", crossPipeline: true },
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a dangling reference, or one whose target is not a node, is kept on ir.dangling", () => {
|
|
43
|
+
expect(merged.dangling).toEqual([
|
|
44
|
+
{ from: "build", kind: "needs", path: "needs", value: "nowhere" },
|
|
45
|
+
{ from: "test", kind: "needs", path: "needs", value: "lint" },
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("pipelines land on groups.byPipeline, restricted to nodes", () => {
|
|
50
|
+
expect(merged.groups.byPipeline).toEqual({ ci: ["build", "ci", "test"], shared: ["shared"] });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("no report leaves the IR as it was", () => {
|
|
54
|
+
expect(applyLexiconGraphEdges(base, [])).toBe(base);
|
|
55
|
+
expect(applyLexiconGraphEdges(base, [{ references: [] }])).toBe(base);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("renderers draw CI edges (#3910)", () => {
|
|
60
|
+
test("mermaid: kind labels, dashed cross-pipeline edges, pipeline subgraphs, dangling stubs", () => {
|
|
61
|
+
const out = toMermaid(merged);
|
|
62
|
+
expect(out).toContain('test -->|"needs"| build');
|
|
63
|
+
expect(out).toContain('test -.->|"calls (uses)"| shared');
|
|
64
|
+
expect(out).toContain('subgraph pipe_github_ci["ci"]');
|
|
65
|
+
expect(out).toContain('subgraph pipe_github_shared["shared"]');
|
|
66
|
+
expect(out).toContain('dangling_lint(["lint<br/>(not declared)"])');
|
|
67
|
+
expect(out).toContain('test -.->|"needs"| dangling_lint');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("dot: the same, as nested clusters and dashed edges", () => {
|
|
71
|
+
const out = toDot(merged);
|
|
72
|
+
expect(out).toContain('"test" -> "build" [label="needs"];');
|
|
73
|
+
expect(out).toContain('"test" -> "shared" [label="calls (uses)", style=dashed];');
|
|
74
|
+
expect(out).toContain('subgraph "cluster_github_pipeline_ci" {');
|
|
75
|
+
expect(out).toContain('"dangling:lint" [label="lint\\n(not declared)", shape=box, style="rounded,dashed"];');
|
|
76
|
+
expect(out).toContain('"test" -> "dangling:lint" [label="needs", style=dashed];');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a typed ref edge renders as before", () => {
|
|
80
|
+
const out = toMermaid({ ...base, edges: [{ from: "test", to: "build", kind: "ref", viaAttr: "needs" }] });
|
|
81
|
+
expect(out).toContain('test -->|"needs"| build');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("detail and lens keep CI edges (#3910)", () => {
|
|
86
|
+
test("the composite tier keeps the kind and the cross-pipeline mark, and the dangling list", () => {
|
|
87
|
+
const t1 = applyDetail(merged, 1);
|
|
88
|
+
expect(t1.edges).toContainEqual({ from: "test", to: "shared", kind: "calls", viaAttr: "uses", crossPipeline: true });
|
|
89
|
+
expect(t1.dangling).toHaveLength(2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a lens keeps the dangling references and pipelines of the nodes it keeps", () => {
|
|
93
|
+
const focused = applyLens(merged, parseLens("blast:shared", { down: true }));
|
|
94
|
+
expect(focused.dangling).toEqual([{ from: "test", kind: "needs", path: "needs", value: "lint" }]);
|
|
95
|
+
expect(focused.groups.byPipeline).toEqual({ ci: ["test"], shared: ["shared"] });
|
|
96
|
+
});
|
|
97
|
+
});
|
package/src/graph-detail.ts
CHANGED
|
@@ -159,10 +159,13 @@ function toComposites(ir: GraphIR): GraphIR {
|
|
|
159
159
|
if (from === to) continue; // edge internal to a composite
|
|
160
160
|
// A property label only makes sense when neither endpoint was collapsed.
|
|
161
161
|
const collapsed = from !== e.from || to !== e.to;
|
|
162
|
+
// The kind survives the collapse (#3910): a job's `needs` into a
|
|
163
|
+
// collapsed composite is still a `needs`, not a typed reference.
|
|
164
|
+
const cross = e.crossPipeline ? { crossPipeline: true } : {};
|
|
162
165
|
const edge: IREdge = collapsed
|
|
163
|
-
? { from, to, kind:
|
|
164
|
-
: { from, to, kind:
|
|
165
|
-
const key = `${from}\0${to}\0${edge.viaAttr ?? ""}`;
|
|
166
|
+
? { from, to, kind: e.kind, ...cross }
|
|
167
|
+
: { from, to, kind: e.kind, viaAttr: e.viaAttr, ...cross };
|
|
168
|
+
const key = `${from}\0${to}\0${edge.viaAttr ?? ""}\0${collapsed ? e.kind : ""}`;
|
|
166
169
|
if (!seen.has(key)) seen.set(key, edge);
|
|
167
170
|
}
|
|
168
171
|
|
|
@@ -173,11 +176,14 @@ function toComposites(ir: GraphIR): GraphIR {
|
|
|
173
176
|
// not just the base + ATTRIBUTES. Filter to handles whose node survived.
|
|
174
177
|
const surviving = new Set(nodes.map((n) => n.id));
|
|
175
178
|
const imports = ir.imports?.filter((i) => surviving.has(i.node));
|
|
179
|
+
// Unresolved references (#3910) stay with whichever node now holds them.
|
|
180
|
+
const dangling = ir.dangling?.map((d) => ({ ...d, from: idMap.get(d.from) ?? d.from }));
|
|
176
181
|
return {
|
|
177
182
|
nodes,
|
|
178
183
|
edges: sortEdges([...seen.values()]),
|
|
179
184
|
groups: { byLexicon: byLexiconOf(nodes) },
|
|
180
185
|
...(imports && imports.length ? { imports } : {}),
|
|
186
|
+
...(dangling && dangling.length ? { dangling } : {}),
|
|
181
187
|
};
|
|
182
188
|
}
|
|
183
189
|
|
package/src/graph-dot.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { GraphIR, IRNode, IREdge } from "./graph-ir";
|
|
2
|
+
import { edgeLabel } from "./graph-mermaid";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Render the graph IR as Graphviz DOT. Pure text — no `dot` needed to produce
|
|
@@ -14,17 +15,36 @@ export function toDot(ir: GraphIR): string {
|
|
|
14
15
|
|
|
15
16
|
// Cluster by wave (component graph) when present, else by lexicon (entity graph).
|
|
16
17
|
const clusters = ir.groups.byWave ?? ir.groups.byLexicon;
|
|
18
|
+
// CI pipelines (#3910) nest as clusters inside their lexicon's cluster.
|
|
19
|
+
const pipelineOf = new Map<string, string>();
|
|
20
|
+
for (const [name, members] of Object.entries(ir.groups.byPipeline ?? {})) {
|
|
21
|
+
for (const id of members) if (!pipelineOf.has(id)) pipelineOf.set(id, name);
|
|
22
|
+
}
|
|
17
23
|
const grouped = new Set<string>();
|
|
18
24
|
if (clusters) {
|
|
19
25
|
for (const [name, members] of Object.entries(clusters)) {
|
|
20
26
|
lines.push(` subgraph ${q(`cluster_${name}`)} {`);
|
|
21
27
|
lines.push(` label=${q(name)};`);
|
|
28
|
+
const nested = new Map<string, IRNode[]>();
|
|
22
29
|
for (const id of members) {
|
|
23
30
|
const node = ir.nodes.find((n) => n.id === id);
|
|
24
31
|
if (!node) continue;
|
|
25
32
|
grouped.add(id);
|
|
33
|
+
const pipeline = pipelineOf.get(id);
|
|
34
|
+
if (pipeline !== undefined) {
|
|
35
|
+
const arr = nested.get(pipeline) ?? [];
|
|
36
|
+
arr.push(node);
|
|
37
|
+
nested.set(pipeline, arr);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
26
40
|
lines.push(` ${nodeLine(node)}`);
|
|
27
41
|
}
|
|
42
|
+
for (const [pipeline, nodes] of [...nested].sort(([a], [b]) => a.localeCompare(b))) {
|
|
43
|
+
lines.push(` subgraph ${q(`cluster_${name}_pipeline_${pipeline}`)} {`);
|
|
44
|
+
lines.push(` label=${q(pipeline)};`);
|
|
45
|
+
for (const node of nodes) lines.push(` ${nodeLine(node)}`);
|
|
46
|
+
lines.push(" }");
|
|
47
|
+
}
|
|
28
48
|
lines.push(" }");
|
|
29
49
|
}
|
|
30
50
|
}
|
|
@@ -35,6 +55,17 @@ export function toDot(ir: GraphIR): string {
|
|
|
35
55
|
|
|
36
56
|
for (const e of ir.edges) lines.push(` ${edgeLine(e)}`);
|
|
37
57
|
|
|
58
|
+
// Unresolved references (#3910): a dashed edge to a dashed stub node.
|
|
59
|
+
const stubs = new Set<string>();
|
|
60
|
+
for (const d of ir.dangling ?? []) {
|
|
61
|
+
const stub = `dangling:${d.value}`;
|
|
62
|
+
if (!stubs.has(stub)) {
|
|
63
|
+
stubs.add(stub);
|
|
64
|
+
lines.push(` ${q(stub)} [label=${q(`${d.value}\n(not declared)`)}, shape=box, style="rounded,dashed"];`);
|
|
65
|
+
}
|
|
66
|
+
lines.push(` ${q(d.from)} -> ${q(stub)} [label=${q(d.kind)}, style=dashed];`);
|
|
67
|
+
}
|
|
68
|
+
|
|
38
69
|
lines.push("}");
|
|
39
70
|
return lines.join("\n") + "\n";
|
|
40
71
|
}
|
|
@@ -47,8 +78,9 @@ function nodeLine(node: IRNode): string {
|
|
|
47
78
|
function edgeLine(e: IREdge): string {
|
|
48
79
|
const from = q(e.from);
|
|
49
80
|
const to = q(e.to);
|
|
50
|
-
const label =
|
|
51
|
-
|
|
81
|
+
const label = edgeLabel(e);
|
|
82
|
+
const attrs = [label ? `label=${q(label)}` : "", e.crossPipeline ? "style=dashed" : ""].filter(Boolean);
|
|
83
|
+
return attrs.length ? `${from} -> ${to} [${attrs.join(", ")}];` : `${from} -> ${to};`;
|
|
52
84
|
}
|
|
53
85
|
|
|
54
86
|
/** Quote a DOT identifier/label. Graphviz accepts any string in double quotes;
|