@intentius/chant 0.60.0 → 0.62.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 (54) hide show
  1. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  2. package/dist/cli/handlers/operator.d.ts +0 -18
  3. package/dist/cli/handlers/operator.d.ts.map +1 -1
  4. package/dist/codegen/json-schema.d.ts +5 -2
  5. package/dist/codegen/json-schema.d.ts.map +1 -1
  6. package/dist/graph-ir.d.ts +70 -2
  7. package/dist/graph-ir.d.ts.map +1 -1
  8. package/dist/lexicon.d.ts +51 -0
  9. package/dist/lexicon.d.ts.map +1 -1
  10. package/dist/lifecycle/assert-live.d.ts.map +1 -1
  11. package/dist/lifecycle/git.d.ts +117 -0
  12. package/dist/lifecycle/git.d.ts.map +1 -1
  13. package/dist/lifecycle/observe.d.ts.map +1 -1
  14. package/dist/observation.d.ts +21 -1
  15. package/dist/observation.d.ts.map +1 -1
  16. package/dist/op/activities/lexicon-upgrade.d.ts +19 -1
  17. package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
  18. package/dist/op/activities/reconcile.d.ts +225 -14
  19. package/dist/op/activities/reconcile.d.ts.map +1 -1
  20. package/dist/op/gate.d.ts.map +1 -1
  21. package/dist/op/local-executor.d.ts +17 -2
  22. package/dist/op/local-executor.d.ts.map +1 -1
  23. package/dist/op/operator.d.ts.map +1 -1
  24. package/dist/op/runtimes/local.d.ts.map +1 -1
  25. package/dist/runtime-adapter.d.ts +8 -0
  26. package/dist/runtime-adapter.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/cli/handlers/components.ts +1 -1
  29. package/src/cli/handlers/lifecycle.ts +1 -0
  30. package/src/cli/handlers/operator.test.ts +102 -1
  31. package/src/cli/handlers/operator.ts +75 -5
  32. package/src/codegen/json-schema.test.ts +159 -0
  33. package/src/codegen/json-schema.ts +106 -8
  34. package/src/graph-ir.test.ts +63 -0
  35. package/src/graph-ir.ts +125 -8
  36. package/src/lexicon.ts +51 -0
  37. package/src/lifecycle/assert-live.ts +1 -0
  38. package/src/lifecycle/git.test.ts +49 -5
  39. package/src/lifecycle/git.ts +312 -11
  40. package/src/lifecycle/observe.ts +3 -0
  41. package/src/observation.test.ts +21 -9
  42. package/src/observation.ts +31 -2
  43. package/src/op/activities/lexicon-upgrade.test.ts +122 -39
  44. package/src/op/activities/lexicon-upgrade.ts +55 -9
  45. package/src/op/activities/reconcile.test.ts +527 -1
  46. package/src/op/activities/reconcile.ts +446 -23
  47. package/src/op/gate.test.ts +504 -0
  48. package/src/op/gate.ts +9 -1
  49. package/src/op/local-executor.test.ts +115 -0
  50. package/src/op/local-executor.ts +130 -21
  51. package/src/op/operator.test.ts +20 -0
  52. package/src/op/operator.ts +39 -1
  53. package/src/op/runtimes/local.ts +11 -0
  54. package/src/runtime-adapter.ts +17 -3
@@ -8,6 +8,69 @@ import { getRuntime } from "../runtime-adapter";
8
8
 
9
9
  const STATE_BRANCH = "chant/lifecycle";
10
10
 
11
+ /**
12
+ * The identity chant's ledger commits fall back to when the checkout has none
13
+ * of its own (#2301).
14
+ *
15
+ * A CI checkout is the ordinary case here, not an exotic one: GitLab CI,
16
+ * GitHub Actions and Forgejo Actions all clone without `user.email` or
17
+ * `user.name`, and `git commit-tree` refuses outright rather than inventing
18
+ * one — `fatal: unable to auto-detect email address`. Every write in this
19
+ * module goes through `commit-tree`, so before #2301 a gate's pending fact,
20
+ * a run record, a snapshot and a release record all died the same way the
21
+ * moment they ran anywhere but a developer's laptop.
22
+ *
23
+ * This is a *fallback*, applied per-invocation via `git -c` and field by
24
+ * field: only the half the checkout cannot supply is filled in. A configured
25
+ * `user.name` or `user.email` — a developer's, or a CI job that sets one
26
+ * deliberately — is left exactly as it is and still authors these commits, and
27
+ * nothing is written to the repository's config either way. Override the
28
+ * fallback with `CHANT_LIFECYCLE_COMMITTER_NAME` / `_EMAIL` when a project
29
+ * wants its ledger commits attributed to a bot account it controls.
30
+ */
31
+ const LEDGER_COMMITTER_NAME = "chant";
32
+ const LEDGER_COMMITTER_EMAIL = "chant@localhost";
33
+
34
+ /**
35
+ * `git -c` arguments that give {@link writeBlobToPath}'s `commit-tree` a
36
+ * committer when — and only when — the checkout cannot supply one itself.
37
+ * Empty for any checkout that has an identity, which keeps every existing
38
+ * caller's commits byte-identical to what they were before #2301.
39
+ */
40
+ async function ledgerCommitIdentityArgs(cwd?: string): Promise<string[]> {
41
+ const rt = getRuntime();
42
+ const probe = await rt.spawn(["git", "var", "GIT_COMMITTER_IDENT"], { cwd, env: C_LOCALE_ENV });
43
+ if (probe.exitCode === 0) return [];
44
+
45
+ // Per-field, not all-or-nothing (#2309 review). `git var` fails if *either*
46
+ // half is missing, so filling in both would overwrite a configured
47
+ // `user.name` in the checkout that has a name but no email — which is not
48
+ // what "a configured identity is left exactly as it is" promises.
49
+ const args: string[] = [];
50
+ for (const [key, envVar, fallback] of [
51
+ ["user.name", "CHANT_LIFECYCLE_COMMITTER_NAME", LEDGER_COMMITTER_NAME],
52
+ ["user.email", "CHANT_LIFECYCLE_COMMITTER_EMAIL", LEDGER_COMMITTER_EMAIL],
53
+ ] as const) {
54
+ const configured = await rt.spawn(["git", "config", "--get", key], { cwd, env: C_LOCALE_ENV });
55
+ if (configured.exitCode === 0 && configured.stdout.trim() !== "") continue;
56
+ args.push("-c", `${key}=${process.env[envVar]?.trim() || fallback}`);
57
+ }
58
+ return args;
59
+ }
60
+
61
+ /**
62
+ * Prefix every failure out of the ledger write path with what was being
63
+ * written and where (#2301 deliverable 2). `git commit-tree failed: <stderr>`
64
+ * on its own does not say that the thing that failed was chant recording a
65
+ * fact on its own branch, and the caller that swallows it is one stack frame
66
+ * away from printing nothing at all.
67
+ */
68
+ function ledgerWriteError(stage: string, path: string, stderr: string): Error {
69
+ return new Error(
70
+ `cannot write ${path} on the ${STATE_BRANCH} branch: ${stage} failed: ${(stderr ?? "").trim()}`,
71
+ );
72
+ }
73
+
11
74
  /**
12
75
  * Write a blob to an arbitrary `<environment>/<filename>` path on the orphan
13
76
  * branch, preserving every other env/file entry already on the branch.
@@ -56,13 +119,42 @@ export async function writeBlobToPath(
56
119
  // annotation) and no shell-quoting dance around embedded single quotes.
57
120
  // Content-addressed and idempotent, so this stays outside the retry loop
58
121
  // below — nothing about a CAS conflict on the ref ever invalidates it.
122
+ //
123
+ // `path` is resolved first only so every failure below can name it (#2301).
124
+ const path = `${environment}/${filename}`;
125
+
59
126
  const blobResult = await rt.spawn(["git", "hash-object", "-w", "--stdin"], { cwd, stdin: content });
60
127
  if (blobResult.exitCode !== 0) {
61
- throw new Error(`git hash-object failed: ${blobResult.stderr}`);
128
+ throw ledgerWriteError("git hash-object", path, blobResult.stderr);
62
129
  }
63
130
  const blobSha = blobResult.stdout.trim();
64
131
 
65
- const path = `${environment}/${filename}`;
132
+ // Resolved once, outside the retry loop: whether the checkout can name a
133
+ // committer does not change between attempts (#2301).
134
+ const identityArgs = await ledgerCommitIdentityArgs(cwd);
135
+
136
+ // Creating the ledger branch is a different act from extending it, and only
137
+ // one of them is safe to do blind (#2309 review). With no local
138
+ // `chant/lifecycle`, everything below builds a tree from an empty read and
139
+ // commits it with no parent — a brand-new root commit that *replaces* the
140
+ // branch rather than appending to it, which `updateRefCAS` cannot catch
141
+ // because "the ref does not exist" is perfectly true locally.
142
+ //
143
+ // Until #2301 this was mostly hidden: `commit-tree` died for want of a
144
+ // committer before it could happen. Supplying an identity turned that loud
145
+ // failure into a silent branch fabrication on every writer that reaches
146
+ // here without a ledger — a run record, a snapshot, a release, a build
147
+ // manifest — and a fabricated branch is precisely the fork the guard above
148
+ // exists to refuse. So the guard runs here, at the one seam they all pass
149
+ // through, rather than at each of their call sites.
150
+ //
151
+ // Only when the branch is absent: an extend costs no fetch, and the check
152
+ // that matters is whether the history this is about to discard exists
153
+ // somewhere. `requireLifecycleLedger` allows `no-remote` and `absent`, so a
154
+ // genuinely first write still creates the branch.
155
+ if ((await getStateBranchTip(cwd)) === null) {
156
+ await requireLifecycleLedger({ ...(cwd ? { cwd } : {}) });
157
+ }
66
158
 
67
159
  // 2-5. Read tree, build the commit, and CAS-update the branch ref — retried
68
160
  // on a conflict (#1959 finding 1). `writeBlobToPath` had no retry of its
@@ -127,7 +219,7 @@ export async function writeBlobToPath(
127
219
 
128
220
  const envTreeResult = await rt.spawn(["git", "mktree"], { cwd, stdin: `${envEntries}\n` });
129
221
  if (envTreeResult.exitCode !== 0) {
130
- throw new Error(`git mktree (env) failed: ${envTreeResult.stderr}`);
222
+ throw ledgerWriteError("git mktree (env)", path, envTreeResult.stderr);
131
223
  }
132
224
  const envTreeSha = envTreeResult.stdout.trim();
133
225
 
@@ -150,7 +242,7 @@ export async function writeBlobToPath(
150
242
  stdin: `${rootEntries.join("\n")}\n`,
151
243
  });
152
244
  if (rootTreeResult.exitCode !== 0) {
153
- throw new Error(`git mktree (root) failed: ${rootTreeResult.stderr}`);
245
+ throw ledgerWriteError("git mktree (root)", path, rootTreeResult.stderr);
154
246
  }
155
247
  const rootTreeSha = rootTreeResult.stdout.trim();
156
248
 
@@ -160,11 +252,11 @@ export async function writeBlobToPath(
160
252
  const parentRef = tip;
161
253
  const parentArgs = parentRef ? ["-p", parentRef] : [];
162
254
  const commitResult = await rt.spawn(
163
- ["git", "commit-tree", ...parentArgs, "-m", commitMessage, rootTreeSha],
255
+ ["git", ...identityArgs, "commit-tree", ...parentArgs, "-m", commitMessage, rootTreeSha],
164
256
  { cwd },
165
257
  );
166
258
  if (commitResult.exitCode !== 0) {
167
- throw new Error(`git commit-tree failed: ${commitResult.stderr}`);
259
+ throw ledgerWriteError("git commit-tree", path, commitResult.stderr);
168
260
  }
169
261
  const commitSha = commitResult.stdout.trim();
170
262
 
@@ -534,16 +626,225 @@ export async function pushLifecycle(opts?: { cwd?: string }): Promise<boolean> {
534
626
  * Fetch the state branch from remote.
535
627
  */
536
628
  export async function fetchLifecycle(opts?: { cwd?: string }): Promise<boolean> {
629
+ return (await fetchLifecycleStatus(opts)).status === "fetched";
630
+ }
631
+
632
+ /**
633
+ * Why a fetch of the ledger branch did or did not produce local history
634
+ * (#2303).
635
+ *
636
+ * `fetchLifecycle`'s boolean cannot answer the question the gate actually
637
+ * needs answered. "No local `chant/lifecycle`" has two causes that a reader
638
+ * of an empty ledger cannot tell apart, and they call for opposite
639
+ * behaviour: on a project whose ledger has never been written, an empty
640
+ * ledger is the truth; in a CI checkout that fetched only the pipeline's own
641
+ * ref, an empty ledger is history the checkout simply cannot see. Reading the
642
+ * second as the first is what makes a second pending fact look correct, so
643
+ * these are separate outcomes:
644
+ *
645
+ * - `fetched` — the branch is now in this checkout (or already was, and is
646
+ * up to date). Whatever the ledger says is the whole truth.
647
+ * - `no-remote` — nothing to fetch from. A single-machine project; local is
648
+ * authoritative by construction, the same assumption `chant operator`'s
649
+ * lease already documents.
650
+ * - `absent` — the remote answered and has no `chant/lifecycle`. The ledger
651
+ * is genuinely empty; this is a project's first gate.
652
+ * - `diverged` — both sides have the branch and neither contains the other.
653
+ * Appending here would drop one side's facts.
654
+ * - `unreachable` — a remote is configured and did not answer (no network,
655
+ * no credentials, a shallow-clone refspec that cannot resolve). Whether
656
+ * anything is on the branch is unknown, which is exactly the state that
657
+ * must not be read as "nothing has been approved".
658
+ */
659
+ export type LifecycleFetchStatus =
660
+ | { status: "fetched" }
661
+ | { status: "no-remote" }
662
+ | { status: "absent"; remote: string }
663
+ | { status: "ahead"; remote: string }
664
+ | { status: "diverged"; remote: string; stderr: string }
665
+ | { status: "unreachable"; remote: string; stderr: string };
666
+
667
+ /**
668
+ * git's wording when the remote answered and simply does not carry the ref.
669
+ *
670
+ * Matched against output forced to `LC_ALL=C` ({@link C_LOCALE_ENV}) — both of
671
+ * these strings are in git's translation catalogs, so on a distro git built
672
+ * with NLS and a non-C `LANG` an unpinned locale would fail to match and send
673
+ * a project's genuine first gate down the `unreachable` path, refusing it.
674
+ */
675
+ const NO_SUCH_REMOTE_REF_RE = /couldn't find remote ref|no such ref was fetched/i;
676
+ /** git's wording when the refspec would not fast-forward the local branch. Same locale caveat. */
677
+ const NON_FAST_FORWARD_RE = /non-fast-forward|rejected/i;
678
+
679
+ /**
680
+ * `git` invocations here are parsed by their output, so they are pinned to the
681
+ * C locale. `LC_ALL` alone is enough — it outranks `LC_MESSAGES` and `LANG` —
682
+ * but `LANGUAGE` is GNU gettext's own override and outranks all of them, so it
683
+ * is cleared too.
684
+ */
685
+ const C_LOCALE_ENV = { LC_ALL: "C", LANGUAGE: "" };
686
+
687
+ /**
688
+ * The remote the ledger lives on.
689
+ *
690
+ * `origin` when it exists, rather than whichever name sorts first — a repo
691
+ * with a `backup` remote alongside `origin` would otherwise fetch the ledger
692
+ * from `backup`. A non-zero exit is *not* read as "no remote": a git that
693
+ * failed to answer is not evidence that a project is single-machine, and
694
+ * treating it as one is what lets an unreadable ledger look empty.
695
+ */
696
+ async function ledgerRemote(cwd?: string): Promise<{ remote: string } | { failed: true; stderr: string } | null> {
537
697
  const rt = getRuntime();
538
- const remoteResult = await rt.spawn(["git", "remote"], { cwd: opts?.cwd });
539
- if (remoteResult.exitCode !== 0 || !remoteResult.stdout.trim()) return false;
698
+ const result = await rt.spawn(["git", "remote"], { cwd, env: C_LOCALE_ENV });
699
+ if (result.exitCode !== 0) return { failed: true, stderr: (result.stderr ?? "").trim() };
700
+ const names = result.stdout.trim().split("\n").map((n) => n.trim()).filter(Boolean);
701
+ if (names.length === 0) return null;
702
+ return { remote: names.includes("origin") ? "origin" : names[0] };
703
+ }
704
+
705
+ /** A scratch ref this module owns, used to resolve the remote tip without touching the ledger branch. */
706
+ const PROBE_REF = "refs/chant/lifecycle-probe";
707
+
708
+ /**
709
+ * Fetch `chant/lifecycle` and say what happened, in the terms
710
+ * {@link LifecycleFetchStatus} defines.
711
+ *
712
+ * The refspec is deliberately un-forced (`chant/lifecycle:chant/lifecycle`,
713
+ * no leading `+`), so a local branch holding facts the remote does not have is
714
+ * never rewound by a read.
715
+ *
716
+ * git reports "non-fast-forward" for two states that could not be more
717
+ * different, which is why the rejection is not taken at face value (#2309
718
+ * review): a local branch *ahead* of the remote — the ordinary state right
719
+ * after an append whose push has not landed yet — and a local branch that has
720
+ * genuinely *forked*. Resolving the remote tip into a scratch ref and asking
721
+ * whether it is an ancestor of the local tip separates them. Reading a fork as
722
+ * the ledger is what destroys approvals; refusing an append that is merely
723
+ * ahead would break the retry that is supposed to repair a failed push.
724
+ */
725
+ export async function fetchLifecycleStatus(opts?: { cwd?: string }): Promise<LifecycleFetchStatus> {
726
+ const rt = getRuntime();
727
+ const cwd = opts?.cwd;
728
+ const found = await ledgerRemote(cwd);
729
+ if (found === null) return { status: "no-remote" };
730
+ if ("failed" in found) {
731
+ return { status: "unreachable", remote: "(unknown)", stderr: found.stderr };
732
+ }
733
+ const { remote } = found;
540
734
 
541
- const remote = remoteResult.stdout.trim().split("\n")[0];
542
735
  const fetchResult = await rt.spawn(
543
736
  ["git", "fetch", remote, `${STATE_BRANCH}:${STATE_BRANCH}`],
544
- { cwd: opts?.cwd },
737
+ { cwd, env: C_LOCALE_ENV },
545
738
  );
546
- return fetchResult.exitCode === 0;
739
+ if (fetchResult.exitCode === 0) return { status: "fetched" };
740
+
741
+ const stderr = (fetchResult.stderr ?? "").trim();
742
+ if (NO_SUCH_REMOTE_REF_RE.test(stderr)) return { status: "absent", remote };
743
+ if (!NON_FAST_FORWARD_RE.test(stderr)) return { status: "unreachable", remote, stderr };
744
+
745
+ // Rejected. Resolve the remote tip into our own scratch ref (forced: the ref
746
+ // is this module's, and nothing reads it but the next two lines) and ask
747
+ // whether the local branch already contains it.
748
+ const probe = await rt.spawn(
749
+ ["git", "fetch", "--force", remote, `${STATE_BRANCH}:${PROBE_REF}`],
750
+ { cwd, env: C_LOCALE_ENV },
751
+ );
752
+ if (probe.exitCode !== 0) {
753
+ return { status: "unreachable", remote, stderr: (probe.stderr ?? "").trim() || stderr };
754
+ }
755
+ const contains = await rt.spawn(
756
+ ["git", "merge-base", "--is-ancestor", PROBE_REF, `refs/heads/${STATE_BRANCH}`],
757
+ { cwd, env: C_LOCALE_ENV },
758
+ );
759
+ await rt.spawn(["git", "update-ref", "-d", PROBE_REF], { cwd, env: C_LOCALE_ENV });
760
+ if (contains.exitCode === 0) return { status: "ahead", remote };
761
+ return { status: "diverged", remote, stderr };
762
+ }
763
+
764
+ /**
765
+ * Thrown when the ledger branch cannot be read, rather than being reported as
766
+ * empty (#2303).
767
+ */
768
+ export class LifecycleLedgerUnreadableError extends Error {
769
+ constructor(
770
+ public readonly reason: "unreachable" | "diverged",
771
+ public readonly remote: string,
772
+ public readonly stderr: string,
773
+ detail: string,
774
+ ) {
775
+ super(
776
+ `the ${STATE_BRANCH} ledger branch ${detail}, so what is on it cannot be read. ` +
777
+ `Refusing rather than treating an unreadable ledger as an empty one.` +
778
+ (reason === "diverged"
779
+ ? `\n Two histories claim to be the ledger; appending to either one drops the other's facts. ` +
780
+ `Inspect both before doing anything else:\n` +
781
+ ` git fetch ${remote} ${STATE_BRANCH}:refs/chant/remote-ledger\n` +
782
+ ` git log --oneline ${STATE_BRANCH} refs/chant/remote-ledger`
783
+ : `\n Fetch it and retry: git fetch ${remote} ${STATE_BRANCH}:${STATE_BRANCH}`) +
784
+ (stderr ? `\n git said: ${stderr.split("\n")[0]}` : ""),
785
+ );
786
+ this.name = "LifecycleLedgerUnreadableError";
787
+ }
788
+ }
789
+
790
+ /**
791
+ * Bring the ledger branch into this checkout before reading or appending to
792
+ * it, and refuse when it cannot be (#2303).
793
+ *
794
+ * The gate's read is the one that has to be right: `evaluateGate` decides
795
+ * whether to walk through or to record a *new* pending fact purely from what
796
+ * the ledger says, so a read that reports history it cannot see as an empty
797
+ * ledger makes the second run of an already-approved commit record a second
798
+ * pending fact and gate again. That is what a GitLab CI retry did on
799
+ * INTENTIUS/choudoufu#1026 — two pending records for one commit, with
800
+ * different expiries — because a GitLab checkout fetches the pipeline's own
801
+ * ref at depth 20 and nothing else. `GIT_DEPTH: 0` does not help: GitLab
802
+ * fetches refspecs, not all branches.
803
+ *
804
+ * There was a separate, laxer guard for the read until #2309's review, on the
805
+ * reasoning that a checkout which already has the branch "has real history to
806
+ * read". That reasoning is only sound when the local branch *is* the remote's
807
+ * history, and it let the worst case through: on a fork, the gate read a
808
+ * ledger missing every remote approval, recorded a fresh pending fact, and
809
+ * pushed. The push was not protected either — a rejected non-fast-forward
810
+ * fetch still updates `refs/remotes/<remote>/chant/lifecycle` opportunistically
811
+ * in a full clone, so `pushLifecycle`'s `--force-with-lease` matched the tip
812
+ * it had just learned and forced. Verified on git 2.50.1: a remote holding a
813
+ * pending fact and an approval ended up holding neither.
814
+ *
815
+ * So there is one rule now, for readers and writers alike:
816
+ *
817
+ * - `diverged` always refuses. A fork is not the ledger, and nothing good
818
+ * comes of reading one as if it were.
819
+ * - `unreachable` refuses only when the checkout has no local history at all,
820
+ * which is the case that would otherwise read as an empty ledger. With
821
+ * local history and no answer from the remote, the read proceeds and the
822
+ * push's lease fails safely on its own — refusing there would strand every
823
+ * offline run.
824
+ * - `fetched`, `no-remote`, `absent` and `ahead` all proceed. `ahead` is the
825
+ * ordinary state right after an append whose push has not landed, and is
826
+ * exactly what a retry needs to be able to repair.
827
+ */
828
+ export async function requireLifecycleLedger(opts?: { cwd?: string }): Promise<void> {
829
+ const hadLocal = (await getStateBranchTip(opts?.cwd)) !== null;
830
+ const result = await fetchLifecycleStatus(opts);
831
+
832
+ if (result.status === "diverged") {
833
+ throw new LifecycleLedgerUnreadableError(
834
+ "diverged",
835
+ result.remote,
836
+ result.stderr,
837
+ `has diverged from the copy on "${result.remote}" — neither contains the other`,
838
+ );
839
+ }
840
+ if (result.status === "unreachable" && !hadLocal) {
841
+ throw new LifecycleLedgerUnreadableError(
842
+ "unreachable",
843
+ result.remote,
844
+ result.stderr,
845
+ `is not in this checkout and could not be fetched from "${result.remote}"`,
846
+ );
847
+ }
547
848
  }
548
849
 
549
850
  // ── Generic ref CAS (#1485) ──────────────────────────────────────────────────
@@ -55,6 +55,8 @@ function qualifyObservation(obs: NormalizedObservation, stackName: string): Norm
55
55
  resources: q(obs.resources),
56
56
  unobserved: q(obs.unobserved),
57
57
  queried: q(obs.queried),
58
+ // Which read answered (#2267) is per entity, so it re-keys with them.
59
+ sources: q(obs.sources),
58
60
  notes: obs.notes,
59
61
  // Exports are already keyed by stack (#1279); nothing to qualify.
60
62
  ...(obs.stackExports ? { stackExports: obs.stackExports } : {}),
@@ -283,6 +285,7 @@ export async function observeResources(
283
285
  resources: {},
284
286
  unobserved: unobservedAll(entityNames, "read-failed", message, entities),
285
287
  queried: {},
288
+ sources: {},
286
289
  notes: [],
287
290
  },
288
291
  environment,
@@ -29,7 +29,7 @@ const meta = (over: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
29
29
 
30
30
  describe("normalizeObservation", () => {
31
31
  test("a bare map means 'I looked at everything'", () => {
32
- expect(normalizeObservation({ a: meta() })).toEqual({ resources: { a: meta() }, unobserved: {}, queried: {}, notes: [] });
32
+ expect(normalizeObservation({ a: meta() })).toEqual({ resources: { a: meta() }, unobserved: {}, queried: {}, sources: {}, notes: [] });
33
33
  });
34
34
 
35
35
  test("the envelope carries both halves", () => {
@@ -38,6 +38,7 @@ describe("normalizeObservation", () => {
38
38
  resources: { a: meta() },
39
39
  unobserved: { b: { reason: "read-failed" } },
40
40
  queried: {},
41
+ sources: {},
41
42
  notes: [],
42
43
  });
43
44
  });
@@ -50,7 +51,7 @@ describe("normalizeObservation", () => {
50
51
  });
51
52
 
52
53
  test("undefined normalizes to empty maps", () => {
53
- expect(normalizeObservation(undefined)).toEqual({ resources: {}, unobserved: {}, queried: {}, notes: [] });
54
+ expect(normalizeObservation(undefined)).toEqual({ resources: {}, unobserved: {}, queried: {}, sources: {}, notes: [] });
54
55
  });
55
56
 
56
57
  test("the envelope carries the queried addresses through normalization (#1620)", () => {
@@ -92,8 +93,8 @@ describe("unobservedAll", () => {
92
93
  describe("mergeObservations (multi-stack)", () => {
93
94
  test("present beats not-observed beats absent", () => {
94
95
  const merged = mergeObservations([
95
- { resources: {}, unobserved: { a: { reason: "read-failed" }, b: { reason: "no-binding" } }, queried: {}, notes: [] },
96
- { resources: { a: meta() }, unobserved: {}, queried: {}, notes: [] },
96
+ { resources: {}, unobserved: { a: { reason: "read-failed" }, b: { reason: "no-binding" } }, queried: {}, sources: {}, notes: [] },
97
+ { resources: { a: meta() }, unobserved: {}, queried: {}, sources: {}, notes: [] },
97
98
  ]);
98
99
  expect(Object.keys(merged.resources)).toEqual(["a"]);
99
100
  expect(Object.keys(merged.unobserved)).toEqual(["b"]);
@@ -102,26 +103,37 @@ describe("mergeObservations (multi-stack)", () => {
102
103
  test("the same note from four stacks is one note (#1265)", () => {
103
104
  const note = "ownership filter unavailable";
104
105
  const merged = mergeObservations(
105
- ["a", "b", "c", "d"].map((k) => ({ resources: { [k]: meta() }, unobserved: {}, queried: {}, notes: [note] })),
106
+ ["a", "b", "c", "d"].map((k) => ({ resources: { [k]: meta() }, unobserved: {}, queried: {}, sources: {}, notes: [note] })),
106
107
  );
107
108
  expect(merged.notes).toEqual([note]);
108
109
  });
109
110
 
110
111
  test("an entity nobody looked for in any stack stays absent", () => {
111
112
  const merged = mergeObservations([
112
- { resources: { a: meta() }, unobserved: {}, queried: {}, notes: [] },
113
- { resources: { b: meta() }, unobserved: {}, queried: {}, notes: [] },
113
+ { resources: { a: meta() }, unobserved: {}, queried: {}, sources: {}, notes: [] },
114
+ { resources: { b: meta() }, unobserved: {}, queried: {}, sources: {}, notes: [] },
114
115
  ]);
115
116
  expect(merged.unobserved).toEqual({});
116
117
  });
117
118
 
118
119
  test("queried addresses union across stacks (#1620)", () => {
119
120
  const merged = mergeObservations([
120
- { resources: {}, unobserved: {}, queried: { a: "stack-1/a" }, notes: [] },
121
- { resources: { b: meta() }, unobserved: {}, queried: { b: "stack-2/b" }, notes: [] },
121
+ { resources: {}, unobserved: {}, queried: { a: "stack-1/a" }, sources: {}, notes: [] },
122
+ { resources: { b: meta() }, unobserved: {}, queried: { b: "stack-2/b" }, sources: {}, notes: [] },
122
123
  ]);
123
124
  expect(merged.queried).toEqual({ a: "stack-1/a", b: "stack-2/b" });
124
125
  });
126
+
127
+ test("which read answered unions across parts (#2267)", () => {
128
+ // Parts read disjoint entity sets (one per stack, or per terraform root),
129
+ // so the union is the whole answer and there is nothing to reconcile. A
130
+ // project mixing a stock root and a live one gets both values here.
131
+ const merged = mergeObservations([
132
+ { resources: { a: meta() }, unobserved: {}, queried: {}, sources: { a: "state" }, notes: [] },
133
+ { resources: { b: meta() }, unobserved: {}, queried: {}, sources: { b: "live" }, notes: [] },
134
+ ]);
135
+ expect(merged.sources).toEqual({ a: "state", b: "live" });
136
+ });
125
137
  });
126
138
 
127
139
  describe("reason totality", () => {
@@ -112,6 +112,24 @@ export interface ObservationResult {
112
112
  * namespace, endpoint or region was read, not the one the resource lives in.
113
113
  */
114
114
  queried?: Record<string, string>;
115
+ /**
116
+ * Which read answered for each declared entity (#2267), keyed by chant
117
+ * entity name. The value is a short token the lexicon defines and documents;
118
+ * core carries it and never interprets it, so nothing here switches on one.
119
+ *
120
+ * Additive metadata over the tri-state, exactly like {@link queried}, and
121
+ * present for every verdict including OBSERVED-ABSENT and NOT-OBSERVED,
122
+ * since which read was attempted is a fact whatever the read came back with.
123
+ *
124
+ * A lexicon with one read path has nothing to say here and omits it. This is
125
+ * for a lexicon with two, where the two answer different questions: a stock
126
+ * Terraform root is read with `terraform show -json` over its state, which
127
+ * says what the last apply recorded, and a live root is read with
128
+ * `choudoufu live-plan -json`, which says what the account holds. A renderer
129
+ * that paints an overlay as drift must not paint the first one green, and
130
+ * before this field an observation gave it no way to tell them apart.
131
+ */
132
+ sources?: Record<string, string>;
115
133
  /**
116
134
  * Notices about the read as a whole, not about any one entity (#1265) —
117
135
  * "the ownership filter could not be applied on this surface" is the
@@ -144,6 +162,8 @@ export interface NormalizedObservation {
144
162
  unobserved: Record<string, UnobservedEntity>;
145
163
  /** Resolved query address per entity name (#1620). Empty when the lexicon reported none. */
146
164
  queried: Record<string, string>;
165
+ /** Which read answered, per entity name (#2267). Empty when the lexicon reported none. */
166
+ sources: Record<string, string>;
147
167
  /** Run-level notices (#1265), distinct. Empty when the lexicon reported none. */
148
168
  notes: string[];
149
169
  /** Per-stack exports (#1279), keyed by stack name. Absent when the lexicon reported none. */
@@ -169,12 +189,14 @@ export function observation(
169
189
  queried?: Record<string, string>,
170
190
  notes?: string[],
171
191
  stackExports?: Record<string, Record<string, unknown>>,
192
+ sources?: Record<string, string>,
172
193
  ): ObservationResult {
173
194
  return {
174
195
  observation: "v1",
175
196
  resources,
176
197
  ...(unobserved && Object.keys(unobserved).length > 0 ? { unobserved } : {}),
177
198
  ...(queried && Object.keys(queried).length > 0 ? { queried } : {}),
199
+ ...(sources && Object.keys(sources).length > 0 ? { sources } : {}),
178
200
  ...(notes && notes.length > 0 ? { notes } : {}),
179
201
  ...(stackExports && Object.keys(stackExports).length > 0 ? { stackExports } : {}),
180
202
  };
@@ -187,17 +209,18 @@ export function observation(
187
209
  * {@link unobservedAll} rather than returning nothing.
188
210
  */
189
211
  export function normalizeObservation(value: DescribeResourcesResult | undefined): NormalizedObservation {
190
- if (!value) return { resources: {}, unobserved: {}, queried: {}, notes: [] };
212
+ if (!value) return { resources: {}, unobserved: {}, queried: {}, sources: {}, notes: [] };
191
213
  if (isObservationResult(value)) {
192
214
  return {
193
215
  resources: value.resources ?? {},
194
216
  unobserved: value.unobserved ?? {},
195
217
  queried: value.queried ?? {},
218
+ sources: value.sources ?? {},
196
219
  notes: [...new Set(value.notes ?? [])],
197
220
  ...(value.stackExports && Object.keys(value.stackExports).length > 0 ? { stackExports: value.stackExports } : {}),
198
221
  };
199
222
  }
200
- return { resources: value, unobserved: {}, queried: {}, notes: [] };
223
+ return { resources: value, unobserved: {}, queried: {}, sources: {}, notes: [] };
201
224
  }
202
225
 
203
226
  /**
@@ -237,6 +260,10 @@ export function mergeObservations(parts: Iterable<NormalizedObservation>): Norma
237
260
  const resources: Record<string, ResourceMetadata> = {};
238
261
  const unobserved: Record<string, UnobservedEntity> = {};
239
262
  const queried: Record<string, string> = {};
263
+ // Which read answered (#2267) merges the same way `queried` does: it is a
264
+ // fact about one entity's read, and the parts being merged are reads of
265
+ // disjoint entity sets (one per stack, or per terraform root).
266
+ const sources: Record<string, string> = {};
240
267
  // A note is about the read, not a stack; four stacks saying the same thing
241
268
  // is one note (#1265).
242
269
  const notes = new Set<string>();
@@ -245,6 +272,7 @@ export function mergeObservations(parts: Iterable<NormalizedObservation>): Norma
245
272
  Object.assign(resources, part.resources);
246
273
  Object.assign(unobserved, part.unobserved);
247
274
  Object.assign(queried, part.queried);
275
+ Object.assign(sources, part.sources);
248
276
  for (const n of part.notes) notes.add(n);
249
277
  Object.assign(stackExports, part.stackExports ?? {});
250
278
  }
@@ -255,6 +283,7 @@ export function mergeObservations(parts: Iterable<NormalizedObservation>): Norma
255
283
  resources,
256
284
  unobserved,
257
285
  queried,
286
+ sources,
258
287
  notes: [...notes],
259
288
  ...(Object.keys(stackExports).length > 0 ? { stackExports } : {}),
260
289
  };