@intentius/chant 0.34.0 → 0.37.0

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 (41) hide show
  1. package/dist/cli/commands/check-lexicon-docs.d.ts +42 -0
  2. package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -0
  3. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  4. package/dist/cli/handlers/components.d.ts.map +1 -1
  5. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/registry.d.ts +2 -0
  8. package/dist/cli/registry.d.ts.map +1 -1
  9. package/dist/codegen/docs.d.ts +16 -0
  10. package/dist/codegen/docs.d.ts.map +1 -1
  11. package/dist/codegen/fetch.d.ts +1 -1
  12. package/dist/codegen/fetch.d.ts.map +1 -1
  13. package/dist/deep-observation.d.ts +0 -10
  14. package/dist/deep-observation.d.ts.map +1 -1
  15. package/dist/lifecycle/rollback.d.ts +18 -0
  16. package/dist/lifecycle/rollback.d.ts.map +1 -1
  17. package/dist/lifecycle/status.d.ts +53 -0
  18. package/dist/lifecycle/status.d.ts.map +1 -1
  19. package/dist/yaml.d.ts.map +1 -1
  20. package/package.json +1 -1
  21. package/src/cli/commands/check-lexicon-docs.test.ts +90 -0
  22. package/src/cli/commands/check-lexicon-docs.ts +71 -0
  23. package/src/cli/commands/check-lexicon.ts +15 -0
  24. package/src/cli/handlers/components.ts +54 -3
  25. package/src/cli/handlers/graph.test.ts +61 -0
  26. package/src/cli/handlers/lifecycle.ts +8 -1
  27. package/src/cli/main.ts +2 -0
  28. package/src/cli/registry.ts +2 -0
  29. package/src/codegen/docs-sections.ts +1 -1
  30. package/src/codegen/docs.ts +122 -5
  31. package/src/codegen/fetch.test.ts +24 -5
  32. package/src/codegen/fetch.ts +19 -1
  33. package/src/codegen/publish-order.test.ts +133 -0
  34. package/src/deep-observation.test.ts +151 -0
  35. package/src/deep-observation.ts +66 -2
  36. package/src/lifecycle/rollback.test.ts +78 -1
  37. package/src/lifecycle/rollback.ts +41 -2
  38. package/src/lifecycle/status.test.ts +90 -5
  39. package/src/lifecycle/status.ts +85 -2
  40. package/src/yaml.test.ts +89 -0
  41. package/src/yaml.ts +66 -9
@@ -572,6 +572,67 @@ describe("runGraph", () => {
572
572
  expect((opts as { owned: boolean }).owned).toBe(true);
573
573
  });
574
574
 
575
+ // #1158: a project that declares its stacks in `ChantConfig.stacks` — rather
576
+ // than deriving them from `*.component.ts` — must observe each declared
577
+ // stack, the same contract `resolveStackTargets` gives lifecycle
578
+ // snapshot/diff. Every other test here passes `stacks: []`, so without this
579
+ // the declared-stack path is implemented and unguarded.
580
+ test("ChantConfig.stacks: observeResources gets every declared stack, with its region and src", async () => {
581
+ resolveLexMock.mockResolvedValue(["aws"]);
582
+ loadPluginsMock.mockResolvedValue([
583
+ { name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
584
+ ]);
585
+ loadChantConfigMock.mockResolvedValue({
586
+ config: {
587
+ stacks: [
588
+ { name: "estate-east", region: "us-east-1", src: "east/src" },
589
+ { name: "estate-west", region: "us-west-2", src: "west/src" },
590
+ ],
591
+ },
592
+ });
593
+ observeMock.mockResolvedValue({ observations: [], errors: [], warnings: [] });
594
+ const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
595
+ expect(exit).toBe(0);
596
+ expect(observeMock).toHaveBeenCalledTimes(1);
597
+ const [, , , opts] = observeMock.mock.calls[0];
598
+ // The region/src carry through — a multi-region estate observes each
599
+ // stack in its own region, not all of them in the ambient default.
600
+ expect((opts as { stacks: Array<{ name: string; region?: string; src?: string }> }).stacks).toEqual([
601
+ { name: "estate-east", region: "us-east-1", src: "east/src" },
602
+ { name: "estate-west", region: "us-west-2", src: "west/src" },
603
+ ]);
604
+ });
605
+
606
+ // A stack can be both component-derived and declared. It must be observed
607
+ // once — `describeResources` is a live API call per stack, so a duplicate
608
+ // is a wasted round trip and a doubled node set to reconcile.
609
+ test("ChantConfig.stacks: a stack also derived from a component is not observed twice", async () => {
610
+ resolveLexMock.mockResolvedValue(["aws"]);
611
+ loadPluginsMock.mockResolvedValue([
612
+ { name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
613
+ ]);
614
+ discoverComponentsMock.mockResolvedValue({
615
+ errors: [],
616
+ sourceFiles: [],
617
+ components: new Map([
618
+ ["loom-db", { component: { name: "loom-db", dependsOn: [], deploy: [
619
+ { phase: "deploy", steps: [{ kind: "cfn-deploy", stack: "shared-estate" }] },
620
+ ] }, exportName: "loomDb", filePath: "components/loom-db.component.ts" }],
621
+ ]),
622
+ });
623
+ loadChantConfigMock.mockResolvedValue({
624
+ config: { stacks: [{ name: "shared-estate" }, { name: "estate-west" }] },
625
+ });
626
+ observeMock.mockResolvedValue({ observations: [], errors: [], warnings: [] });
627
+ const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
628
+ expect(exit).toBe(0);
629
+ const [, , , opts] = observeMock.mock.calls[0];
630
+ expect((opts as { stacks: Array<{ name: string }> }).stacks.map((s) => s.name)).toEqual([
631
+ "shared-estate",
632
+ "estate-west",
633
+ ]);
634
+ });
635
+
575
636
  test("component discovery errors: falls back to the single-stack path with a warning", async () => {
576
637
  resolveLexMock.mockResolvedValue(["aws"]);
577
638
  loadPluginsMock.mockResolvedValue([
@@ -273,11 +273,18 @@ export async function runLifecycleRollback(ctx: CommandContext): Promise<number>
273
273
  const { config } = await loadChantConfig(resolve("."));
274
274
  const sourceDir = config.sourceDir ?? ".";
275
275
  try {
276
- const result = await rollbackToRevision({ ref, env: environment, sourceDir, cwd: resolve(".") });
276
+ const result = await rollbackToRevision({ ref, env: environment, sourceDir, cwd: resolve("."), dryRun: args.dryRun });
277
277
  if (result.noop) {
278
278
  console.error(formatSuccess(`${sourceDir} already matches ${ref} — nothing to roll back`));
279
279
  return 0;
280
280
  }
281
+ if (args.dryRun) {
282
+ // The delta on stdout, so it pipes and diffs like any other patch; the
283
+ // summary on stderr, matching the PR path's split.
284
+ process.stdout.write(result.diff ?? "");
285
+ console.error(formatSuccess(`Rollback delta for ${ref} computed — no PR opened, nothing pushed`));
286
+ return 0;
287
+ }
281
288
  console.log(result.prUrl); // the PR URL — the consumer (behold) reads this from stdout
282
289
  console.error(formatSuccess(`Opened rollback PR on ${result.branch}`));
283
290
  return 0;
package/src/cli/main.ts CHANGED
@@ -211,6 +211,8 @@ export function parseArgs(args: string[]): ParsedArgs {
211
211
  result.migrateTo = args[++i];
212
212
  } else if (arg === "--emit") {
213
213
  result.emit = args[++i];
214
+ } else if (arg === "--dry-run") {
215
+ result.dryRun = true;
214
216
  } else if (arg === "--strict") {
215
217
  result.strict = true;
216
218
  } else if (arg === "--validate") {
@@ -63,6 +63,8 @@ export interface ParsedArgs {
63
63
  selectName?: string;
64
64
  /** `chant import --owned` — restrict live import to chant-owned resources */
65
65
  owned?: boolean;
66
+ /** `chant lifecycle rollback --dry-run` — compute the rollback delta and print it; open no PR, push nothing, leave no branch. */
67
+ dryRun?: boolean;
66
68
  /** `chant import --verbatim` — keep server-defaulted fields in live import */
67
69
  verbatim?: boolean;
68
70
  /** `chant lifecycle … --src <dir>` — build root override for lifecycle commands */
@@ -95,7 +95,7 @@ export function generateIntrinsics(
95
95
  "",
96
96
  `The ${config.displayName} lexicon provides **${intrinsics.length}** intrinsic functions.`,
97
97
  "",
98
- `**Tag?** shows how an intrinsic is authored: a genuine tagged template (\`Sub\\\`...\\\`\`) or a plain function call (\`Ref(...)\`). **Folds?** shows whether [\`chant build --fold\`](/chant/concepts/typescript-as-data/#folded-vs-run) can reduce a use of this intrinsic today, without running the file — generated directly from this lexicon's registration, not restated by hand. A tagged template folds once it is registered. A plain call folds only where this lexicon has opted that intrinsic in one at a time (\`foldsAsCall\`), so a \`No\` in this column means this intrinsic has not been opted in, not that calls in general cannot fold.`,
98
+ `**Tag?** shows how an intrinsic is authored: a genuine tagged template (\`Sub\\\`...\\\`\`) or a plain function call (\`Ref(...)\`). **Folds?** shows whether [folding](/chant/concepts/typescript-as-data/#folded-vs-run) — the default \`chant build\` path — can reduce a use of this intrinsic today, without running the file — generated directly from this lexicon's registration, not restated by hand. A tagged template folds once it is registered. A plain call folds only where this lexicon has opted that intrinsic in one at a time (\`foldsAsCall\`), so a \`No\` in this column means this intrinsic has not been opted in, not that calls in general cannot fold.`,
99
99
  "",
100
100
  "| Function | Description | Output Key | Tag? | Folds? |",
101
101
  "|----------|-------------|------------|------|--------|",
@@ -7,7 +7,7 @@
7
7
  * (service grouping, resource type URLs, custom overview content).
8
8
  */
9
9
 
10
- import { copyFileSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs";
10
+ import { copyFileSync, existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs";
11
11
  import { join } from "path";
12
12
  import { fileURLToPath } from "url";
13
13
 
@@ -71,6 +71,7 @@ export function docsPipeline(config: DocsConfig): DocsResult {
71
71
  }
72
72
  pages.set("index.mdx", overviewContent);
73
73
  const suppress = new Set(config.suppressPages ?? []);
74
+ const extraSlugs = new Set((config.extraPages ?? []).map((p) => p.slug));
74
75
 
75
76
  // Extra pages from lexicon config
76
77
  if (config.extraPages) {
@@ -96,12 +97,28 @@ export function docsPipeline(config: DocsConfig): DocsResult {
96
97
  }
97
98
  }
98
99
 
99
- if (!suppress.has("intrinsics") && manifest.intrinsics && manifest.intrinsics.length > 0) {
100
+ // A generated page must not overwrite one the lexicon explicitly declared.
101
+ // The extraPages above are written into `pages` first, so an unguarded
102
+ // `pages.set` below silently discards them: helm declared its own
103
+ // "Intrinsics Reference" and pre-synth rules pages and shipped neither for
104
+ // as long as both slugs collided (#1312). Explicit authorship wins, and the
105
+ // collision is reported rather than resolved in silence.
106
+ const claimed = (slug: string): boolean => {
107
+ if (suppress.has(slug)) return true;
108
+ if (!extraSlugs.has(slug)) return false;
109
+ console.warn(
110
+ `[docs:${config.name}] extraPages declares "${slug}", which is also a generated page — keeping the declared one.\n` +
111
+ ` Add "${slug}" to suppressPages to make that explicit, or rename the extraPage if both are wanted.`,
112
+ );
113
+ return true;
114
+ };
115
+
116
+ if (!claimed("intrinsics") && manifest.intrinsics && manifest.intrinsics.length > 0) {
100
117
  pages.set("intrinsics.mdx", generateIntrinsics(config, manifest));
101
118
  }
102
119
 
103
120
  if (
104
- !suppress.has("pseudo-parameters") &&
121
+ !claimed("pseudo-parameters") &&
105
122
  manifest.pseudoParameters &&
106
123
  Object.keys(manifest.pseudoParameters).length > 0
107
124
  ) {
@@ -111,14 +128,23 @@ export function docsPipeline(config: DocsConfig): DocsResult {
111
128
  );
112
129
  }
113
130
 
114
- if (!suppress.has("rules") && rules.length > 0) {
131
+ if (!claimed("rules") && rules.length > 0) {
115
132
  pages.set("rules.mdx", generateRules(config, rules));
116
133
  }
117
134
 
118
- if (!suppress.has("serialization")) {
135
+ if (!claimed("serialization")) {
119
136
  pages.set("serialization.mdx", generateSerialization(config));
120
137
  }
121
138
 
139
+ // Stamp every emitted page with its provenance. These files look exactly
140
+ // like the hand-written pages sitting beside them, and without a marker they
141
+ // get edited directly — the k8s "Live Cluster" sidebar group and the AWS
142
+ // intrinsics guide's #1044 claim were both fixed in the emitted `.mdx` and
143
+ // silently reverted by the next regen (#1312).
144
+ for (const [filename, content] of pages) {
145
+ pages.set(filename, withGeneratedMarker(config, content));
146
+ }
147
+
122
148
  return {
123
149
  pages,
124
150
  stats: {
@@ -131,6 +157,66 @@ export function docsPipeline(config: DocsConfig): DocsResult {
131
157
  };
132
158
  }
133
159
 
160
+ /**
161
+ * Insert a provenance comment directly after a page's frontmatter.
162
+ *
163
+ * MDX parses `<!-- -->` as JSX rather than a comment, so this uses the
164
+ * `{/* … *\/}` form the rest of the docs already use for generated markers.
165
+ */
166
+ function withGeneratedMarker(config: DocsConfig, content: string): string {
167
+ const marker = `{/* ${GENERATED_MARKER_TAG} by \`npm run docs -w @intentius/chant-lexicon-${config.name}\` — DO NOT EDIT.\n Edit lexicons/${config.name}/src/codegen/docs.ts instead; changes here are overwritten. */}`;
168
+ const lines = content.split("\n");
169
+ // Frontmatter is the leading `---` … `---` block; the marker goes after it.
170
+ if (lines[0] === "---") {
171
+ const close = lines.indexOf("---", 1);
172
+ if (close > 0) {
173
+ lines.splice(close + 1, 0, "", marker);
174
+ return lines.join("\n");
175
+ }
176
+ }
177
+ return `${marker}\n\n${content}`;
178
+ }
179
+
180
+ /**
181
+ * Marks a page as pipeline output. Used both to warn readers off editing the
182
+ * file and, in {@link writeDocsSite}, to tell a page this pipeline owns from a
183
+ * hand-written one when reaping pages it no longer emits.
184
+ */
185
+ export const GENERATED_MARKER_TAG = "GENERATED-BY-CHANT-DOCS";
186
+
187
+ /**
188
+ * Every slug a Starlight sidebar reaches, including nested group items.
189
+ */
190
+ export function collectSidebarSlugs(items: Array<Record<string, unknown>>): Set<string> {
191
+ const slugs = new Set<string>();
192
+ const walk = (list: Array<Record<string, unknown>>): void => {
193
+ for (const item of list) {
194
+ if (typeof item.slug === "string") slugs.add(item.slug);
195
+ if (Array.isArray(item.items)) walk(item.items as Array<Record<string, unknown>>);
196
+ }
197
+ };
198
+ walk(items);
199
+ return slugs;
200
+ }
201
+
202
+ /**
203
+ * Content pages that no sidebar entry points at.
204
+ *
205
+ * `index` is always the site root and never needs an entry of its own.
206
+ */
207
+ export function unreachablePages(
208
+ contentDir: string,
209
+ sidebar: Array<Record<string, unknown>>,
210
+ ): string[] {
211
+ if (!existsSync(contentDir)) return [];
212
+ const slugs = collectSidebarSlugs(sidebar);
213
+ return readdirSync(contentDir)
214
+ .filter((f) => f.endsWith(".mdx") || f.endsWith(".md"))
215
+ .map((f) => f.replace(/\.mdx?$/, ""))
216
+ .filter((slug) => slug !== "index" && !slugs.has(slug))
217
+ .sort();
218
+ }
219
+
134
220
  /**
135
221
  * Write generated docs pages to disk.
136
222
  */
@@ -161,6 +247,23 @@ export function writeDocsSite(config: DocsConfig, result: DocsResult): void {
161
247
  const filePath = join(contentDir, filename);
162
248
  rmSync(filePath, { force: true });
163
249
  }
250
+
251
+ // Reap pages this pipeline used to emit and no longer does. Suppressing a
252
+ // page only stops it being written; the copy from the last run stayed on
253
+ // disk, unreferenced by the sidebar and indistinguishable from a
254
+ // hand-written page — which is how azure, gcp and helm each kept a `rules`
255
+ // page after adopting their own (#1312). The provenance marker is what makes
256
+ // this safe: only a file this pipeline stamped is ever removed.
257
+ if (existsSync(contentDir)) {
258
+ for (const filename of readdirSync(contentDir)) {
259
+ if (!filename.endsWith(".mdx") && !filename.endsWith(".md")) continue;
260
+ if (result.pages.has(filename)) continue;
261
+ const filePath = join(contentDir, filename);
262
+ if (readFileSync(filePath, "utf-8").includes(GENERATED_MARKER_TAG)) {
263
+ rmSync(filePath, { force: true });
264
+ }
265
+ }
266
+ }
164
267
  rmSync(join(outDir, ".astro"), { recursive: true, force: true });
165
268
  rmSync(join(outDir, "node_modules", ".astro"), { recursive: true, force: true });
166
269
 
@@ -170,6 +273,20 @@ export function writeDocsSite(config: DocsConfig, result: DocsResult): void {
170
273
  // Build sidebar from generated pages
171
274
  const sidebar = buildSidebar(config, result);
172
275
 
276
+ // Starlight does not auto-discover pages, so a page absent from the sidebar
277
+ // is reachable only by typing its URL. The pipeline deliberately preserves
278
+ // hand-written pages it did not emit (above), which makes it easy to add one
279
+ // and never wire it up — azure, temporal, helm and github each accumulated
280
+ // several that way (#1312). Report them; `chant dev check-lexicon` gates on
281
+ // the same condition.
282
+ const unreachable = unreachablePages(contentDir, sidebar);
283
+ if (unreachable.length > 0) {
284
+ console.warn(
285
+ `[docs:${config.name}] ${unreachable.length} page(s) in no sidebar entry — reachable only by direct URL: ${unreachable.join(", ")}.\n` +
286
+ ` Declare them in this lexicon's DocsConfig \`sidebarExtra\` (or \`extraPages\`) to surface them.`,
287
+ );
288
+ }
289
+
173
290
  // package.json
174
291
  writeFileSync(
175
292
  join(outDir, "package.json"),
@@ -182,12 +182,26 @@ describe("fetchWithRetry", () => {
182
182
  expect(fetchMock).toHaveBeenCalledTimes(3);
183
183
  });
184
184
 
185
- test("calls fetch with no init argument when none is given", async () => {
185
+ test("bounds an attempt with a signal even when no init is given", async () => {
186
186
  const fetchMock = vi.fn().mockResolvedValue(ok());
187
187
  vi.stubGlobal("fetch", fetchMock);
188
188
 
189
189
  await fetchWithRetry("https://example.test/x", 4, 1);
190
- expect(fetchMock).toHaveBeenCalledWith("https://example.test/x");
190
+ // A hung connect would otherwise wait on the OS default, which is long
191
+ // enough to run a CI job past its timeout.
192
+ expect(fetchMock).toHaveBeenCalledWith(
193
+ "https://example.test/x",
194
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
195
+ );
196
+ });
197
+
198
+ test("a caller's own signal wins over the attempt bound", async () => {
199
+ const fetchMock = vi.fn().mockResolvedValue(ok());
200
+ vi.stubGlobal("fetch", fetchMock);
201
+
202
+ const controller = new AbortController();
203
+ await fetchWithRetry("https://example.test/x", 4, 1, { signal: controller.signal });
204
+ expect(fetchMock.mock.calls[0][1].signal).toBe(controller.signal);
191
205
  });
192
206
 
193
207
  test("passes request init through to fetch", async () => {
@@ -196,7 +210,10 @@ describe("fetchWithRetry", () => {
196
210
 
197
211
  const init = { headers: { Accept: "application/vnd.github+json" } };
198
212
  await fetchWithRetry("https://example.test/x", 4, 1, init);
199
- expect(fetchMock).toHaveBeenCalledWith("https://example.test/x", init);
213
+ expect(fetchMock).toHaveBeenCalledWith(
214
+ "https://example.test/x",
215
+ expect.objectContaining({ headers: init.headers, signal: expect.any(AbortSignal) }),
216
+ );
200
217
  });
201
218
 
202
219
  test("preserves init across retries on a transient status", async () => {
@@ -210,8 +227,10 @@ describe("fetchWithRetry", () => {
210
227
  const resp = await fetchWithRetry("https://example.test/x", 4, 1, init);
211
228
  expect(resp.ok).toBe(true);
212
229
  expect(fetchMock).toHaveBeenCalledTimes(2);
213
- expect(fetchMock).toHaveBeenNthCalledWith(1, "https://example.test/x", init);
214
- expect(fetchMock).toHaveBeenNthCalledWith(2, "https://example.test/x", init);
230
+ for (const call of fetchMock.mock.calls) {
231
+ expect(call[0]).toBe("https://example.test/x");
232
+ expect(call[1]).toEqual(expect.objectContaining({ headers: init.headers, signal: expect.any(AbortSignal) }));
233
+ }
215
234
  });
216
235
 
217
236
  test("does not retry a permanent status when init is given", async () => {
@@ -30,6 +30,20 @@ const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
30
30
 
31
31
  const DEFAULT_RETRIES = 4;
32
32
  const DEFAULT_BACKOFF_MS = 1000;
33
+ /**
34
+ * Per-attempt ceiling, so an upstream that accepts a connection and then stops
35
+ * answering cannot hold a build open.
36
+ *
37
+ * Without one, a hung connect waits on the OS default — which on Linux is over
38
+ * a minute — and five of those plus back-off is enough to run a CI job past its
39
+ * timeout. That is not hypothetical: it is what pushed chant's `check` job from
40
+ * ~9m30s to a cancellation at 10m, twice, on two unreachable spec endpoints
41
+ * whose results are only ever a fallback to a committed snapshot anyway.
42
+ *
43
+ * Generous enough for a slow-but-alive endpoint; the point is a bound, not
44
+ * speed.
45
+ */
46
+ const DEFAULT_ATTEMPT_TIMEOUT_MS = 15_000;
33
47
  /** Hard cap on Retry-After delays so a rogue header cannot stall CI indefinitely. */
34
48
  const MAX_RETRY_AFTER_MS = 60_000;
35
49
 
@@ -85,6 +99,7 @@ export async function fetchWithRetry(
85
99
  retries = DEFAULT_RETRIES,
86
100
  backoffMs = DEFAULT_BACKOFF_MS,
87
101
  init?: RequestInit,
102
+ attemptTimeoutMs = DEFAULT_ATTEMPT_TIMEOUT_MS,
88
103
  ): Promise<Response> {
89
104
  let lastStatus: number | undefined;
90
105
  let lastError: Error | undefined;
@@ -101,8 +116,11 @@ export async function fetchWithRetry(
101
116
  }
102
117
 
103
118
  let response: Response;
119
+ // A caller's own signal still wins; this only bounds an attempt that would
120
+ // otherwise hang with no signal at all.
121
+ const signal = init?.signal ?? AbortSignal.timeout(attemptTimeoutMs);
104
122
  try {
105
- response = init === undefined ? await fetch(url) : await fetch(url, init);
123
+ response = await fetch(url, { ...(init ?? {}), signal });
106
124
  } catch (e) {
107
125
  // Network-level failure (DNS, connection reset, timeout). Transient.
108
126
  lastError = e instanceof Error ? e : new Error(String(e));
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Publishing a package runs its prepack, and a prepack builds against and
3
+ * imports its workspace dependencies' *generated* output — output that only
4
+ * exists once that dependency has been published. So publish order has to be a
5
+ * topological order, and for a long time directory order stood in for one.
6
+ *
7
+ * It held until it did not. chant-v0.34.0 published twelve of fourteen packages
8
+ * and stranded lexicon-forgejo and lexicon-helm a version behind: forgejo needs
9
+ * github's `src/generated/index` and helm needs k8s's `dist/generated`, but
10
+ * `forgejo` < `github` and `helm` < `k8s`, so both ran before the thing they
11
+ * import existed. Half a release shipped and the failure only surfaced from
12
+ * npm, after the tag.
13
+ *
14
+ * publish-packages.sh now derives the order instead of assuming one. This
15
+ * asserts the derivation is actually topological, so the next lexicon that
16
+ * depends on an alphabetically earlier one fails here rather than mid-release.
17
+ */
18
+
19
+ import { describe, expect, it } from "vitest";
20
+ import { execFileSync } from "node:child_process";
21
+ import { readFileSync } from "node:fs";
22
+ import { join, dirname } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ const REPO = join(dirname(fileURLToPath(import.meta.url)), "../../../..");
26
+
27
+ /** Run one of publish-packages.sh's own functions and read back what it prints. */
28
+ function ask(fn: string): string[] {
29
+ const script = readFileSync(join(REPO, "scripts/publish-packages.sh"), "utf8");
30
+ const body = script.match(new RegExp(`^${fn}\\(\\) \\{$.*?^\\}$`, "ms"));
31
+ if (!body) throw new Error(`${fn}() not found in scripts/publish-packages.sh`);
32
+ return execFileSync("bash", ["-c", `${body[0]}\n${fn}`], { cwd: REPO, encoding: "utf8" })
33
+ .split("\n")
34
+ .filter(Boolean);
35
+ }
36
+
37
+ const publishOrder = () => ask("publishable_dirs");
38
+
39
+ function manifest(dir: string) {
40
+ return JSON.parse(readFileSync(join(REPO, dir, "package.json"), "utf8"));
41
+ }
42
+
43
+ describe("publish order", () => {
44
+ const order = publishOrder();
45
+
46
+ it("covers every publishable workspace package", () => {
47
+ // A package missing from the order never publishes at all, which is the
48
+ // same stranding by a different route.
49
+ const all = execFileSync(
50
+ "bash",
51
+ ["-c", 'for d in packages/*/ lexicons/*/; do [ -f "$d/package.json" ] && echo "${d%/}"; done'],
52
+ { cwd: REPO, encoding: "utf8" },
53
+ )
54
+ .split("\n")
55
+ .filter(Boolean)
56
+ .filter((d) => !manifest(d).private);
57
+
58
+ expect([...order].sort()).toEqual([...all].sort());
59
+ });
60
+
61
+ it("places every package after the workspace packages it depends on", () => {
62
+ const position = new Map(order.map((d, i) => [d, i]));
63
+ const owner = new Map(order.map((d) => [manifest(d).name as string, d]));
64
+
65
+ for (const dir of order) {
66
+ const pkg = manifest(dir);
67
+ const deps = Object.keys({
68
+ ...pkg.dependencies,
69
+ ...pkg.peerDependencies,
70
+ ...pkg.optionalDependencies,
71
+ });
72
+ for (const name of deps) {
73
+ const depDir = owner.get(name);
74
+ if (!depDir || depDir === dir) continue;
75
+ expect(
76
+ position.get(depDir)!,
77
+ `${pkg.name} (${dir}) publishes before its dependency ${name} (${depDir}), ` +
78
+ `so ${name}'s generated output will not exist when ${pkg.name} prepacks`,
79
+ ).toBeLessThan(position.get(dir)!);
80
+ }
81
+ }
82
+ });
83
+
84
+ it("puts the two packages that stranded chant-v0.34.0 after what they import", () => {
85
+ // The specific regression, named, so the failure says what broke rather
86
+ // than only that some invariant did.
87
+ expect(order.indexOf("lexicons/github")).toBeLessThan(order.indexOf("lexicons/forgejo"));
88
+ expect(order.indexOf("lexicons/k8s")).toBeLessThan(order.indexOf("lexicons/helm"));
89
+ });
90
+ });
91
+
92
+ /**
93
+ * Ordering alone did not fix the release. The rerun published nothing, found
94
+ * k8s already at 0.34.0, skipped it — and skipping the publish skipped the
95
+ * prepack that builds `dist/generated`, so helm failed on the same missing
96
+ * module. Anything another package compiles against has to be built whether or
97
+ * not it needs publishing.
98
+ */
99
+ describe("packages built even when their publish is skipped", () => {
100
+ const built = new Set(ask("depended_on_dirs"));
101
+ const order = publishOrder();
102
+
103
+ it("covers every workspace dependency, transitively", () => {
104
+ for (const dir of order) {
105
+ const pkg = manifest(dir);
106
+ const owner = new Map(order.map((d) => [manifest(d).name as string, d]));
107
+ for (const name of Object.keys({
108
+ ...pkg.dependencies,
109
+ ...pkg.peerDependencies,
110
+ ...pkg.optionalDependencies,
111
+ })) {
112
+ const depDir = owner.get(name);
113
+ if (!depDir || depDir === dir) continue;
114
+ expect(
115
+ built.has(depDir),
116
+ `${name} (${depDir}) is a dependency of ${pkg.name} but would not be built when ` +
117
+ `its own publish is skipped, so ${pkg.name} compiles against nothing`,
118
+ ).toBe(true);
119
+ }
120
+ }
121
+ });
122
+
123
+ it("includes the two whose skipped build failed the chant-v0.34.0 rerun", () => {
124
+ expect(built.has("lexicons/k8s")).toBe(true);
125
+ expect(built.has("lexicons/github")).toBe(true);
126
+ });
127
+
128
+ it("does not build a leaf nothing depends on", () => {
129
+ // The set is the reason a rerun is not a full 14-package rebuild.
130
+ expect(built.has("lexicons/helm")).toBe(false);
131
+ expect(built.has("lexicons/forgejo")).toBe(false);
132
+ });
133
+ });