@intentius/chant 0.124.22 → 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 (187) hide show
  1. package/dist/audit/rules-doc.js +1 -1
  2. package/dist/ci-jobs.d.ts +41 -1
  3. package/dist/ci-jobs.d.ts.map +1 -1
  4. package/dist/ci-jobs.js +48 -1
  5. package/dist/cli/commands/build.d.ts.map +1 -1
  6. package/dist/cli/commands/build.js +14 -0
  7. package/dist/cli/handlers/approve.d.ts.map +1 -1
  8. package/dist/cli/handlers/approve.js +9 -0
  9. package/dist/cli/handlers/emulator.d.ts +13 -2
  10. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  11. package/dist/cli/handlers/emulator.js +63 -3
  12. package/dist/cli/handlers/graph.js +24 -1
  13. package/dist/cli/handlers/promote.d.ts.map +1 -1
  14. package/dist/cli/handlers/promote.js +7 -1
  15. package/dist/cli/handlers/run-wave.d.ts.map +1 -1
  16. package/dist/cli/handlers/run-wave.js +5 -4
  17. package/dist/cli/handlers/run.d.ts +4 -3
  18. package/dist/cli/handlers/run.d.ts.map +1 -1
  19. package/dist/cli/handlers/run.js +73 -9
  20. package/dist/cli/main.d.ts.map +1 -1
  21. package/dist/cli/main.js +17 -4
  22. package/dist/cli/parse-args.d.ts.map +1 -1
  23. package/dist/cli/parse-args.js +31 -0
  24. package/dist/cli/registry.d.ts +12 -0
  25. package/dist/cli/registry.d.ts.map +1 -1
  26. package/dist/components/cli-support.d.ts +45 -6
  27. package/dist/components/cli-support.d.ts.map +1 -1
  28. package/dist/components/cli-support.js +97 -22
  29. package/dist/components/driver.d.ts.map +1 -1
  30. package/dist/components/driver.js +25 -10
  31. package/dist/graph-detail.d.ts.map +1 -1
  32. package/dist/graph-detail.js +24 -2
  33. package/dist/graph-ir.d.ts +43 -0
  34. package/dist/graph-ir.d.ts.map +1 -1
  35. package/dist/graph-ir.js +27 -0
  36. package/dist/lexicon.d.ts +25 -1
  37. package/dist/lexicon.d.ts.map +1 -1
  38. package/dist/lifecycle/git.d.ts +11 -0
  39. package/dist/lifecycle/git.d.ts.map +1 -1
  40. package/dist/lifecycle/git.js +62 -6
  41. package/dist/lifecycle/no-record.d.ts +59 -0
  42. package/dist/lifecycle/no-record.d.ts.map +1 -0
  43. package/dist/lifecycle/no-record.js +88 -0
  44. package/dist/op/activities/shell.d.ts.map +1 -1
  45. package/dist/op/activities/shell.js +30 -9
  46. package/dist/op/emulator-lifecycle.d.ts +23 -0
  47. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  48. package/dist/op/emulator-lifecycle.js +39 -2
  49. package/dist/op/gate.d.ts.map +1 -1
  50. package/dist/op/gate.js +3 -0
  51. package/dist/op/index.d.ts +1 -1
  52. package/dist/op/index.d.ts.map +1 -1
  53. package/dist/op/index.js +1 -1
  54. package/dist/op/local-executor.d.ts +10 -0
  55. package/dist/op/local-executor.d.ts.map +1 -1
  56. package/dist/op/local-executor.js +7 -2
  57. package/dist/op/op-waves-notify.d.ts +2 -0
  58. package/dist/op/op-waves-notify.d.ts.map +1 -1
  59. package/dist/op/op-waves-notify.js +9 -3
  60. package/dist/op/op-waves-run.d.ts +8 -2
  61. package/dist/op/op-waves-run.d.ts.map +1 -1
  62. package/dist/op/op-waves-run.js +36 -11
  63. package/dist/op/op-waves.d.ts +85 -1
  64. package/dist/op/op-waves.d.ts.map +1 -1
  65. package/dist/op/op-waves.js +153 -1
  66. package/dist/op/runtimes/local.d.ts.map +1 -1
  67. package/dist/op/runtimes/local.js +6 -0
  68. package/dist/op/step-console.d.ts +65 -0
  69. package/dist/op/step-console.d.ts.map +1 -0
  70. package/dist/op/step-console.js +140 -0
  71. package/dist/point-decision.d.ts.map +1 -1
  72. package/dist/point-decision.js +3 -0
  73. package/dist/wave-runtime.mjs +240 -224
  74. package/dist/workspace/checks/links.d.ts +5 -0
  75. package/dist/workspace/checks/links.d.ts.map +1 -1
  76. package/dist/workspace/checks/links.js +54 -8
  77. package/dist/workspace/checks.d.ts +4 -1
  78. package/dist/workspace/checks.d.ts.map +1 -1
  79. package/dist/workspace/checks.js +6 -2
  80. package/dist/workspace/compose-graph.d.ts +26 -4
  81. package/dist/workspace/compose-graph.d.ts.map +1 -1
  82. package/dist/workspace/compose-graph.js +21 -7
  83. package/dist/workspace/dbt-project-reader.d.ts +36 -0
  84. package/dist/workspace/dbt-project-reader.d.ts.map +1 -0
  85. package/dist/workspace/dbt-project-reader.js +290 -0
  86. package/dist/workspace/graph-cli.d.ts.map +1 -1
  87. package/dist/workspace/graph-cli.js +3 -1
  88. package/dist/workspace/graph.schema.json +109 -4
  89. package/dist/workspace/intent.js +7 -2
  90. package/dist/workspace/kind-link-readers.d.ts +84 -0
  91. package/dist/workspace/kind-link-readers.d.ts.map +1 -0
  92. package/dist/workspace/kind-link-readers.js +53 -0
  93. package/dist/workspace/kinds.d.ts +15 -0
  94. package/dist/workspace/kinds.d.ts.map +1 -1
  95. package/dist/workspace/kinds.js +11 -0
  96. package/dist/workspace/lexicon-outputs.d.ts +63 -0
  97. package/dist/workspace/lexicon-outputs.d.ts.map +1 -0
  98. package/dist/workspace/lexicon-outputs.js +241 -0
  99. package/dist/workspace/links.d.ts +29 -7
  100. package/dist/workspace/links.d.ts.map +1 -1
  101. package/dist/workspace/links.js +109 -34
  102. package/dist/workspace/member-handles.d.ts +18 -1
  103. package/dist/workspace/member-handles.d.ts.map +1 -1
  104. package/dist/workspace/member-handles.js +31 -14
  105. package/dist/workspace/nested-graph.d.ts +1 -1
  106. package/dist/workspace/nested-graph.d.ts.map +1 -1
  107. package/dist/workspace/nested-graph.js +5 -1
  108. package/dist/workspace/records-checkout.d.ts +11 -1
  109. package/dist/workspace/records-checkout.d.ts.map +1 -1
  110. package/dist/workspace/records-checkout.js +38 -2
  111. package/dist/workspace/runs.d.ts.map +1 -1
  112. package/dist/workspace/runs.js +4 -2
  113. package/dist/workspace/status.d.ts.map +1 -1
  114. package/dist/workspace/status.js +3 -2
  115. package/dist/workspace/workspace-kinds.schema.json +5 -0
  116. package/package.json +1 -1
  117. package/src/audit/rules-doc.ts +1 -1
  118. package/src/ci-jobs.ts +58 -1
  119. package/src/cli/commands/build.ts +18 -0
  120. package/src/cli/handlers/approve.ts +9 -0
  121. package/src/cli/handlers/emulator.test.ts +61 -1
  122. package/src/cli/handlers/emulator.ts +65 -4
  123. package/src/cli/handlers/graph.ts +22 -1
  124. package/src/cli/handlers/promote.test.ts +7 -0
  125. package/src/cli/handlers/promote.ts +7 -1
  126. package/src/cli/handlers/run-no-record.e2e.test.ts +87 -0
  127. package/src/cli/handlers/run-wave.ts +5 -4
  128. package/src/cli/handlers/run.test.ts +126 -0
  129. package/src/cli/handlers/run.ts +69 -9
  130. package/src/cli/main.test.ts +24 -0
  131. package/src/cli/main.ts +17 -4
  132. package/src/cli/parse-args.ts +21 -0
  133. package/src/cli/registry.ts +12 -0
  134. package/src/components/cli-support.test.ts +75 -0
  135. package/src/components/cli-support.ts +130 -25
  136. package/src/components/driver.test.ts +28 -0
  137. package/src/components/driver.ts +23 -10
  138. package/src/components/generate-disabled.test.ts +56 -0
  139. package/src/graph-ci-edges.test.ts +50 -0
  140. package/src/graph-detail.ts +21 -2
  141. package/src/graph-ir.test.ts +23 -1
  142. package/src/graph-ir.ts +65 -0
  143. package/src/lexicon.ts +26 -1
  144. package/src/lifecycle/git.ts +61 -6
  145. package/src/lifecycle/no-record.test.ts +175 -0
  146. package/src/lifecycle/no-record.ts +108 -0
  147. package/src/op/activities/shell.ts +28 -6
  148. package/src/op/emulator-lifecycle.test.ts +24 -1
  149. package/src/op/emulator-lifecycle.ts +58 -2
  150. package/src/op/gate.ts +3 -0
  151. package/src/op/index.ts +1 -1
  152. package/src/op/local-executor.ts +20 -3
  153. package/src/op/op-waves-jobs.test.ts +213 -0
  154. package/src/op/op-waves-notify.ts +11 -5
  155. package/src/op/op-waves-run.ts +42 -11
  156. package/src/op/op-waves.ts +202 -2
  157. package/src/op/runtimes/local.ts +6 -0
  158. package/src/op/step-console.test.ts +150 -0
  159. package/src/op/step-console.ts +155 -0
  160. package/src/point-decision-bundle.test.ts +1 -0
  161. package/src/point-decision.ts +3 -0
  162. package/src/wave-loop-golden.test.ts +3 -0
  163. package/src/workspace/checks/links.ts +57 -8
  164. package/src/workspace/checks.e2e.test.ts +1 -1
  165. package/src/workspace/checks.ts +9 -2
  166. package/src/workspace/compose-graph.test.ts +55 -0
  167. package/src/workspace/compose-graph.ts +33 -9
  168. package/src/workspace/dbt-kind.e2e.test.ts +95 -0
  169. package/src/workspace/dbt-kind.test.ts +283 -0
  170. package/src/workspace/dbt-project-reader.ts +291 -0
  171. package/src/workspace/graph-cli.ts +3 -1
  172. package/src/workspace/graph-contract.test.ts +26 -1
  173. package/src/workspace/graph.schema.json +109 -4
  174. package/src/workspace/intent.ts +7 -2
  175. package/src/workspace/kind-link-readers.ts +109 -0
  176. package/src/workspace/kinds.ts +21 -1
  177. package/src/workspace/lexicon-outputs.ts +272 -0
  178. package/src/workspace/links.test.ts +22 -0
  179. package/src/workspace/links.ts +129 -34
  180. package/src/workspace/member-handles.ts +28 -8
  181. package/src/workspace/nested-graph.test.ts +14 -0
  182. package/src/workspace/nested-graph.ts +7 -2
  183. package/src/workspace/records-checkout.ts +36 -2
  184. package/src/workspace/records-uncommitted.test.ts +5 -1
  185. package/src/workspace/runs.ts +4 -2
  186. package/src/workspace/status.ts +3 -2
  187. package/src/workspace/workspace-kinds.schema.json +5 -0
@@ -15,7 +15,11 @@
15
15
  * that parameter give way to the declared one, which is how an inferred edge
16
16
  * is promoted.
17
17
  *
18
- * Every row says how it was found (`declared` or `inferred:joinKey`), where it
18
+ * A member whose kind has a link reader (#3932, `./kind-link-readers.ts`) also
19
+ * states links in its own files, such as a dbt project's sources. Those rows
20
+ * have origin `kind-reader` and carry the file and line that state them.
21
+ *
22
+ * Every row says how it was found (`declared`, `kind-reader` or `inferred:joinKey`), where it
19
23
  * resolved (`source`, or `live` for the rows of `check --live`, #2549) and its status. A link that
20
24
  * can't be resolved is kept, with the reason.
21
25
  *
@@ -30,6 +34,7 @@ import { joinKey, joinLabel, type JoinLabel } from "../join-key";
30
34
  import type { Declaration, LinkDeclaration, Member } from "./declaration";
31
35
  import type { KindRegistry, MemberKind } from "./kinds";
32
36
  import type { MemberCollector } from "./compose-graph";
37
+ import type { KindLinkRead, KindReadLink } from "./kind-link-readers";
33
38
 
34
39
  /**
35
40
  * The link kinds chant knows. Closed: a link of any other kind fails closed.
@@ -46,14 +51,20 @@ export const TELEMETRY_LINK_KIND = "telemetry";
46
51
  /** The OTLP protocols a telemetry link may state, as `OTEL_EXPORTER_OTLP_PROTOCOL` names them. */
47
52
  export const TELEMETRY_PROTOCOLS = ["grpc", "http/protobuf", "http/json"] as const;
48
53
 
49
- /** How a row was found: written down, or inferred by the named rule. */
50
- export type LinkOrigin = "declared" | "inferred:joinKey";
54
+ /**
55
+ * How a row was found: written down in the declaration, read from the
56
+ * member's own files by its kind's link reader (#3932), or inferred by the
57
+ * named rule.
58
+ */
59
+ export type LinkOrigin = "declared" | "kind-reader" | "inferred:joinKey";
51
60
 
52
61
  /** One output or parameter of a member, as a join sees it. */
53
62
  export interface JoinHandle {
54
63
  name: string;
55
64
  /** The node that holds it, when the reader knows one (a graph IR id). */
56
65
  node?: string;
66
+ /** Names it had before, from a rename hint in the producer's source (#3931). */
67
+ previously?: readonly string[];
57
68
  }
58
69
 
59
70
  /** What a member offers links, and what it reads. */
@@ -80,6 +91,10 @@ interface RowBase {
80
91
  resolves: "source" | "live";
81
92
  /** Why the row is not `resolved`, or null. */
82
93
  reason: string | null;
94
+ /** A `kind-reader` row (#3932): the reader, and the file (from the workspace root) and line that state the link. */
95
+ reader?: string;
96
+ file?: string;
97
+ line?: number;
83
98
  }
84
99
 
85
100
  /** A declared link, or an inferred join with one producer. */
@@ -113,7 +128,11 @@ export interface TelemetryTargets {
113
128
  composed: ReadonlySet<string>;
114
129
  }
115
130
 
116
- /** A parameter that joins outputs of two or more producers (or two outputs of one). */
131
+ /**
132
+ * A parameter that joins outputs of two or more producers (or two outputs of
133
+ * one). For a `kind-reader` row whose file names no producer, `input` is the
134
+ * output it reads, and the candidates are the members that could produce it.
135
+ */
117
136
  export interface AmbiguousRow extends RowBase {
118
137
  status: "ambiguous";
119
138
  input: string;
@@ -170,9 +189,16 @@ function nearNames(name: string, outputs: JoinHandle[]): string[] {
170
189
  * Resolve every declared link and infer the joins nobody wrote down.
171
190
  * `handles` holds one entry per member whose handles were read; a member
172
191
  * missing from it is unresolved. Declared rows come first, in declaration
173
- * order; inferred and ambiguous rows follow, sorted by consumer and parameter.
192
+ * order; then the rows each member's kind reader found (`readLinks`, #3932),
193
+ * in member and file order; inferred and ambiguous rows follow, sorted by
194
+ * consumer and parameter.
174
195
  */
175
- export function resolveLinks(declaration: Declaration, handles: ReadonlyMap<string, MemberHandles>, telemetry?: TelemetryTargets): LinkTableRow[] {
196
+ export function resolveLinks(
197
+ declaration: Declaration,
198
+ handles: ReadonlyMap<string, MemberHandles>,
199
+ telemetry?: TelemetryTargets,
200
+ readLinks?: ReadonlyMap<string, KindLinkRead>,
201
+ ): LinkTableRow[] {
176
202
  const rows: LinkTableRow[] = [];
177
203
  const declaredTargets = new Map<string, Set<string>>();
178
204
 
@@ -215,30 +241,18 @@ export function resolveLinks(declaration: Declaration, handles: ReadonlyMap<stri
215
241
  row.reason = `protocol applies to telemetry links; this link has kind ${kind}`;
216
242
  continue;
217
243
  }
218
- const producer = handles.get(link.member);
219
- const found = producer?.outputs.find((o) => o.name === link.output);
220
- if (found) {
221
- if (found.node) row.to = found.node;
222
- continue;
223
- }
224
- if (!producer) {
225
- row.status = "unresolved";
226
- row.reason = `the outputs of ${link.member} were not read`;
227
- } else if (producer.exposesNone) {
228
- row.status = "missing";
229
- row.reason = producer.why ?? `${link.member} exposes no outputs`;
230
- } else if (!producer.complete) {
231
- row.status = "unresolved";
232
- row.reason = `${link.output} was not found in ${link.member}, whose outputs could not all be read in source${producer.why ? ` (${producer.why})` : ""}`;
233
- } else {
234
- const near = nearNames(link.output, producer.outputs);
235
- const known = producer.outputs.map((o) => o.name);
236
- row.status = "missing";
237
- row.reason =
238
- `${link.member} has no output ${link.output}` +
239
- (near.length ? `; did you mean ${near.join(" or ")}? Links match exactly` : "") +
240
- (known.length ? `; its outputs: ${[...new Set(known)].sort(cmp).join(", ")}` : "; it exposes no outputs");
241
- }
244
+ resolveOutput(row, handles);
245
+ }
246
+ }
247
+
248
+ // Links a kind's reader found in the member's own files (#3932).
249
+ for (const consumer of declaration.members) {
250
+ const read = readLinks?.get(consumer.name);
251
+ if (!read) continue;
252
+ for (const link of read.links) {
253
+ const row = readerRow(declaration, consumer, read, link, handles);
254
+ rows.push(row);
255
+ if (row.status !== "ambiguous") (declaredTargets.get(consumer.name) ?? declaredTargets.set(consumer.name, new Set()).get(consumer.name)!).add(`${row.producer}\0${row.output}`);
242
256
  }
243
257
  }
244
258
 
@@ -297,6 +311,85 @@ export function resolveLinks(declaration: Declaration, handles: ReadonlyMap<stri
297
311
  return [...rows, ...inferred];
298
312
  }
299
313
 
314
+ /** Match a declared or reader's output row against its producer's handles, setting its status and reason. */
315
+ function resolveOutput(row: LinkRow, handles: ReadonlyMap<string, MemberHandles>): void {
316
+ const producer = handles.get(row.producer);
317
+ const found = producer?.outputs.find((o) => o.name === row.output);
318
+ if (found) {
319
+ if (found.node) row.to = found.node;
320
+ return;
321
+ }
322
+ if (!producer) {
323
+ row.status = "unresolved";
324
+ row.reason = `the outputs of ${row.producer} were not read`;
325
+ } else if (producer.exposesNone) {
326
+ row.status = "missing";
327
+ row.reason = producer.why ?? `${row.producer} exposes no outputs`;
328
+ } else if (!producer.complete) {
329
+ row.status = "unresolved";
330
+ row.reason = `${row.output} was not found in ${row.producer}, whose outputs could not all be read in source${producer.why ? ` (${producer.why})` : ""}`;
331
+ } else {
332
+ const renamed = [...new Set(producer.outputs.filter((o) => o.previously?.includes(row.output)).map((o) => o.name))].sort(cmp);
333
+ const near = renamed.length ? [] : nearNames(row.output, producer.outputs);
334
+ const known = producer.outputs.map((o) => o.name);
335
+ row.status = "missing";
336
+ row.reason =
337
+ `${row.producer} has no output ${row.output}` +
338
+ (renamed.length ? `; it was renamed to ${renamed.join(" or ")}, so link to that instead` : "") +
339
+ (near.length ? `; did you mean ${near.join(" or ")}? Links match exactly` : "") +
340
+ (known.length ? `; its outputs: ${[...new Set(known)].sort(cmp).join(", ")}` : "; it exposes no outputs");
341
+ }
342
+ }
343
+
344
+ /**
345
+ * The row for one link a kind's reader found (#3932). A link whose file names
346
+ * its producer resolves like a declared one. One that doesn't is given the
347
+ * producer that exposes its anchor (a dbt column's table); when none does,
348
+ * and exactly one other member exposes outputs at all, that member, so a
349
+ * dropped table is missing from the only place it could be. Otherwise the
350
+ * row is ambiguous, and the message names the field that settles it.
351
+ */
352
+ function readerRow(declaration: Declaration, consumer: Member, read: KindLinkRead, link: KindReadLink, handles: ReadonlyMap<string, MemberHandles>): LinkTableRow {
353
+ const base = { consumer: consumer.name, kind: DEFAULT_LINK_KIND, origin: "kind-reader" as const, resolves: "source" as const, reader: read.reader, file: link.file, line: link.line };
354
+ let producer = link.member;
355
+ if (producer === null) {
356
+ const anchor = link.anchor ?? link.output;
357
+ const others = declaration.members.filter((m) => m.name !== consumer.name);
358
+ const exposing = others.filter((m) => handles.get(m.name)?.outputs.some((o) => o.name === anchor));
359
+ const possible = others.filter((m) => {
360
+ const h = handles.get(m.name);
361
+ return h !== undefined && !h.exposesNone;
362
+ });
363
+ if (exposing.length === 1) producer = exposing[0].name;
364
+ else if (exposing.length === 0 && possible.length === 1) producer = possible[0].name;
365
+ else {
366
+ const candidates = (exposing.length > 0 ? exposing : possible).map((m) => ({ producer: m.name, output: link.output, label: "exact" as const }));
367
+ const field = read.producerField ? `; set fields.${read.producerField} on ${consumer.name} to name it` : "";
368
+ return {
369
+ ...base,
370
+ status: "ambiguous",
371
+ input: link.output,
372
+ candidates,
373
+ reason:
374
+ exposing.length > 1
375
+ ? `${link.file}:${link.line} reads ${anchor}, which ${exposing.map((m) => m.name).join(" and ")} all expose${field}`
376
+ : candidates.length > 0
377
+ ? `${link.file}:${link.line} reads ${anchor}, which no member exposes, and ${consumer.name} does not say which of ${candidates.map((c) => c.producer).join(", ")} produces it${field}`
378
+ : `${link.file}:${link.line} reads ${anchor}, and no other member exposes outputs`,
379
+ };
380
+ }
381
+ }
382
+ const row: LinkRow = { ...base, producer, output: link.output, label: "exact", input: null, status: "resolved", reason: null };
383
+ const problem = linkTargetProblem(declaration, consumer, { member: producer } as LinkDeclaration);
384
+ if (problem) {
385
+ row.status = "invalid";
386
+ row.reason = read.producerField ? `fields.${read.producerField}: ${problem}` : problem;
387
+ return row;
388
+ }
389
+ resolveOutput(row, handles);
390
+ return row;
391
+ }
392
+
300
393
  /**
301
394
  * Resolve a telemetry link against the producer's collector (#2558): the
302
395
  * target exists (presence), and when the link states a protocol, the target
@@ -369,7 +462,7 @@ export interface ComposedHandles {
369
462
  composed: readonly string[];
370
463
  /** The collectors those members report (#2558); a telemetry link resolves against them. */
371
464
  collectors?: readonly MemberCollector[];
372
- exports: readonly { member: string; name: string; node?: string }[];
465
+ exports: readonly { member: string; name: string; node?: string; previously?: readonly string[] }[];
373
466
  imports: readonly { member: string; name: string; node: string }[];
374
467
  }
375
468
 
@@ -384,7 +477,9 @@ export function irMemberHandles(graph: ComposedHandles): Map<string, MemberHandl
384
477
  for (const member of graph.composed) {
385
478
  out.set(member, {
386
479
  member,
387
- outputs: graph.exports.filter((e) => e.member === member).map((e) => ({ name: e.name, ...(e.node ? { node: e.node } : {}) })),
480
+ outputs: graph.exports
481
+ .filter((e) => e.member === member)
482
+ .map((e) => ({ name: e.name, ...(e.node ? { node: e.node } : {}), ...(e.previously?.length ? { previously: e.previously } : {}) })),
388
483
  inputs: graph.imports.filter((i) => i.member === member).map((i) => ({ name: i.name, node: i.node })),
389
484
  complete: true,
390
485
  why: null,
@@ -401,7 +496,7 @@ export function irMemberHandles(graph: ComposedHandles): Map<string, MemberHandl
401
496
  * With `kinds`, a member that isn't composed but whose kind lists its outputs
402
497
  * (`other`, kinds from a package) is a link target too.
403
498
  */
404
- export function graphLinks(declaration: Declaration, graph: ComposedHandles, kinds?: KindRegistry): LinkTableRow[] {
499
+ export function graphLinks(declaration: Declaration, graph: ComposedHandles, kinds?: KindRegistry, readLinks?: ReadonlyMap<string, KindLinkRead>): LinkTableRow[] {
405
500
  const handles = irMemberHandles(graph);
406
501
  for (const m of declaration.members) {
407
502
  const kind = kinds?.get(m.kind);
@@ -413,7 +508,7 @@ export function graphLinks(declaration: Declaration, graph: ComposedHandles, kin
413
508
  collectors: new Map((graph.collectors ?? []).map((c) => [c.member, c])),
414
509
  composed: new Set(graph.composed),
415
510
  };
416
- return resolveLinks(declaration, handles, telemetry).map((row) => {
511
+ return resolveLinks(declaration, handles, telemetry, readLinks).map((row) => {
417
512
  if (row.status === "ambiguous" || row.origin !== "declared") return row;
418
513
  // The declaration's pointer means nothing to a graph reader.
419
514
  const { pointer: _pointer, ...rest } = row;
@@ -25,6 +25,7 @@ import { isInside, type Declaration, type ResolvedGroup } from "./declaration";
25
25
  import type { KindRegistry } from "./kinds";
26
26
  import { kindHandles, type MemberHandles } from "./links";
27
27
  import { joinPath, skippedDir, type WorkspaceTree } from "./tree";
28
+ import type { MemberLexiconOutputs } from "./lexicon-outputs";
28
29
 
29
30
  const memberDir = (dir: string) => (dir === "." ? "" : dir);
30
31
 
@@ -60,7 +61,7 @@ function isSourceFile(name: string): boolean {
60
61
  * directories (other members, group matches), `node_modules`, dot
61
62
  * directories and build output.
62
63
  */
63
- function sourceFiles(tree: WorkspaceTree, dir: string, exclude: ReadonlySet<string>): string[] {
64
+ export function sourceFiles(tree: WorkspaceTree, dir: string, exclude: ReadonlySet<string>): string[] {
64
65
  const out: string[] = [];
65
66
  const walk = (at: string) => {
66
67
  for (const e of tree.list(at) ?? []) {
@@ -188,18 +189,35 @@ export function readSourceHandles(tree: WorkspaceTree, dir: string, exclude: Rea
188
189
  return out;
189
190
  }
190
191
 
192
+ /**
193
+ * The directories inside member `m` that are not its source: other members'
194
+ * directories and example-group matches nested in it.
195
+ */
196
+ export function memberExcludes(declaration: Declaration, groups: readonly ResolvedGroup[], m: Declaration["members"][number]): Set<string> {
197
+ const exclude = new Set<string>();
198
+ for (const o of declaration.members) if (o !== m && o.dir !== "." && isInside(o.dir, m.dir)) exclude.add(o.dir);
199
+ for (const g of groups) for (const dir of g.matches) if (isInside(dir, m.dir)) exclude.add(dir);
200
+ return exclude;
201
+ }
202
+
191
203
  /**
192
204
  * Every member's handles, read in source for `chant workspace check`. A
193
205
  * `chant` member's outputs and parameters are parsed from its TypeScript
194
206
  * without running it; other kinds expose what the kind and the entry list.
195
207
  * A member whose directory is missing or whose kind is unknown is left out,
196
208
  * so links to it are unresolved.
209
+ *
210
+ * `lexiconOutputs` adds what the member's lexicons report about their own
211
+ * declarations (#3931, `./lexicon-outputs.ts`), gathered beforehand because
212
+ * reading them folds the member's files, which is asynchronous. Its gaps
213
+ * count as the member's.
197
214
  */
198
215
  export function sourceMemberHandles(
199
216
  declaration: Declaration,
200
217
  tree: WorkspaceTree,
201
218
  groups: readonly ResolvedGroup[],
202
219
  kinds: KindRegistry,
220
+ lexiconOutputs?: ReadonlyMap<string, MemberLexiconOutputs>,
203
221
  ): Map<string, MemberHandles> {
204
222
  const out = new Map<string, MemberHandles>();
205
223
  for (const m of declaration.members) {
@@ -210,16 +228,18 @@ export function sourceMemberHandles(
210
228
  out.set(m.name, listed);
211
229
  } else {
212
230
  // Other members' directories and group matches inside this one are not its source.
213
- const exclude = new Set<string>();
214
- for (const o of declaration.members) if (o !== m && o.dir !== "." && isInside(o.dir, m.dir)) exclude.add(o.dir);
215
- for (const g of groups) for (const dir of g.matches) if (isInside(dir, m.dir)) exclude.add(dir);
216
- const read = readSourceHandles(tree, memberDir(m.dir), exclude);
231
+ const read = readSourceHandles(tree, memberDir(m.dir), memberExcludes(declaration, groups, m));
232
+ const lexicon = lexiconOutputs?.get(m.name);
233
+ const gaps = [...read.gaps, ...(lexicon?.gaps ?? [])];
217
234
  out.set(m.name, {
218
235
  member: m.name,
219
- outputs: read.outputs.map((o) => ({ name: o.name })),
236
+ outputs: [
237
+ ...read.outputs.map((o) => ({ name: o.name })),
238
+ ...(lexicon?.outputs ?? []).map((o) => ({ name: o.name, ...(o.previously?.length ? { previously: o.previously } : {}) })),
239
+ ],
220
240
  inputs: read.inputs.map((i) => ({ name: i.name })),
221
- complete: read.gaps.length === 0,
222
- why: read.gaps.length === 0 ? null : read.gaps.length === 1 ? read.gaps[0] : `${read.gaps[0]}, and ${read.gaps.length - 1} more`,
241
+ complete: gaps.length === 0,
242
+ why: gaps.length === 0 ? null : gaps.length === 1 ? gaps[0] : `${gaps[0]}, and ${gaps.length - 1} more`,
223
243
  exposesNone: false,
224
244
  });
225
245
  }
@@ -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]);
@@ -11,10 +11,15 @@
11
11
  *
12
12
  * Every git call here reads. None takes the index lock or refreshes the
13
13
  * index (`--no-optional-locks`), so a read leaves the checkout as it was,
14
- * `.git` included, which the reader conformance suite checks.
14
+ * `.git` included, which the reader conformance suite checks. A diff against
15
+ * the working tree is the exception git makes: it refreshes the index even
16
+ * then, so {@link worktreeChanges} runs it on a private copy.
15
17
  */
16
18
 
17
19
  import { execFileSync } from "node:child_process";
20
+ import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { isAbsolute, join } from "node:path";
18
23
  import { resolveBase, type BaseSource } from "./trust/provenance";
19
24
 
20
25
  /** Where a record's file stands against `HEAD`. */
@@ -55,6 +60,35 @@ function tryGit(top: string, args: string[]): string | undefined {
55
60
  }
56
61
  }
57
62
 
63
+ /**
64
+ * The paths under `spec` whose working-tree content differs from `rev`, or
65
+ * undefined when git can't say. `git diff <rev>` refreshes the index's stat
66
+ * data for a file touched without a change, and writes the index even under
67
+ * `--no-optional-locks` (git 2.54), so it runs against a copy of the index
68
+ * (`GIT_INDEX_FILE`) and the checkout's own index is never written.
69
+ */
70
+ export function worktreeChanges(top: string, rev: string, spec: string): string[] | undefined {
71
+ const scratch = mkdtempSync(join(tmpdir(), "chant-index-"));
72
+ try {
73
+ const at = tryGit(top, ["rev-parse", "--git-path", "index"])?.trim();
74
+ const index = at ? (isAbsolute(at) ? at : join(top, at)) : undefined;
75
+ const copy = join(scratch, "index");
76
+ if (index && existsSync(index)) copyFileSync(index, copy);
77
+ const out = execFileSync("git", ["--no-optional-locks", "--literal-pathspecs", "diff", "--name-only", "-z", "--no-renames", rev, "--", spec], {
78
+ cwd: top,
79
+ encoding: "utf-8",
80
+ stdio: ["ignore", "pipe", "pipe"],
81
+ maxBuffer: 256 * 1024 * 1024,
82
+ env: { ...process.env, GIT_INDEX_FILE: copy },
83
+ });
84
+ return out.split("\0").filter(Boolean);
85
+ } catch {
86
+ return undefined;
87
+ } finally {
88
+ rmSync(scratch, { recursive: true, force: true });
89
+ }
90
+ }
91
+
58
92
  /**
59
93
  * The branch, head and base of the checkout at `top`. `explicitBase` is
60
94
  * `--base`, as the trust policy takes it; otherwise the target branch is the
@@ -98,7 +132,7 @@ export function worktreeStates(
98
132
  const direct = (p: string) => p.startsWith(prefix) && !p.slice(prefix.length).includes("/");
99
133
  const spec = dir === "." ? "." : dir;
100
134
  const atHead = new Set(zList(tryGit(top, ["ls-tree", "-r", "-z", "--name-only", head, "--", spec])).filter(direct));
101
- const changed = new Set(zList(tryGit(top, ["diff", "--name-only", "-z", "--no-renames", head, "--", spec])));
135
+ const changed = new Set(worktreeChanges(top, head, spec) ?? []);
102
136
  for (const p of paths) states.set(p, !atHead.has(p) ? "new" : changed.has(p) ? "modified" : "committed");
103
137
  const present = new Set(paths);
104
138
  const deleted = [...atHead].filter((p) => !present.has(p) && match.test(p.slice(prefix.length))).sort();
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { execFileSync } from "node:child_process";
9
- import { cpSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
9
+ import { cpSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import Ajv2020 from "ajv/dist/2020";
@@ -101,6 +101,10 @@ describe("records in the working tree (#3160)", () => {
101
101
  const { root } = repository();
102
102
  const path = join(root, file("001"));
103
103
  writeFileSync(path, decided("001"));
104
+ // Its stat data no longer matches the index on any clock, so a read that
105
+ // refreshes the index would write it (a CI runner was slow enough to).
106
+ const later = new Date(Date.now() + 5_000);
107
+ utimesSync(path, later, later);
104
108
  const index = join(root, ".git", "index");
105
109
  const before = { bytes: readFileSync(index), mtime: statSync(index).mtimeMs };
106
110
  const doc = result(await queryRecords({ kind: KIND, cwd: root, uncommitted: true }));
@@ -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);
@@ -41,7 +41,7 @@ import { readPathSha } from "../lifecycle/git";
41
41
  import { latestPerComponent, readReleaseLedger, type ReleaseRecord } from "../lifecycle/release-ledger";
42
42
  import { readReleasePlan, type ReleasePlan } from "../lifecycle/plan-ledger";
43
43
  import { listWorkLeases, type WorkLeaseState } from "../lifecycle/work-lease";
44
- import { readCheckoutHead, type CheckoutHead } from "./records-checkout";
44
+ import { readCheckoutHead, worktreeChanges, type CheckoutHead } from "./records-checkout";
45
45
  import { policyView, replicationState, type ReplicationState } from "./wip";
46
46
  import { findWorkspaceRoot } from "../project-root";
47
47
  import { resolveBoxes, type ResolvedIsolation } from "./box-isolation";
@@ -400,7 +400,8 @@ async function shipView(member: Member, cwd: string, read: LedgerReader): Promis
400
400
  };
401
401
  // The commit must be here: a release from a commit this checkout never had says nothing about it.
402
402
  if (lines(["cat-file", "-e", `${last.gitSha}^{commit}`]) === null) return view;
403
- const changed = lines(["diff", "--name-only", "-z", "--no-renames", last.gitSha, "--"]);
403
+ // A diff against the working tree writes the index; run it on a copy.
404
+ const changed = worktreeChanges(top, last.gitSha, ".") ?? null;
404
405
  const untracked = lines(["ls-files", "--others", "--exclude-standard", "-z"]);
405
406
  if (changed === null || untracked === null) return view;
406
407
  const bookkeeping = (path: string) => ship.bookkeeping.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
@@ -78,6 +78,11 @@
78
78
  },
79
79
  "fields": {
80
80
  "$ref": "#/$defs/fields"
81
+ },
82
+ "reader": {
83
+ "type": "string",
84
+ "pattern": "^[a-z][a-z0-9-]{0,39}$",
85
+ "description": "A link reader chant ships, by name, that derives a member's links from the member's own files (#3932): dbt-project reads a dbt project's sources. The reader is chant's code and runs offline, parsing files as data; the package supplies only the name. A name this chant doesn't have keeps the kind without a reader, and the load reports it. A chant older than this field fails a kinds file that uses it."
81
86
  }
82
87
  },
83
88
  "patternProperties": {