@intentius/chant 0.124.23 → 0.124.24

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 (123) hide show
  1. package/dist/ci-jobs.d.ts +41 -1
  2. package/dist/ci-jobs.d.ts.map +1 -1
  3. package/dist/ci-jobs.js +48 -1
  4. package/dist/cli/commands/build.d.ts.map +1 -1
  5. package/dist/cli/commands/build.js +14 -0
  6. package/dist/cli/handlers/approve.d.ts.map +1 -1
  7. package/dist/cli/handlers/approve.js +9 -0
  8. package/dist/cli/handlers/emulator.d.ts +13 -2
  9. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  10. package/dist/cli/handlers/emulator.js +63 -3
  11. package/dist/cli/handlers/promote.d.ts.map +1 -1
  12. package/dist/cli/handlers/promote.js +7 -1
  13. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  14. package/dist/cli/handlers/run-wave.js +5 -4
  15. package/dist/cli/handlers/run.d.ts +4 -3
  16. package/dist/cli/handlers/run.d.ts.map +1 -1
  17. package/dist/cli/handlers/run.js +73 -9
  18. package/dist/cli/main.d.ts.map +1 -1
  19. package/dist/cli/main.js +17 -4
  20. package/dist/cli/parse-args.d.ts.map +1 -1
  21. package/dist/cli/parse-args.js +31 -0
  22. package/dist/cli/registry.d.ts +12 -0
  23. package/dist/cli/registry.d.ts.map +1 -1
  24. package/dist/components/cli-support.d.ts +45 -6
  25. package/dist/components/cli-support.d.ts.map +1 -1
  26. package/dist/components/cli-support.js +97 -22
  27. package/dist/components/driver.d.ts.map +1 -1
  28. package/dist/components/driver.js +25 -10
  29. package/dist/graph-detail.d.ts.map +1 -1
  30. package/dist/graph-detail.js +24 -2
  31. package/dist/lifecycle/git.d.ts +11 -0
  32. package/dist/lifecycle/git.d.ts.map +1 -1
  33. package/dist/lifecycle/git.js +62 -6
  34. package/dist/lifecycle/no-record.d.ts +59 -0
  35. package/dist/lifecycle/no-record.d.ts.map +1 -0
  36. package/dist/lifecycle/no-record.js +88 -0
  37. package/dist/op/activities/shell.d.ts.map +1 -1
  38. package/dist/op/activities/shell.js +30 -9
  39. package/dist/op/emulator-lifecycle.d.ts +23 -0
  40. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  41. package/dist/op/emulator-lifecycle.js +39 -2
  42. package/dist/op/gate.d.ts.map +1 -1
  43. package/dist/op/gate.js +3 -0
  44. package/dist/op/index.d.ts +1 -1
  45. package/dist/op/index.d.ts.map +1 -1
  46. package/dist/op/index.js +1 -1
  47. package/dist/op/local-executor.d.ts +10 -0
  48. package/dist/op/local-executor.d.ts.map +1 -1
  49. package/dist/op/local-executor.js +7 -2
  50. package/dist/op/op-waves-notify.d.ts +2 -0
  51. package/dist/op/op-waves-notify.d.ts.map +1 -1
  52. package/dist/op/op-waves-notify.js +9 -3
  53. package/dist/op/op-waves-run.d.ts +8 -2
  54. package/dist/op/op-waves-run.d.ts.map +1 -1
  55. package/dist/op/op-waves-run.js +36 -11
  56. package/dist/op/op-waves.d.ts +85 -1
  57. package/dist/op/op-waves.d.ts.map +1 -1
  58. package/dist/op/op-waves.js +153 -1
  59. package/dist/op/runtimes/local.d.ts.map +1 -1
  60. package/dist/op/runtimes/local.js +6 -0
  61. package/dist/op/step-console.d.ts +65 -0
  62. package/dist/op/step-console.d.ts.map +1 -0
  63. package/dist/op/step-console.js +140 -0
  64. package/dist/point-decision.d.ts.map +1 -1
  65. package/dist/point-decision.js +3 -0
  66. package/dist/wave-runtime.mjs +238 -238
  67. package/dist/workspace/compose-graph.d.ts +22 -3
  68. package/dist/workspace/compose-graph.d.ts.map +1 -1
  69. package/dist/workspace/compose-graph.js +18 -5
  70. package/dist/workspace/graph.schema.json +68 -1
  71. package/dist/workspace/nested-graph.d.ts +1 -1
  72. package/dist/workspace/nested-graph.d.ts.map +1 -1
  73. package/dist/workspace/nested-graph.js +5 -1
  74. package/dist/workspace/runs.d.ts.map +1 -1
  75. package/dist/workspace/runs.js +4 -2
  76. package/package.json +1 -1
  77. package/src/ci-jobs.ts +58 -1
  78. package/src/cli/commands/build.ts +18 -0
  79. package/src/cli/handlers/approve.ts +9 -0
  80. package/src/cli/handlers/emulator.test.ts +61 -1
  81. package/src/cli/handlers/emulator.ts +65 -4
  82. package/src/cli/handlers/promote.test.ts +7 -0
  83. package/src/cli/handlers/promote.ts +7 -1
  84. package/src/cli/handlers/run-no-record.e2e.test.ts +87 -0
  85. package/src/cli/handlers/run-wave.ts +5 -4
  86. package/src/cli/handlers/run.test.ts +126 -0
  87. package/src/cli/handlers/run.ts +69 -9
  88. package/src/cli/main.test.ts +24 -0
  89. package/src/cli/main.ts +17 -4
  90. package/src/cli/parse-args.ts +21 -0
  91. package/src/cli/registry.ts +12 -0
  92. package/src/components/cli-support.test.ts +75 -0
  93. package/src/components/cli-support.ts +130 -25
  94. package/src/components/driver.test.ts +28 -0
  95. package/src/components/driver.ts +23 -10
  96. package/src/components/generate-disabled.test.ts +56 -0
  97. package/src/graph-ci-edges.test.ts +50 -0
  98. package/src/graph-detail.ts +21 -2
  99. package/src/lifecycle/git.ts +61 -6
  100. package/src/lifecycle/no-record.test.ts +175 -0
  101. package/src/lifecycle/no-record.ts +108 -0
  102. package/src/op/activities/shell.ts +28 -6
  103. package/src/op/emulator-lifecycle.test.ts +24 -1
  104. package/src/op/emulator-lifecycle.ts +58 -2
  105. package/src/op/gate.ts +3 -0
  106. package/src/op/index.ts +1 -1
  107. package/src/op/local-executor.ts +20 -3
  108. package/src/op/op-waves-jobs.test.ts +213 -0
  109. package/src/op/op-waves-notify.ts +11 -5
  110. package/src/op/op-waves-run.ts +42 -11
  111. package/src/op/op-waves.ts +202 -2
  112. package/src/op/runtimes/local.ts +6 -0
  113. package/src/op/step-console.test.ts +150 -0
  114. package/src/op/step-console.ts +155 -0
  115. package/src/point-decision-bundle.test.ts +1 -0
  116. package/src/point-decision.ts +3 -0
  117. package/src/workspace/compose-graph.test.ts +55 -0
  118. package/src/workspace/compose-graph.ts +28 -6
  119. package/src/workspace/graph-contract.test.ts +26 -1
  120. package/src/workspace/graph.schema.json +68 -1
  121. package/src/workspace/nested-graph.test.ts +14 -0
  122. package/src/workspace/nested-graph.ts +7 -2
  123. package/src/workspace/runs.ts +4 -2
@@ -104,6 +104,61 @@ describe("composeWorkspaceGraph", () => {
104
104
  });
105
105
  });
106
106
 
107
+ describe("CI edges, pipelines and dangling references (#3922)", () => {
108
+ /** A github member as `chant graph` reports it after #3910. */
109
+ const ci = (workflow: string, job: string): GraphIR => ({
110
+ version: 1,
111
+ nodes: [
112
+ { id: workflow, kind: "Workflow", lexicon: "github", attrs: {} },
113
+ { id: job, kind: "Job", lexicon: "github", attrs: {} },
114
+ { id: "deploy", kind: "Job", lexicon: "github", attrs: {} },
115
+ ],
116
+ edges: [
117
+ { from: "deploy", to: job, kind: "needs", viaAttr: "needs" },
118
+ { from: job, to: workflow, kind: "calls", viaAttr: "uses", crossPipeline: true },
119
+ ],
120
+ groups: { byLexicon: { github: [job, "deploy", workflow] }, byPipeline: { ci: [job, "deploy", workflow] } },
121
+ dangling: [
122
+ { from: "deploy", kind: "needs", path: "needs", value: "lint" },
123
+ { from: job, kind: "calls", path: "uses", value: "other/repo/.github/workflows/x.yml@main", crossPipeline: true },
124
+ ],
125
+ });
126
+ const doc = composeWorkspaceGraph({ name: "acme", root: "." }, [
127
+ { member: member("svc", "svc"), ir: ci("ciWorkflow", "build") },
128
+ { member: member("app", "app"), ir: ci("ciWorkflow", "test") },
129
+ { member: member("db", "db"), ir: db },
130
+ ]);
131
+
132
+ test("edges keep their kind and crossPipeline", () => {
133
+ expect(doc.edges.filter((e) => e.member === "svc")).toEqual([
134
+ { from: "svc/build", to: "svc/ciWorkflow", kind: "calls", viaAttr: "uses", crossPipeline: true, member: "svc" },
135
+ { from: "svc/deploy", to: "svc/build", kind: "needs", viaAttr: "needs", member: "svc" },
136
+ ]);
137
+ });
138
+
139
+ test("byPipeline keys and ids are prefixed with the member", () => {
140
+ expect(doc.groups.byPipeline).toEqual({
141
+ "app/ci": ["app/ciWorkflow", "app/deploy", "app/test"],
142
+ "svc/ci": ["svc/build", "svc/ciWorkflow", "svc/deploy"],
143
+ });
144
+ });
145
+
146
+ test("dangling references keep their fields, with from prefixed and the member named, sorted", () => {
147
+ expect(doc.dangling).toEqual([
148
+ { from: "app/deploy", kind: "needs", path: "needs", value: "lint", member: "app" },
149
+ { from: "app/test", kind: "calls", path: "uses", value: "other/repo/.github/workflows/x.yml@main", crossPipeline: true, member: "app" },
150
+ { from: "svc/build", kind: "calls", path: "uses", value: "other/repo/.github/workflows/x.yml@main", crossPipeline: true, member: "svc" },
151
+ { from: "svc/deploy", kind: "needs", path: "needs", value: "lint", member: "svc" },
152
+ ]);
153
+ });
154
+
155
+ test("no dangling section and no byPipeline when no member reports either", () => {
156
+ const plain = composeWorkspaceGraph({ name: "acme", root: "." }, [{ member: member("db", "db"), ir: db }]);
157
+ expect("dangling" in plain).toBe(false);
158
+ expect(plain.groups.byPipeline).toBeUndefined();
159
+ });
160
+ });
161
+
107
162
  describe("the links section (#2539)", () => {
108
163
  test("inferred joins carry their label, and a declared link suppresses the inferred edge it covers", () => {
109
164
  const consumer: GraphIR = {
@@ -11,8 +11,12 @@
11
11
  * `runtimeOwner` and export and import nodes are rewritten the same way.
12
12
  * - `groups.byMember` maps each member to its node ids. `byLexicon` and
13
13
  * `byComposite` keep their keys (a lexicon or composite type is the same
14
- * thing in every member) and merge the ids. `byStack`, `byContainer` and
15
- * `byWave` name things inside one member, so their keys are prefixed too.
14
+ * thing in every member) and merge the ids. `byStack`, `byContainer`,
15
+ * `byWave` and `byPipeline` name things inside one member, so their keys
16
+ * are prefixed too.
17
+ * - Edges keep their `kind` and `crossPipeline` (#3910). A member's dangling
18
+ * references (`ir.dangling`) land on the top-level `dangling` list with
19
+ * `from` prefixed and a `member` field, like edges (#3922).
16
20
  * - `links` holds the member links (#2524 D6, #2539): the declared links and
17
21
  * the joins inferred from members' `imports` and `exports` with the core
18
22
  * `joinKey()`, labelled `exact` or `folded` (`./links.ts`). A declared link
@@ -36,7 +40,7 @@ import { graphLinks, type LinkTableRow } from "./links";
36
40
  import type { KindLinkRead } from "./kind-link-readers";
37
41
  import type { RecordLinkRow } from "./record-assets";
38
42
  import type { ReasonCode } from "./reason-codes";
39
- import { GRAPH_IR_VERSION, type GraphIR, type IRExport, type IRGroups, type IRImport, type IREdge, type IRNode } from "../graph-ir";
43
+ import { GRAPH_IR_VERSION, type GraphIR, type IRExport, type IRDanglingRef, type IRGroups, type IRImport, type IREdge, type IRNode } from "../graph-ir";
40
44
 
41
45
  /** The version of the composed document `chant workspace graph` writes. */
42
46
  export const WORKSPACE_GRAPH_VERSION = 1;
@@ -165,6 +169,8 @@ export const COLLECTOR_META_KEY = "collector";
165
169
  /** `nested` names the nested workspace's member a node was read through (#2551); absent for the outer workspace's own nodes. */
166
170
  export type ComposedNode = IRNode & { member: string; nested?: string };
167
171
  export type ComposedEdge = IREdge & { member: string };
172
+ /** A member's dangling reference (#3910, #3922): `from` is prefixed like a node id. `nested` as on {@link ComposedNode}. */
173
+ export type ComposedDangling = IRDanglingRef & { member: string; nested?: string };
168
174
 
169
175
  export interface WorkspaceGraph {
170
176
  version: number;
@@ -176,6 +182,12 @@ export interface WorkspaceGraph {
176
182
  exports: (IRExport & { member: string })[];
177
183
  imports: (IRImport & { member: string })[];
178
184
  derivedAttrs?: Record<string, string[]>;
185
+ /**
186
+ * The members' dangling CI references (#3910, #3922): each `ir.dangling`
187
+ * entry with `from` prefixed by its member and a `member` field, sorted by
188
+ * `from`, `path` and `value`. Absent when no member has one.
189
+ */
190
+ dangling?: ComposedDangling[];
179
191
  /**
180
192
  * Member links (#2524 D6, #2539), declared and inferred, then the links of
181
193
  * the records read with `--kind` (#2549). Empty when no declaration is given.
@@ -289,6 +301,13 @@ function prefixRefs(value: unknown, member: string): unknown {
289
301
  return out;
290
302
  }
291
303
 
304
+ /** The groups whose keys name something inside one member, so are prefixed with it. */
305
+ export const SCOPED_GROUPS = ["byStack", "byContainer", "byWave", "byPipeline"] as const;
306
+ type ScopedGroup = (typeof SCOPED_GROUPS)[number];
307
+
308
+ /** The order the composed `dangling` list is sorted in. */
309
+ export const danglingKey = (d: IRDanglingRef): string => `${d.from}\u0000${d.path}\u0000${d.value}`;
310
+
292
311
  function mergeInto(target: Record<string, string[]>, key: string, ids: string[]): void {
293
312
  (target[key] ??= []).push(...ids);
294
313
  }
@@ -317,7 +336,8 @@ export function composeWorkspaceGraph(
317
336
  const imports: WorkspaceGraph["imports"] = [];
318
337
  const byMember: Record<string, string[]> = {};
319
338
  const shared: Record<"byLexicon" | "byComposite", Record<string, string[]>> = { byLexicon: {}, byComposite: {} };
320
- const scoped: Record<"byStack" | "byContainer" | "byWave", Record<string, string[]>> = { byStack: {}, byContainer: {}, byWave: {} };
339
+ const scoped: Record<ScopedGroup, Record<string, string[]>> = { byStack: {}, byContainer: {}, byWave: {}, byPipeline: {} };
340
+ const dangling: ComposedDangling[] = [];
321
341
  const derivedAttrs: Record<string, Set<string>> = {};
322
342
  const collectors: MemberCollector[] = [];
323
343
 
@@ -337,9 +357,10 @@ export function composeWorkspaceGraph(
337
357
  for (const key of ["byLexicon", "byComposite"] as const) {
338
358
  for (const [k, v] of Object.entries(ir.groups[key] ?? {})) mergeInto(shared[key], k, v.map((id) => prefix(m, id)));
339
359
  }
340
- for (const key of ["byStack", "byContainer", "byWave"] as const) {
360
+ for (const key of SCOPED_GROUPS) {
341
361
  for (const [k, v] of Object.entries(ir.groups[key] ?? {})) mergeInto(scoped[key], prefix(m, k), v.map((id) => prefix(m, id)));
342
362
  }
363
+ for (const d of ir.dangling ?? []) dangling.push({ ...d, from: prefix(m, d.from), member: m });
343
364
  for (const e of ir.exports ?? []) exports.push({ ...e, ...(e.node ? { node: prefix(m, e.node) } : {}), member: m });
344
365
  for (const i of ir.imports ?? []) imports.push({ ...i, node: prefix(m, i.node), member: m });
345
366
  for (const [kind, attrs] of Object.entries(ir.derivedAttrs ?? {})) for (const a of attrs) (derivedAttrs[kind] ??= new Set()).add(a);
@@ -360,7 +381,7 @@ export function composeWorkspaceGraph(
360
381
 
361
382
  const groups: WorkspaceGraph["groups"] = { byMember };
362
383
  for (const key of ["byLexicon", "byComposite"] as const) if (Object.keys(shared[key]).length) groups[key] = sorted(shared[key]);
363
- for (const key of ["byStack", "byContainer", "byWave"] as const) if (Object.keys(scoped[key]).length) groups[key] = sorted(scoped[key]);
384
+ for (const key of SCOPED_GROUPS) if (Object.keys(scoped[key]).length) groups[key] = sorted(scoped[key]);
364
385
 
365
386
  const doc: WorkspaceGraph = {
366
387
  version: WORKSPACE_GRAPH_VERSION,
@@ -377,6 +398,7 @@ export function composeWorkspaceGraph(
377
398
  collectors,
378
399
  records: [],
379
400
  };
401
+ if (dangling.length) doc.dangling = dangling.sort((a, b) => danglingKey(a).localeCompare(danglingKey(b)));
380
402
  if (Object.keys(derivedAttrs).length) {
381
403
  doc.derivedAttrs = Object.fromEntries(Object.keys(derivedAttrs).sort().map((k) => [k, [...derivedAttrs[k]].sort()]));
382
404
  }
@@ -15,7 +15,7 @@ import { existsSync, readdirSync, readFileSync, rmSync, utimesSync, writeFileSyn
15
15
  import { join } from "node:path";
16
16
  import { afterAll, describe, expect, test } from "vitest";
17
17
  import { cleanScratch, commitAll, contract, declaration, declaration as declaration_, FAKE_FILE_GRAPH_CHANT, FAKE_GRAPH_CHANT, git, REPO, repo, scratchDir, validSchema } from "./__fixtures__/contract-repo";
18
- import type { GraphIR } from "../graph-ir";
18
+ import type { GraphIR, IREdgeKind } from "../graph-ir";
19
19
  import { composeWorkspaceGraph, MEMBER_RUN_REASON_CODES } from "./compose-graph";
20
20
  import { parseDeclaration, WORKSPACE_ERROR_CODES } from "./declaration";
21
21
  import type { LinkTableRow } from "./links";
@@ -106,6 +106,31 @@ describe("the links section (#2539)", () => {
106
106
  });
107
107
  });
108
108
 
109
+ describe("CI edges, pipelines and dangling references (#3922)", () => {
110
+ test("the edge kinds match IREdgeKind, and a composed CI member validates", () => {
111
+ const kinds: IREdgeKind[] = ["ref", "needs", "calls", "triggers", "includes", "extends"];
112
+ expect(schema.$defs.edge.properties.kind.enum).toEqual(kinds);
113
+ const ir: GraphIR = {
114
+ version: 1,
115
+ nodes: [
116
+ { id: "ci", kind: "Workflow", lexicon: "github", attrs: {} },
117
+ { id: "build", kind: "Job", lexicon: "github", attrs: {} },
118
+ { id: "deploy", kind: "Job", lexicon: "github", attrs: {} },
119
+ ],
120
+ edges: [
121
+ { from: "deploy", to: "build", kind: "needs", viaAttr: "needs" },
122
+ { from: "build", to: "ci", kind: "calls", viaAttr: "uses", crossPipeline: true },
123
+ ],
124
+ groups: { byPipeline: { ci: ["build", "ci", "deploy"] } },
125
+ dangling: [{ from: "deploy", kind: "needs", path: "needs", value: "lint" }, { from: "build", kind: "calls", path: "uses", value: "o/r/.github/workflows/x.yml@v1", crossPipeline: true }],
126
+ };
127
+ const m = { name: "svc", dir: "svc", kind: "chant", status: "composed" as const, reason: null, chant: "0.124.20", irVersion: 1, live: false };
128
+ const graph = composeWorkspaceGraph({ name: "acme", root: "." }, [{ member: m, ir }]);
129
+ expect(graph.dangling).toHaveLength(2);
130
+ expectValid({ $schema: GRAPH_OUTPUT_SCHEMA_ID, contract: 1, chant: "0.124.20", at: null, ...graph });
131
+ });
132
+ });
133
+
109
134
  describe("the collectors section (#2559)", () => {
110
135
  const topology = {
111
136
  pipelines: [{ id: "traces", signal: "traces", receivers: ["otlp"], processors: [], exporters: ["otlphttp"] }],
@@ -93,6 +93,13 @@
93
93
  "$ref": "#/$defs/edge"
94
94
  }
95
95
  },
96
+ "dangling": {
97
+ "description": "Added in contract 1 by #3922. The members' references whose target is no node of their graph (#3910): a job `needs` a job no workflow declares, a `uses:` names another repository's workflow, a GitLab `include:` names a file chant did not generate. Each member's `ir.dangling` entry with `from` prefixed by its member and a `member` field, sorted by `from`, `path` and `value`. Absent when no member has one.",
98
+ "type": "array",
99
+ "items": {
100
+ "$ref": "#/$defs/dangling"
101
+ }
102
+ },
96
103
  "groups": {
97
104
  "type": "object",
98
105
  "required": [
@@ -158,6 +165,16 @@
158
165
  }
159
166
  },
160
167
  "description": "Keys prefixed with the member."
168
+ },
169
+ "byPipeline": {
170
+ "type": "object",
171
+ "additionalProperties": {
172
+ "type": "array",
173
+ "items": {
174
+ "type": "string"
175
+ }
176
+ },
177
+ "description": "Added in contract 1 by #3922. CI pipeline membership (#3910): a workflow or pipeline the member's CI lexicon reports, with the node ids it runs. Keys prefixed with the member, like byStack. Edges with `crossPipeline` run between these groups."
161
178
  }
162
179
  }
163
180
  },
@@ -523,7 +540,15 @@
523
540
  "type": "string"
524
541
  },
525
542
  "kind": {
526
- "type": "string"
543
+ "enum": [
544
+ "ref",
545
+ "needs",
546
+ "calls",
547
+ "triggers",
548
+ "includes",
549
+ "extends"
550
+ ],
551
+ "description": "`ref` for an edge from a typed reference. The CI kinds (#3910): `needs` (a job waits on another), `calls` (a reusable workflow or action is used), `triggers` (a pipeline starts another), `includes` (a file is pulled in), `extends` (a GitLab job inherits another). The list grows within contract 1; a reader ignores a kind it does not know."
527
552
  },
528
553
  "viaAttr": {
529
554
  "type": "string"
@@ -531,11 +556,53 @@
531
556
  "toAttr": {
532
557
  "type": "string"
533
558
  },
559
+ "crossPipeline": {
560
+ "type": "boolean",
561
+ "description": "Added in contract 1 by #3922. True when the edge leaves the pipeline `from` belongs to (#3910): a reusable-workflow call, a `workflow_run` trigger, a GitLab child or multi-project trigger, an include. Absent on every in-pipeline edge."
562
+ },
534
563
  "member": {
535
564
  "type": "string"
536
565
  }
537
566
  }
538
567
  },
568
+ "dangling": {
569
+ "type": "object",
570
+ "required": [
571
+ "from",
572
+ "kind",
573
+ "path",
574
+ "value",
575
+ "member"
576
+ ],
577
+ "properties": {
578
+ "from": {
579
+ "type": "string",
580
+ "description": "The node holding the reference, prefixed with its member."
581
+ },
582
+ "kind": {
583
+ "$ref": "#/$defs/edge/properties/kind"
584
+ },
585
+ "path": {
586
+ "type": "string",
587
+ "description": "Where on `from` it was written, such as `needs`, `uses` or `trigger.include`."
588
+ },
589
+ "value": {
590
+ "type": "string",
591
+ "description": "The reference exactly as written."
592
+ },
593
+ "crossPipeline": {
594
+ "type": "boolean",
595
+ "description": "As on an edge."
596
+ },
597
+ "member": {
598
+ "type": "string"
599
+ },
600
+ "nested": {
601
+ "type": "string",
602
+ "description": "For a member of kind `workspace`: the nested workspace's member the reference was read through, as on a node."
603
+ }
604
+ }
605
+ },
539
606
  "failure": {
540
607
  "description": "The declaration could not be read, and nothing was composed. Exit code 1.",
541
608
  "type": "object",
@@ -97,6 +97,20 @@ describe("foldNested", () => {
97
97
  });
98
98
  });
99
99
 
100
+ describe("foldNested with CI pipelines and dangling references (#3922)", () => {
101
+ test("prefixes byPipeline and dangling with the outer member, and keeps the nested member on each reference", () => {
102
+ const outer = composeWorkspaceGraph({ name: "outer", root: "." }, [
103
+ { member: member("platform", { kind: "workspace", status: "skipped", reason: { code: "kind-not-run", message: "nested" }, chant: null, irVersion: null }) },
104
+ ]);
105
+ const inner = innerGraph();
106
+ inner.groups.byPipeline = { "api/ci": ["api/Queue"] };
107
+ inner.dangling = [{ from: "api/Queue", kind: "needs", path: "needs", value: "lint", member: "api" }];
108
+ foldNested(outer, outer.members[0], inner);
109
+ expect(outer.groups.byPipeline).toEqual({ "platform/api/ci": ["platform/api/Queue"] });
110
+ expect(outer.dangling).toEqual([{ from: "platform/api/Queue", kind: "needs", path: "needs", value: "lint", member: "platform", nested: "api" }]);
111
+ });
112
+ });
113
+
100
114
  describe("the outer workspace never writes inside a nested one", () => {
101
115
  let root: string;
102
116
  afterEach(() => rmSync(root, { recursive: true, force: true }));
@@ -22,7 +22,7 @@
22
22
 
23
23
  import { spawn } from "node:child_process";
24
24
  import type { ParsedArgs } from "../cli/registry";
25
- import type { ComposedMember, ComposedNode, WorkspaceGraph } from "./compose-graph";
25
+ import { danglingKey, SCOPED_GROUPS, type ComposedMember, type ComposedNode, type WorkspaceGraph } from "./compose-graph";
26
26
 
27
27
  export type { NestedWorkspace } from "./compose-graph";
28
28
  import { readerToolchain, type Toolchain } from "./member-commands";
@@ -148,12 +148,17 @@ export function foldNested(graph: WorkspaceGraph, member: ComposedMember, inner:
148
148
  into[k] = [...(into[k] ?? []), ...v.map((id) => prefixed(outer, id))].sort();
149
149
  }
150
150
  }
151
- for (const key of ["byStack", "byContainer", "byWave"] as const) {
151
+ for (const key of SCOPED_GROUPS) {
152
152
  for (const [k, v] of Object.entries(innerGroups[key] ?? {})) {
153
153
  const into = (groups[key] ??= {});
154
154
  into[prefixed(outer, k)] = v.map((id) => prefixed(outer, id)).sort();
155
155
  }
156
156
  }
157
+ if (inner.dangling?.length) {
158
+ graph.dangling = [...(graph.dangling ?? []), ...inner.dangling.map((d) => ({ ...d, from: prefixed(outer, d.from), member: outer, nested: d.member }))].sort((a, b) =>
159
+ danglingKey(a).localeCompare(danglingKey(b)),
160
+ );
161
+ }
157
162
  for (const c of inner.collectors ?? []) graph.collectors.push({ ...c, member: prefixed(outer, c.member) });
158
163
  for (const [kind, attrs] of Object.entries(inner.derivedAttrs ?? {})) {
159
164
  const all = new Set([...(graph.derivedAttrs?.[kind] ?? []), ...attrs]);
@@ -55,7 +55,7 @@ import { createHash, randomBytes } from "node:crypto";
55
55
  import { readFileSync, statSync } from "node:fs";
56
56
  import { resolve } from "node:path";
57
57
  import { z } from "zod";
58
- import { describeLifecyclePush, fetchLifecycleStatus, type LifecyclePushStatus, pushLifecycleStatus, readBlobBySha, RefCASConflictError, writeLedgerFiles } from "../lifecycle/git";
58
+ import { describeLifecyclePush, fetchLifecycleStatus, ledgerRevision, type LifecyclePushStatus, pushLifecycleStatus, readBlobBySha, RefCASConflictError, writeLedgerFiles } from "../lifecycle/git";
59
59
  import { resolveMemberLedger } from "../lifecycle/member-ledger";
60
60
  import { sortedJsonReplacer } from "../utils";
61
61
  import { followSquashes, type SquashFollow } from "./squash";
@@ -509,7 +509,9 @@ async function appendRun(top: string, rootOnDisk: string, id: string, make: (exi
509
509
  let written: RunLine[] = [];
510
510
  let lastErr: unknown;
511
511
  for (let attempt = 1; attempt <= APPEND_RETRY_ATTEMPTS && commit === undefined; attempt++) {
512
- const prior = git(top, ["rev-parse", "--verify", "--quiet", `refs/heads/${LEDGER_BRANCH}:${path}`])?.trim() || null;
512
+ // #3955: in a run that records nothing, the run's own unrecorded tip.
513
+ const rev = await ledgerRevision(top);
514
+ const prior = git(top, ["rev-parse", "--verify", "--quiet", `${rev === LEDGER_BRANCH ? `refs/heads/${LEDGER_BRANCH}` : rev}:${path}`])?.trim() || null;
513
515
  const text = prior ? ((await readBlobBySha(prior, { cwd: top })) ?? "") : "";
514
516
  const existing = parseRunFile(text, id).lines;
515
517
  const lines = make(existing);