@intentius/chant 0.124.24 → 0.124.26

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 (195) hide show
  1. package/dist/cli/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/build.js +4 -2
  3. package/dist/cli/commands/lint.d.ts.map +1 -1
  4. package/dist/cli/commands/lint.js +4 -2
  5. package/dist/cli/handlers/approve.d.ts.map +1 -1
  6. package/dist/cli/handlers/approve.js +39 -4
  7. package/dist/cli/main.d.ts.map +1 -1
  8. package/dist/cli/main.js +21 -7
  9. package/dist/cli/mcp/workspace-tools.d.ts +3 -3
  10. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  11. package/dist/cli/mcp/workspace-tools.js +27 -4
  12. package/dist/effect-receipt.d.ts +13 -0
  13. package/dist/effect-receipt.d.ts.map +1 -1
  14. package/dist/effect-receipt.js +19 -0
  15. package/dist/gate-decision.d.ts +1 -1
  16. package/dist/gate-decision.d.ts.map +1 -1
  17. package/dist/gate-decision.js +1 -1
  18. package/dist/lexicon-module.d.ts +7 -1
  19. package/dist/lexicon-module.d.ts.map +1 -1
  20. package/dist/lexicon-module.js +7 -2
  21. package/dist/lifecycle/gate-audit.d.ts +7 -1
  22. package/dist/lifecycle/gate-audit.d.ts.map +1 -1
  23. package/dist/lifecycle/gate-audit.js +40 -3
  24. package/dist/lifecycle/gate-checkpoint.d.ts +78 -0
  25. package/dist/lifecycle/gate-checkpoint.d.ts.map +1 -0
  26. package/dist/lifecycle/gate-checkpoint.js +103 -0
  27. package/dist/lifecycle/gate-ledger.d.ts +41 -0
  28. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  29. package/dist/lifecycle/gate-ledger.js +18 -0
  30. package/dist/lifecycle/receipt-plan.d.ts.map +1 -1
  31. package/dist/lifecycle/receipt-plan.js +4 -2
  32. package/dist/lifecycle/run-ledger.d.ts +2 -0
  33. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  34. package/dist/lifecycle/run-ledger.js +2 -0
  35. package/dist/lint/rules/op/index.d.ts +6 -2
  36. package/dist/lint/rules/op/index.d.ts.map +1 -1
  37. package/dist/lint/rules/op/index.js +7 -3
  38. package/dist/lint/rules/op/ops016-gate-resume.d.ts +14 -0
  39. package/dist/lint/rules/op/ops016-gate-resume.d.ts.map +1 -0
  40. package/dist/lint/rules/op/ops016-gate-resume.js +31 -0
  41. package/dist/lint/rules/op/ops017-gate-alias.d.ts +30 -0
  42. package/dist/lint/rules/op/ops017-gate-alias.d.ts.map +1 -0
  43. package/dist/lint/rules/op/ops017-gate-alias.js +67 -0
  44. package/dist/op/activity-contract.d.ts +14 -0
  45. package/dist/op/activity-contract.d.ts.map +1 -1
  46. package/dist/op/activity-contract.js +16 -0
  47. package/dist/op/builders.d.ts +7 -0
  48. package/dist/op/builders.d.ts.map +1 -1
  49. package/dist/op/builders.js +13 -0
  50. package/dist/op/gate-alias.d.ts +57 -0
  51. package/dist/op/gate-alias.d.ts.map +1 -0
  52. package/dist/op/gate-alias.js +127 -0
  53. package/dist/op/gate-checkpoint.d.ts +123 -0
  54. package/dist/op/gate-checkpoint.d.ts.map +1 -0
  55. package/dist/op/gate-checkpoint.js +280 -0
  56. package/dist/op/gate.d.ts +54 -2
  57. package/dist/op/gate.d.ts.map +1 -1
  58. package/dist/op/gate.js +142 -1
  59. package/dist/op/index.d.ts +4 -1
  60. package/dist/op/index.d.ts.map +1 -1
  61. package/dist/op/index.js +2 -1
  62. package/dist/op/local-executor.d.ts +57 -2
  63. package/dist/op/local-executor.d.ts.map +1 -1
  64. package/dist/op/local-executor.js +200 -11
  65. package/dist/op/local-output.d.ts.map +1 -1
  66. package/dist/op/local-output.js +20 -2
  67. package/dist/op/op-ir.d.ts +4 -0
  68. package/dist/op/op-ir.d.ts.map +1 -1
  69. package/dist/op/op-ir.js +4 -0
  70. package/dist/op/receipt-store.d.ts +5 -0
  71. package/dist/op/receipt-store.d.ts.map +1 -1
  72. package/dist/op/receipt-store.js +4 -3
  73. package/dist/op/runtime.d.ts +15 -0
  74. package/dist/op/runtime.d.ts.map +1 -1
  75. package/dist/op/runtimes/local.d.ts.map +1 -1
  76. package/dist/op/runtimes/local.js +5 -0
  77. package/dist/op/step-output-ref.d.ts +26 -18
  78. package/dist/op/step-output-ref.d.ts.map +1 -1
  79. package/dist/op/step-output-ref.js +86 -63
  80. package/dist/op/types.d.ts +31 -0
  81. package/dist/op/types.d.ts.map +1 -1
  82. package/dist/wave-runtime.mjs +223 -221
  83. package/dist/workspace/agent-cli.d.ts.map +1 -1
  84. package/dist/workspace/agent-cli.js +3 -0
  85. package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  86. package/dist/workspace/conformance/writer.js +1 -1
  87. package/dist/workspace/decide.d.ts +51 -9
  88. package/dist/workspace/decide.d.ts.map +1 -1
  89. package/dist/workspace/decide.js +125 -14
  90. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  91. package/dist/workspace/lineage-upgrade.js +20 -11
  92. package/dist/workspace/point-answer.schema.json +16 -1
  93. package/dist/workspace/points-cli.d.ts +5 -3
  94. package/dist/workspace/points-cli.d.ts.map +1 -1
  95. package/dist/workspace/points-cli.js +16 -7
  96. package/dist/workspace/points-write.schema.json +39 -4
  97. package/dist/workspace/points.schema.json +42 -3
  98. package/dist/workspace/reason-codes.d.ts +2 -1
  99. package/dist/workspace/reason-codes.d.ts.map +1 -1
  100. package/dist/workspace/reason-codes.js +2 -1
  101. package/dist/workspace/records-cli.d.ts +12 -2
  102. package/dist/workspace/records-cli.d.ts.map +1 -1
  103. package/dist/workspace/records-cli.js +27 -7
  104. package/dist/workspace/records-write.d.ts.map +1 -1
  105. package/dist/workspace/records-write.js +2 -3
  106. package/dist/workspace/records.d.ts +10 -4
  107. package/dist/workspace/records.d.ts.map +1 -1
  108. package/dist/workspace/records.js +16 -3
  109. package/dist/workspace/records.schema.json +2 -1
  110. package/dist/workspace/trust/gate-seal.d.ts +32 -1
  111. package/dist/workspace/trust/gate-seal.d.ts.map +1 -1
  112. package/dist/workspace/trust/gate-seal.js +41 -2
  113. package/dist/workspace/trust/seal.d.ts +1 -1
  114. package/dist/workspace/trust/seal.d.ts.map +1 -1
  115. package/dist/workspace/trust/seal.js +1 -1
  116. package/dist/workspace/trust/verify-cli.d.ts.map +1 -1
  117. package/dist/workspace/trust/verify-cli.js +2 -1
  118. package/dist/workspace/trust/verify.d.ts +22 -0
  119. package/dist/workspace/trust/verify.d.ts.map +1 -1
  120. package/dist/workspace/trust/verify.js +94 -14
  121. package/dist/workspace/write-scope.d.ts +3 -1
  122. package/dist/workspace/write-scope.d.ts.map +1 -1
  123. package/dist/workspace/write-scope.js +11 -4
  124. package/package.json +1 -1
  125. package/src/cli/commands/build.ts +9 -2
  126. package/src/cli/commands/lint.ts +4 -2
  127. package/src/cli/compiled-cli.e2e.test.ts +45 -1
  128. package/src/cli/handlers/approve.ts +46 -4
  129. package/src/cli/handlers/operator.test.ts +91 -0
  130. package/src/cli/main.test.ts +10 -0
  131. package/src/cli/main.ts +21 -7
  132. package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
  133. package/src/cli/mcp/workspace-tools.ts +27 -4
  134. package/src/effect-receipt.ts +22 -0
  135. package/src/gate-decision.ts +2 -0
  136. package/src/lexicon-module.ts +9 -2
  137. package/src/lifecycle/gate-audit.ts +41 -4
  138. package/src/lifecycle/gate-checkpoint.test.ts +128 -0
  139. package/src/lifecycle/gate-checkpoint.ts +133 -0
  140. package/src/lifecycle/gate-ledger.ts +61 -0
  141. package/src/lifecycle/receipt-plan.test.ts +9 -0
  142. package/src/lifecycle/receipt-plan.ts +4 -1
  143. package/src/lifecycle/run-ledger.ts +4 -0
  144. package/src/lint/rules/op/index.ts +9 -3
  145. package/src/lint/rules/op/ops016-gate-resume.test.ts +53 -0
  146. package/src/lint/rules/op/ops016-gate-resume.ts +48 -0
  147. package/src/lint/rules/op/ops017-gate-alias.test.ts +85 -0
  148. package/src/lint/rules/op/ops017-gate-alias.ts +89 -0
  149. package/src/op/activity-contract.ts +22 -1
  150. package/src/op/builders.ts +21 -1
  151. package/src/op/effect-step.test.ts +95 -1
  152. package/src/op/gate-alias.e2e.test.ts +78 -0
  153. package/src/op/gate-alias.test.ts +350 -0
  154. package/src/op/gate-alias.ts +137 -0
  155. package/src/op/gate-checkpoint.test.ts +284 -0
  156. package/src/op/gate-checkpoint.ts +322 -0
  157. package/src/op/gate.ts +198 -4
  158. package/src/op/index.ts +4 -1
  159. package/src/op/local-executor.test.ts +65 -0
  160. package/src/op/local-executor.ts +248 -12
  161. package/src/op/local-output.ts +18 -2
  162. package/src/op/op-ir.test.ts +25 -0
  163. package/src/op/op-ir.ts +8 -0
  164. package/src/op/receipt-store.ts +13 -3
  165. package/src/op/runtime.ts +8 -0
  166. package/src/op/runtimes/local.ts +5 -0
  167. package/src/op/step-output-ref.test.ts +68 -24
  168. package/src/op/step-output-ref.ts +85 -65
  169. package/src/op/types.ts +32 -0
  170. package/src/workspace/agent-cli.ts +2 -0
  171. package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
  172. package/src/workspace/conformance/writer.ts +1 -1
  173. package/src/workspace/decide.test.ts +87 -3
  174. package/src/workspace/decide.ts +142 -17
  175. package/src/workspace/lineage-upgrade.test.ts +21 -0
  176. package/src/workspace/lineage-upgrade.ts +19 -11
  177. package/src/workspace/point-answer.schema.json +16 -1
  178. package/src/workspace/points-cli.ts +16 -8
  179. package/src/workspace/points-write.schema.json +39 -4
  180. package/src/workspace/points.schema.json +42 -3
  181. package/src/workspace/reason-codes.ts +2 -1
  182. package/src/workspace/record-assets.test.ts +14 -0
  183. package/src/workspace/records-cli.ts +28 -7
  184. package/src/workspace/records-write-commands.e2e.test.ts +32 -0
  185. package/src/workspace/records-write.ts +2 -3
  186. package/src/workspace/records.schema.json +2 -1
  187. package/src/workspace/records.ts +17 -4
  188. package/src/workspace/trust/gate-seal.test.ts +29 -2
  189. package/src/workspace/trust/gate-seal.ts +46 -2
  190. package/src/workspace/trust/seal.ts +1 -1
  191. package/src/workspace/trust/trust.test.ts +105 -0
  192. package/src/workspace/trust/verify-cli.ts +2 -1
  193. package/src/workspace/trust/verify.ts +106 -13
  194. package/src/workspace/write-scope.test.ts +18 -0
  195. package/src/workspace/write-scope.ts +16 -5
@@ -8,6 +8,12 @@
8
8
  * that touches those files must be attested by a signer the base policy
9
9
  * trusts, and by an admin when the base policy grants the admin role.
10
10
  *
11
+ * The write scope is policy too (#3079): when the workspace declaration at
12
+ * base has a `writeScope` or `agents` block, a commit that changes either
13
+ * block, or removes the declaration, is a protected write on the same terms.
14
+ * Other edits to the declaration are not. The declaration is found from
15
+ * `cwd`, as `chant workspace check --changes` finds it.
16
+ *
11
17
  * Attestation is opt-in (#2525 rule 6). Without a signers file at base the
12
18
  * check passes and says so, unless `--require attested` asks for more.
13
19
  *
@@ -22,6 +28,11 @@
22
28
  */
23
29
 
24
30
  import { execFileSync } from "node:child_process";
31
+ import { posix } from "node:path";
32
+ import { isDeepStrictEqual } from "node:util";
33
+ import { DECLARATION_FILES, WorkspaceReadError } from "../declaration";
34
+ import { parseJsonText } from "../jsonc";
35
+ import { locateWorkspace } from "../which-chant";
25
36
  import { type CommitAttestor, type ProvenanceLevel } from "./attestor";
26
37
  import { policyAtBase, commitProvenance, resolveBase, type BaseSource, type RecordProvenance } from "./provenance";
27
38
  import { policyWriters, protectedPaths, type ExcludedSigner, type TrustPolicy } from "./policy";
@@ -41,6 +52,8 @@ export interface CommitVerdict extends RecordProvenance {
41
52
  export interface ProtectedWrite {
42
53
  commit: string;
43
54
  paths: string[];
55
+ /** For a declaration write (#3079): the scope blocks the commit changes. */
56
+ blocks?: ScopeBlock[];
44
57
  level: ProvenanceLevel;
45
58
  principal?: string;
46
59
  allowed: boolean;
@@ -67,6 +80,12 @@ export interface ChangeReport {
67
80
  /** Protected paths whose content differs between the merge base and head. */
68
81
  protectedChanged: string[];
69
82
  protectedWrites: ProtectedWrite[];
83
+ /**
84
+ * The workspace declaration at base, when it has a scope block (#3079): its
85
+ * path from the repository root, the blocks it has, and the blocks that
86
+ * differ between the merge base and head.
87
+ */
88
+ declaration: { path: string; blocks: ScopeBlock[]; changed: ScopeBlock[] } | null;
70
89
  /**
71
90
  * When the change edits the signer set (#2553): the version it proposes and
72
91
  * who signed it, or, when the new set is refused, the version it had to
@@ -80,6 +99,8 @@ export interface ChangeReport {
80
99
 
81
100
  export interface VerifyOptions {
82
101
  repo: string;
102
+ /** Where to look for the workspace declaration, as `check --changes` does; the repository root by default. */
103
+ cwd?: string;
83
104
  base?: string;
84
105
  head?: string;
85
106
  require?: VerifyRequirement;
@@ -106,6 +127,51 @@ export function commitsTouching(repo: string, range: string, paths: string[]): M
106
127
  return out;
107
128
  }
108
129
 
130
+ /** The declaration blocks whose change is a protected write (#3079). */
131
+ export type ScopeBlock = "writeScope" | "agents";
132
+ const SCOPE_BLOCKS: readonly ScopeBlock[] = ["writeScope", "agents"];
133
+
134
+ /** The scope blocks of the declaration in `dir` at `rev`: absent ones undefined, or the text when it can't be read. */
135
+ type ScopeState = { blocks: Partial<Record<ScopeBlock, unknown>> } | { unreadable: string };
136
+
137
+ function scopeState(repo: string, rev: string, dir: string): ScopeState {
138
+ const present = DECLARATION_FILES.map((name) => ({ name, text: fileAt(repo, rev, posix.join(dir, name)) })).filter((f) => f.text !== undefined);
139
+ if (present.length === 0) return { blocks: {} };
140
+ if (present.length > 1) return { unreadable: present.map((f) => f.text).join("\0") };
141
+ const parsed = parseJsonText(present[0].text!, { jsonc: present[0].name.endsWith(".jsonc") });
142
+ if (!parsed.ok || typeof parsed.value !== "object" || parsed.value === null || Array.isArray(parsed.value)) return { unreadable: present[0].text! };
143
+ const obj = parsed.value as Record<string, unknown>;
144
+ return { blocks: Object.fromEntries(SCOPE_BLOCKS.filter((b) => obj[b] !== undefined).map((b) => [b, obj[b]])) };
145
+ }
146
+
147
+ /** The scope blocks that differ between two states; all of them when either can't be read and the texts differ. */
148
+ function changedBlocks(a: ScopeState, b: ScopeState): ScopeBlock[] {
149
+ if ("unreadable" in a || "unreadable" in b) {
150
+ return "unreadable" in a && "unreadable" in b && a.unreadable === b.unreadable ? [] : [...SCOPE_BLOCKS];
151
+ }
152
+ return SCOPE_BLOCKS.filter((k) => !isDeepStrictEqual(a.blocks[k], b.blocks[k]));
153
+ }
154
+
155
+ /**
156
+ * The directory, from the repository root, of the declaration found from
157
+ * `cwd` at `base`, with the scope blocks it has there. Null when there is no
158
+ * declaration, or one with neither block: then nothing in it is protected. A
159
+ * declaration that can't be read at base counts as having both.
160
+ */
161
+ function scopedDeclarationAt(repo: string, cwd: string, base: string): { dir: string; blocks: ScopeBlock[] } | null {
162
+ let dir: string;
163
+ try {
164
+ const root = locateWorkspace(cwd, base).root;
165
+ dir = root === "." ? "" : root;
166
+ } catch (err) {
167
+ if (err instanceof WorkspaceReadError) return null;
168
+ throw err;
169
+ }
170
+ const state = scopeState(repo, base, dir);
171
+ const blocks = "unreadable" in state ? [...SCOPE_BLOCKS] : SCOPE_BLOCKS.filter((b) => b in state.blocks);
172
+ return blocks.length > 0 ? { dir, blocks } : null;
173
+ }
174
+
109
175
  /** Whether a merge commit changes nothing beyond what its parents bring. */
110
176
  function mergeWithoutOwnChanges(repo: string, commit: string, parents: number): boolean {
111
177
  if (parents < 2) return false;
@@ -134,6 +200,7 @@ export function verifyChange(opts: VerifyOptions): ChangeReport {
134
200
  protectedPaths: protectedPaths(policy),
135
201
  protectedChanged: [],
136
202
  protectedWrites: [],
203
+ declaration: null,
137
204
  rotation: null,
138
205
  notes: [],
139
206
  failures: [],
@@ -157,7 +224,8 @@ export function verifyChange(opts: VerifyOptions): ChangeReport {
157
224
 
158
225
  // Every commit in the change, oldest first, with its parents.
159
226
  const listing = git(repo, ["rev-list", "--reverse", "--parents", `${base.commit}..${head}`]).split("\n").filter(Boolean);
160
- const parentsOf = new Map(listing.map((l) => { const [c, ...p] = l.split(" "); return [c, p.length] as const; }));
227
+ const parentList = new Map(listing.map((l) => { const [c, ...p] = l.split(" "); return [c, p] as const; }));
228
+ const parentsOf = new Map([...parentList].map(([c, p]) => [c, p.length] as const));
161
229
  report.runs = changeRuns(repo, [...parentsOf.keys()], policy.runners);
162
230
  if (opts.require === "attested-runs") {
163
231
  if (policy.runners.length === 0 && report.runs.commits.length > 0) {
@@ -204,23 +272,48 @@ export function verifyChange(opts: VerifyOptions): ChangeReport {
204
272
  }
205
273
 
206
274
  // Protected writes: every commit that touches the policy needs a writer's signature.
275
+ const writers = policyWriters(policy);
276
+ const judge = (commit: string, paths: string[], blocks?: ScopeBlock[]) => {
277
+ const v = verdicts.get(commit) ?? { ...commitProvenance(repo, policy, commit, opts.attestors), commit, subject: "" };
278
+ const allowed = v.level === "attested" && v.principal !== undefined && writers.has(v.principal);
279
+ const who = policy.roles.admin?.length ? "an admin the policy at base names" : "a signer the policy at base lists";
280
+ const what = blocks ? `${paths.join(", ")} (${blocks.join(", ")})` : paths.join(", ");
281
+ const reason = allowed
282
+ ? `signed by ${v.principal}, ${who.replace(/^an? /, "")}`
283
+ : v.level === "attested"
284
+ ? `signed by ${v.principal}, who is not ${who}`
285
+ : `${what} is protected and needs a signature by ${who}: ${v.reason}`;
286
+ report.protectedWrites.push({ commit, paths, ...(blocks ? { blocks } : {}), level: v.level, ...(v.principal ? { principal: v.principal } : {}), allowed, reason });
287
+ if (!allowed) report.failures.push(`${commit.slice(0, 8)} changes ${what}: ${reason}`);
288
+ };
207
289
  if (changedPaths.length > 0) {
208
- const writers = policyWriters(policy);
209
290
  const touching = commitsTouching(repo, `${base.commit}..${head}`, report.protectedPaths);
210
291
  if (touching.size === 0) {
211
292
  report.failures.push(`the policy files ${changedPaths.join(", ")} differ from base, and no commit in the change accounts for it`);
212
293
  }
213
- for (const [commit, paths] of touching) {
214
- const v = verdicts.get(commit) ?? { ...commitProvenance(repo, policy, commit, opts.attestors), commit, subject: "" };
215
- const allowed = v.level === "attested" && v.principal !== undefined && writers.has(v.principal);
216
- const who = policy.roles.admin?.length ? "an admin the policy at base names" : "a signer the policy at base lists";
217
- const reason = allowed
218
- ? `signed by ${v.principal}, ${who.replace(/^an? /, "")}`
219
- : v.level === "attested"
220
- ? `signed by ${v.principal}, who is not ${who}`
221
- : `${paths.join(", ")} is protected and needs a signature by ${who}: ${v.reason}`;
222
- report.protectedWrites.push({ commit, paths, level: v.level, ...(v.principal ? { principal: v.principal } : {}), allowed, reason });
223
- if (!allowed) report.failures.push(`${commit.slice(0, 8)} changes ${paths.join(", ")}: ${reason}`);
294
+ for (const [commit, paths] of touching) judge(commit, paths);
295
+ }
296
+
297
+ // The write scope in the declaration at base (#3079): a commit that changes writeScope or agents.
298
+ const scoped = scopedDeclarationAt(repo, opts.cwd ?? repo, base.commit);
299
+ if (scoped) {
300
+ const files = DECLARATION_FILES.map((name) => posix.join(scoped.dir, name));
301
+ const changed = changedBlocks(scopeState(repo, mergeBase, scoped.dir), scopeState(repo, head, scoped.dir));
302
+ report.declaration = { path: files.find((f) => fileAt(repo, base.commit!, f) !== undefined) ?? files[0], blocks: scoped.blocks, changed };
303
+ if (changed.length > 0) {
304
+ let accounted = false;
305
+ for (const [commit, paths] of commitsTouching(repo, `${base.commit}..${head}`, files)) {
306
+ const after = scopeState(repo, commit, scoped.dir);
307
+ // A merge changes a block only when it differs from every parent.
308
+ const per = (parentList.get(commit) ?? []).map((p) => new Set(changedBlocks(scopeState(repo, p, scoped.dir), after)));
309
+ const blocks = SCOPE_BLOCKS.filter((b) => per.length > 0 && per.every((s) => s.has(b)));
310
+ if (blocks.length === 0) continue;
311
+ accounted = true;
312
+ judge(commit, paths, blocks);
313
+ }
314
+ if (!accounted) {
315
+ report.failures.push(`${changed.join(" and ")} in ${report.declaration.path} differ from base, and no commit in the change accounts for it`);
316
+ }
224
317
  }
225
318
  }
226
319
 
@@ -371,6 +371,24 @@ describe("chant workspace agent: what a session reloads from (#2548)", () => {
371
371
  agent.expectValid(doc);
372
372
  expect(code(doc)).toBe("agent-unknown");
373
373
  });
374
+
375
+ test("a declaration that fails validation is declaration-invalid, with the problem, not declaration-missing (#3433)", async () => {
376
+ const broken = JSON.stringify({ name: "studio", schema: 1, members: [{ name: "app", dir: "app" }], agents: AGENTS });
377
+ for (const committed of [false, true]) {
378
+ const dir = repo({ "chant.workspace.json": broken, "app/server.mjs": "export {};\n" }, committed);
379
+ if (committed) git(dir, "branch", "-M", "main");
380
+ const doc = await agentSession({ cwd: dir, name: "app-agent" });
381
+ agent.expectValid(doc);
382
+ expect(code(doc), committed ? "committed on main" : "uncommitted").toBe("declaration-invalid");
383
+ expect("error" in doc ? doc.error.message : "").toMatch(/kind/);
384
+ }
385
+ });
386
+
387
+ test("no declaration at all is still declaration-missing", async () => {
388
+ const doc = await agentSession({ cwd: repo({ "app/server.mjs": "export {};\n" }), name: "app-agent" });
389
+ agent.expectValid(doc);
390
+ expect(code(doc)).toBe("declaration-missing");
391
+ });
374
392
  });
375
393
 
376
394
  describe("principal classes a plugin defines (#3080)", () => {
@@ -352,18 +352,28 @@ export interface ScopeSource {
352
352
  policy: TrustPolicy;
353
353
  /** The principal classes: the core four and those the declaration's pins supply, path pins read where the declaration was (#3080). */
354
354
  classes: ClassRegistry;
355
+ /** Why the working tree's declaration couldn't be read when `declaration` is null: declaration-missing, or declaration-invalid with the problem (#3433). */
356
+ failure?: WorkspaceReadError;
355
357
  }
356
358
 
357
- function readAt(cwd: string, at?: string): { declaration: Declaration; rootOnDisk: string; root: string; tree: WorkspaceTree } | null {
359
+ type ReadAt = { declaration: Declaration; rootOnDisk: string; root: string; tree: WorkspaceTree };
360
+
361
+ /** The declaration at `at` (the working tree without it), or the read error that says why there is none. */
362
+ function readOrFailure(cwd: string, at?: string): ReadAt | WorkspaceReadError {
358
363
  try {
359
364
  const located = locateWorkspace(cwd, at);
360
365
  return { declaration: readDeclaration(located.tree), rootOnDisk: located.rootOnDisk, root: located.root, tree: located.tree };
361
366
  } catch (err) {
362
- if (err instanceof WorkspaceReadError) return null;
367
+ if (err instanceof WorkspaceReadError) return err;
363
368
  throw err;
364
369
  }
365
370
  }
366
371
 
372
+ function readAt(cwd: string, at?: string): ReadAt | null {
373
+ const read = readOrFailure(cwd, at);
374
+ return read instanceof WorkspaceReadError ? null : read;
375
+ }
376
+
367
377
  /**
368
378
  * The scope for a write from `cwd`: the declaration and the trust policy at
369
379
  * the base revision (`origin/HEAD`, `main` or `master`), so a write can't
@@ -375,7 +385,7 @@ export function scopeSource(cwd: string, at?: string): ScopeSource {
375
385
  const top = gitRoot(cwd);
376
386
  const base = top ? resolveBase(top, at) : null;
377
387
  const policy = top && base ? policyAtBase(top, base) : emptyPolicy(null);
378
- const withClasses = (read: NonNullable<ReturnType<typeof readAt>>) => {
388
+ const withClasses = (read: ReadAt) => {
379
389
  const { tree, ...rest } = read;
380
390
  return { ...rest, classes: loadClassRegistry(read.declaration.pins, read.rootOnDisk, { tree }).registry };
381
391
  };
@@ -383,8 +393,9 @@ export function scopeSource(cwd: string, at?: string): ScopeSource {
383
393
  const atBase = readAt(cwd, base.commit);
384
394
  if (atBase) return { ...withClasses(atBase), from: "base", policy };
385
395
  }
386
- const here = readAt(cwd);
387
- return here ? { ...withClasses(here), from: "working-tree", policy } : { declaration: null, from: null, rootOnDisk: null, root: null, policy, classes: coreClassRegistry() };
396
+ const here = readOrFailure(cwd);
397
+ if (here instanceof WorkspaceReadError) return { declaration: null, from: null, rootOnDisk: null, root: null, policy, classes: coreClassRegistry(), failure: here };
398
+ return { ...withClasses(here), from: "working-tree", policy };
388
399
  }
389
400
 
390
401
  const toPosix = (p: string) => (sep === "/" ? p : p.split(sep).join("/"));