@intentius/chant 0.110.0 → 0.112.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 (59) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/op/activities/factory.d.ts.map +1 -1
  3. package/dist/op/steward.d.ts +1 -1
  4. package/dist/op/steward.d.ts.map +1 -1
  5. package/dist/workspace/box-cli.d.ts +3 -2
  6. package/dist/workspace/box-cli.d.ts.map +1 -1
  7. package/dist/workspace/box-factory-write.d.ts +71 -0
  8. package/dist/workspace/box-factory-write.d.ts.map +1 -0
  9. package/dist/workspace/box-intent.d.ts +6 -0
  10. package/dist/workspace/box-intent.d.ts.map +1 -1
  11. package/dist/workspace/declaration-write-cli.d.ts +11 -0
  12. package/dist/workspace/declaration-write-cli.d.ts.map +1 -0
  13. package/dist/workspace/declaration-write.d.ts +78 -0
  14. package/dist/workspace/declaration-write.d.ts.map +1 -0
  15. package/dist/workspace/declaration.d.ts +8 -0
  16. package/dist/workspace/declaration.d.ts.map +1 -1
  17. package/dist/workspace/json-edit.d.ts +11 -0
  18. package/dist/workspace/json-edit.d.ts.map +1 -1
  19. package/dist/workspace/ls.d.ts +8 -0
  20. package/dist/workspace/ls.d.ts.map +1 -1
  21. package/dist/workspace/reason-codes.d.ts +4 -0
  22. package/dist/workspace/reason-codes.d.ts.map +1 -1
  23. package/dist/workspace/status-stewards.d.ts +4 -1
  24. package/dist/workspace/status-stewards.d.ts.map +1 -1
  25. package/dist/workspace/status.d.ts.map +1 -1
  26. package/dist/workspace/steward-index.d.ts +89 -0
  27. package/dist/workspace/steward-index.d.ts.map +1 -0
  28. package/dist/workspace/write-scope.d.ts +10 -0
  29. package/dist/workspace/write-scope.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/cli/main.test.ts +2 -0
  32. package/src/cli/main.ts +18 -0
  33. package/src/op/activities/factory.ts +3 -1
  34. package/src/op/factory.e2e.test.ts +3 -1
  35. package/src/op/steward.ts +1 -1
  36. package/src/workspace/box-cli.ts +35 -4
  37. package/src/workspace/box-factory-write.schema.json +342 -0
  38. package/src/workspace/box-factory-write.test.ts +108 -0
  39. package/src/workspace/box-factory-write.ts +226 -0
  40. package/src/workspace/box-intent.test.ts +14 -4
  41. package/src/workspace/box-intent.ts +9 -1
  42. package/src/workspace/declaration-extensions.test.ts +79 -0
  43. package/src/workspace/declaration-write-cli.ts +82 -0
  44. package/src/workspace/declaration-write.test.ts +155 -0
  45. package/src/workspace/declaration-write.ts +275 -0
  46. package/src/workspace/declaration.ts +19 -1
  47. package/src/workspace/json-edit.ts +71 -0
  48. package/src/workspace/ls-contract.test.ts +53 -0
  49. package/src/workspace/ls.schema.json +11 -0
  50. package/src/workspace/ls.ts +18 -0
  51. package/src/workspace/member-write.schema.json +193 -0
  52. package/src/workspace/reason-codes.test.ts +4 -0
  53. package/src/workspace/reason-codes.ts +6 -0
  54. package/src/workspace/status-stewards.ts +23 -35
  55. package/src/workspace/status.schema.json +10 -1
  56. package/src/workspace/status.ts +25 -6
  57. package/src/workspace/steward-index.test.ts +171 -0
  58. package/src/workspace/steward-index.ts +226 -0
  59. package/src/workspace/write-scope.ts +17 -0
@@ -0,0 +1,193 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://intentius.io/chant/schemas/workspace/member-write/v1/member-write.schema.json",
4
+ "title": "chant workspace member add|remove and host set output",
5
+ "description": "What `chant workspace member add <name> --from <file|->`, `member remove <name>` and `host set <name> --from <file|->` print (#3596): the entry before and after the write and the file it changed, or the reason it wrote nothing. Each write changes one entry of members or hosts in the declaration, in place, keeping the file's formatting and comments, and never commits. Readers ignore fields they do not know; a field is only ever added within a version. The error codes are a closed list, each in the one closed list of `reason-codes.ts`. Contract version 1 of this document is written by chant 0.110.0 and newer.",
6
+ "oneOf": [
7
+ {
8
+ "$ref": "#/$defs/result"
9
+ },
10
+ {
11
+ "$ref": "#/$defs/failure"
12
+ }
13
+ ],
14
+ "$defs": {
15
+ "result": {
16
+ "description": "The entry was written, or would be with --dry-run, or the declaration already held it (changed false). Exit code 0.",
17
+ "type": "object",
18
+ "required": [
19
+ "$schema",
20
+ "contract",
21
+ "chant",
22
+ "action",
23
+ "name",
24
+ "declaration",
25
+ "paths",
26
+ "changed",
27
+ "dryRun",
28
+ "previous",
29
+ "entry"
30
+ ],
31
+ "properties": {
32
+ "$schema": {
33
+ "const": "https://intentius.io/chant/schemas/workspace/member-write/v1/member-write.schema.json"
34
+ },
35
+ "contract": {
36
+ "const": 1
37
+ },
38
+ "chant": {
39
+ "type": "string",
40
+ "description": "The chant that wrote it."
41
+ },
42
+ "action": {
43
+ "enum": [
44
+ "member add",
45
+ "member remove",
46
+ "host set"
47
+ ]
48
+ },
49
+ "name": {
50
+ "type": "string",
51
+ "description": "The member's or host's name."
52
+ },
53
+ "declaration": {
54
+ "type": "object",
55
+ "required": [
56
+ "path",
57
+ "sha256"
58
+ ],
59
+ "properties": {
60
+ "path": {
61
+ "type": "string",
62
+ "description": "The declaration file, relative to the repository root (the working directory outside git), with / separators."
63
+ },
64
+ "sha256": {
65
+ "type": "string",
66
+ "pattern": "^[0-9a-f]{64}$",
67
+ "description": "The sha256 of the declaration's bytes after the write, or as they would be with --dry-run."
68
+ }
69
+ }
70
+ },
71
+ "paths": {
72
+ "type": "array",
73
+ "items": {
74
+ "type": "string"
75
+ },
76
+ "description": "The declaration, relative to the repository root, when the write changed it or would with --dry-run; empty when it already held what was given."
77
+ },
78
+ "changed": {
79
+ "type": "boolean",
80
+ "description": "Whether the declaration changed: paths is not empty."
81
+ },
82
+ "dryRun": {
83
+ "type": "boolean",
84
+ "description": "True when --dry-run was given: nothing was written, and the document says what would have been."
85
+ },
86
+ "previous": {
87
+ "oneOf": [
88
+ {
89
+ "type": "null"
90
+ },
91
+ {
92
+ "type": "object"
93
+ }
94
+ ],
95
+ "description": "The entry before the write, as the declaration held it, x- keys included; null when member add or host set adds a new one."
96
+ },
97
+ "entry": {
98
+ "oneOf": [
99
+ {
100
+ "type": "null"
101
+ },
102
+ {
103
+ "type": "object",
104
+ "required": [
105
+ "name"
106
+ ],
107
+ "properties": {
108
+ "name": {
109
+ "type": "string"
110
+ }
111
+ }
112
+ }
113
+ ],
114
+ "description": "The entry after the write, as the declaration holds it, with name first; null after member remove."
115
+ }
116
+ }
117
+ },
118
+ "failure": {
119
+ "type": "object",
120
+ "description": "Nothing was written. Exit code 1.",
121
+ "required": [
122
+ "$schema",
123
+ "contract",
124
+ "chant",
125
+ "action",
126
+ "name",
127
+ "error"
128
+ ],
129
+ "properties": {
130
+ "$schema": {
131
+ "const": "https://intentius.io/chant/schemas/workspace/member-write/v1/member-write.schema.json"
132
+ },
133
+ "contract": {
134
+ "const": 1
135
+ },
136
+ "chant": {
137
+ "type": "string"
138
+ },
139
+ "action": {
140
+ "enum": [
141
+ "member add",
142
+ "member remove",
143
+ "host set"
144
+ ]
145
+ },
146
+ "name": {
147
+ "type": [
148
+ "string",
149
+ "null"
150
+ ]
151
+ },
152
+ "error": {
153
+ "type": "object",
154
+ "required": [
155
+ "code",
156
+ "message"
157
+ ],
158
+ "properties": {
159
+ "code": {
160
+ "enum": [
161
+ "declaration-missing",
162
+ "declaration-ambiguous",
163
+ "declaration-unparseable",
164
+ "declaration-invalid",
165
+ "placement-invalid",
166
+ "reader-too-old",
167
+ "root-chant-required",
168
+ "not-a-git-repository",
169
+ "revision-unknown",
170
+ "write-usage-invalid",
171
+ "write-input-invalid",
172
+ "member-exists",
173
+ "member-unknown",
174
+ "box-isolation-collision",
175
+ "box-isolation-literal",
176
+ "write-scope-member",
177
+ "write-scope-protected",
178
+ "write-scope-class-unknown",
179
+ "agent-unknown",
180
+ "principal-unidentified",
181
+ "write-lock-timeout",
182
+ "write-lock-not-held"
183
+ ]
184
+ },
185
+ "message": {
186
+ "type": "string"
187
+ }
188
+ }
189
+ }
190
+ }
191
+ }
192
+ }
193
+ }
@@ -40,7 +40,9 @@ import { PATCH_ERROR_CODES } from "./patch";
40
40
  import { AGENT_ERROR_CODES } from "./agent-cli";
41
41
  import { SCOPE_FINDING_CODES, WRITE_SCOPE_CODES } from "./write-scope";
42
42
  import { PLANTABLE_REASON_CODES } from "./box-factory";
43
+ import { BOX_FACTORY_ERROR_CODES } from "./box-factory-write";
43
44
  import { BOX_LISTING_ERROR_CODES } from "./box-listing";
45
+ import { DECLARATION_WRITE_ERROR_CODES } from "./declaration-write";
44
46
  import { BOX_PUBLISH_ERROR_CODES } from "./box-publish";
45
47
  import { WRITE_LOCK_ERROR_CODES } from "./write-lock-cli";
46
48
  import { WIP_ERROR_CODES, WIP_WRITE_ERROR_CODES } from "./wip";
@@ -108,6 +110,8 @@ const PER_COMMAND: Record<string, readonly string[]> = {
108
110
  SCOPE_FINDING_CODES,
109
111
  PLANTABLE_REASON_CODES,
110
112
  BOX_LISTING_ERROR_CODES,
113
+ DECLARATION_WRITE_ERROR_CODES,
114
+ BOX_FACTORY_ERROR_CODES,
111
115
  BOX_PUBLISH_ERROR_CODES,
112
116
  WRITE_LOCK_ERROR_CODES,
113
117
  WIP_ERROR_CODES,
@@ -260,6 +260,12 @@ export const REASONS = {
260
260
  "listing-member-unknown": "box listing set names a member the declaration does not declare.",
261
261
  "listing-box-missing": "box listing set names a member whose entry declares no box block, so it has no listing.",
262
262
  "listing-cover-invalid": "The cover can't be read, is not a PNG, JPEG or WebP picture, is larger than 5 MiB, has a path outside the workspace, or has an extension other than its picture format's.",
263
+ // A declaration's members and hosts written through chant (member add|remove, host set, #3596): why the write was refused. Nothing is written.
264
+ "member-exists": "member add names a member the declaration already has, with an entry other than the one given.",
265
+ "member-unknown": "member remove names a member the declaration does not declare.",
266
+ // A box's factory written through chant (box factory set, #3600): why the write was refused. Nothing is written.
267
+ "factory-member-unknown": "box factory set names a member the declaration does not declare.",
268
+ "factory-box-missing": "box factory set names a member whose entry declares no box block, so it has no factory.",
263
269
  // A box's work published through chant (box publish, #3165, ws-088): why the call printed no result.
264
270
  "publish-member-unknown": "box publish names a member the declaration does not declare.",
265
271
  "publish-none": "box publish names a member whose box block names no publisher, or which declares no box block.",
@@ -4,7 +4,8 @@
4
4
  * its Ops with their schedules, and each Op's last run.
5
5
  *
6
6
  * The steward itself is read from its declaration, the same `*.op.ts` files
7
- * `chant operator --steward` reads (`../op/discover.ts`), and only for a member
7
+ * `chant operator --steward` reads (`../op/discover.ts`), through the index
8
+ * kept per tree (`./steward-index.ts`, #3636), and only for a member
8
9
  * of kind `chant` that is a chant project of its own (a `chant.config.ts` or
9
10
  * `.json` in its directory), so a member never lists a parent project's
10
11
  * steward. Everything
@@ -47,8 +48,7 @@
47
48
 
48
49
  import { existsSync, realpathSync } from "node:fs";
49
50
  import { dirname, join, relative, resolve } from "node:path";
50
- import { discoverOps, discoverStewards } from "../op/discover";
51
- import { stewardBesideFor, stewardFormFor, stewardLeaseName, type StewardForm } from "../op/steward";
51
+ import { stewardFormFor, stewardLeaseName, type StewardForm } from "../op/steward";
52
52
  import { readRunLedger, runEnvOf } from "../lifecycle/run-ledger";
53
53
  import { readConvergeLedger, type ConvergeTickRecord } from "../lifecycle/converge-ledger";
54
54
  import { leaseRef } from "../lifecycle/lease";
@@ -56,7 +56,7 @@ import { readBlobBySha, readRefSha } from "../lifecycle/git";
56
56
  import { resolveMemberLedger } from "../lifecycle/member-ledger";
57
57
  import { listWorkLeases } from "../lifecycle/work-lease";
58
58
  import { stewardWorkHolder } from "../op/work-lease-run";
59
- import type { OpConfig } from "../op/types";
59
+ import { readStewardIndex, type IndexedOp, type IndexedSteward, type StewardIndex } from "./steward-index";
60
60
  import type { OpRunRecord } from "../op/runtime";
61
61
  import { readInFlightRun } from "../op/run-live";
62
62
  import { approveCommand } from "./status-gates";
@@ -342,19 +342,14 @@ async function readLeaseRef(leaseName: string, memberDir: string, now: string):
342
342
  }
343
343
 
344
344
  /** An Op's `beside` entry (#2861): null for an Op run as one of the steward's turns. */
345
- async function besideOf(
346
- declaration: Parameters<typeof stewardBesideFor>[0],
347
- op: string,
348
- memberDir: string,
349
- now: string,
350
- ): Promise<StatusStewardOp["beside"]> {
351
- const beside = stewardBesideFor(declaration, op);
345
+ async function besideOf(steward: IndexedSteward, op: string, memberDir: string, now: string): Promise<StatusStewardOp["beside"]> {
346
+ const beside = steward.beside.find((b) => b.op === op);
352
347
  if (!beside) return null;
353
- return { ready: beside.ready !== null && beside.ready !== undefined, lease: await readLeaseRef(op, memberDir, now) };
348
+ return { ready: beside.ready, lease: await readLeaseRef(op, memberDir, now) };
354
349
  }
355
350
 
356
351
  /** The work leases `steward`'s turns of `op` hold, from the ledger of the Op's kind or the member's. */
357
- async function readHeldWorkLeases(steward: string, op: OpConfig, memberDir: string, now: string): Promise<StatusStewardWorkLease[]> {
352
+ async function readHeldWorkLeases(steward: string, op: IndexedOp, memberDir: string, now: string): Promise<StatusStewardWorkLease[]> {
358
353
  try {
359
354
  const kind = op.workLease?.kind;
360
355
  const cwd = kind ? dirname(resolve(memberDir, kind)) : memberDir;
@@ -378,7 +373,7 @@ export async function readMemberStewards(
378
373
  now: string,
379
374
  kind = "chant",
380
375
  box: { capabilities: { name: string; broker: string | null }[] } | null = null,
381
- deps: { readQuestions?: ReadQuestionStates } = {},
376
+ deps: { readQuestions?: ReadQuestionStates; readIndex?: (memberDir: string) => Promise<StewardIndex> } = {},
382
377
  ): Promise<MemberStewards> {
383
378
  const reasons: MemberStewards["reasons"] = [];
384
379
  // Read once per member, and only when a run stopped on a question.
@@ -386,19 +381,18 @@ export async function readMemberStewards(
386
381
  const questionStates = () => (states ??= (deps.readQuestions ?? readQuestionStates)(memberDir));
387
382
  if (kind !== "chant" || !isChantProject(memberDir)) return { stewards: [], reasons };
388
383
 
389
- let discovered: Awaited<ReturnType<typeof discoverStewards>>;
384
+ let index: StewardIndex;
390
385
  try {
391
- discovered = await discoverStewards({ cwd: memberDir });
386
+ index = await (deps.readIndex ?? readStewardIndex)(memberDir);
392
387
  } catch (err) {
393
388
  reasons.push({ code: "stewards-unreadable", message: err instanceof Error ? err.message.split("\n")[0] : String(err) });
394
389
  return { stewards: [], reasons };
395
390
  }
396
- const { stewards: found, errors, conflicts } = discovered;
397
- for (const message of errors) reasons.push({ code: "stewards-unreadable", message });
398
- for (const message of conflicts) reasons.push({ code: "stewards-conflict", message });
391
+ for (const message of index.errors) reasons.push({ code: "stewards-unreadable", message });
392
+ for (const message of index.conflicts) reasons.push({ code: "stewards-conflict", message });
399
393
 
400
394
  const stewards: StatusSteward[] = [];
401
- for (const { declaration, filePath } of [...found.values()].sort((a, b) => a.declaration.name.localeCompare(b.declaration.name))) {
395
+ for (const declaration of [...index.stewards].sort((a, b) => a.name.localeCompare(b.name))) {
402
396
  const ops: StatusStewardOp[] = [];
403
397
  const waiting: StatusSteward["waiting"] = [];
404
398
  for (const op of declaration.ops) {
@@ -467,11 +461,11 @@ export async function readMemberStewards(
467
461
  }
468
462
  stewards.push({
469
463
  name: declaration.name,
470
- file: relative(realpathSync(memberDir), realpathSync(filePath)).split("\\").join("/"),
464
+ file: relative(realpathSync(memberDir), realpathSync(declaration.filePath)).split("\\").join("/"),
471
465
  form: stewardFormFor(declaration, env),
472
466
  forms: { default: declaration.form.default, environments: { ...declaration.form.environments } },
473
- vault: typeof declaration.vault === "string" ? declaration.vault : null,
474
- capabilities: (Array.isArray(declaration.capabilities) ? declaration.capabilities : []).map((name) => {
467
+ vault: declaration.vault,
468
+ capabilities: declaration.capabilities.map((name) => {
475
469
  const declared = box?.capabilities.find((c) => c.name === name);
476
470
  return { name, broker: declared?.broker ?? null, declared: declared !== undefined };
477
471
  }),
@@ -480,7 +474,7 @@ export async function readMemberStewards(
480
474
  waiting,
481
475
  });
482
476
  }
483
- if (stewards.length > 0) await addUndeclaredWaits(memberDir, stewards, found, reasons, now, questionStates);
477
+ if (stewards.length > 0 && index.otherOps) await addUndeclaredWaits(memberDir, stewards, index.otherOps, reasons, now, questionStates);
484
478
  return { stewards, reasons };
485
479
  }
486
480
 
@@ -489,26 +483,20 @@ export async function readMemberStewards(
489
483
  * steward its record names. A steward's process can start an Op itself
490
484
  * with `CHANT_STEWARD` set (studio#137), and the run ledger then records
491
485
  * the run as the steward's though the declaration does not list the Op.
492
- * Import failures are left out here: `discoverStewards` read the same files
493
- * and reported them as `stewards-unreadable`.
486
+ * `ops` are the member's Ops no steward lists, from the index; import
487
+ * failures are left out there, as discovering the stewards reported them as
488
+ * `stewards-unreadable`.
494
489
  */
495
490
  async function addUndeclaredWaits(
496
491
  memberDir: string,
497
492
  stewards: StatusSteward[],
498
- found: Awaited<ReturnType<typeof discoverStewards>>["stewards"],
493
+ ops: IndexedOp[],
499
494
  reasons: MemberStewards["reasons"],
500
495
  now: string,
501
496
  questionStates: () => Promise<Map<string, string> | null>,
502
497
  ): Promise<void> {
503
- const declared = new Set([...found.values()].flatMap(({ declaration }) => declaration.ops.map((op) => op.name)));
504
- let ops: OpConfig[];
505
- try {
506
- ops = [...(await discoverOps({ cwd: memberDir })).ops.values()].map((d) => d.config).filter((op) => !declared.has(op.name));
507
- } catch {
508
- return;
509
- }
510
498
  const byName = new Map(stewards.map((s) => [s.name, s]));
511
- for (const op of ops.sort((a, b) => a.name.localeCompare(b.name))) {
499
+ for (const op of [...ops].sort((a, b) => a.name.localeCompare(b.name))) {
512
500
  const opEnv = runEnvOf(op);
513
501
  try {
514
502
  const newest = (await readRunLedger(opEnv, op.name, { cwd: memberDir })).records.at(-1);
@@ -7,6 +7,7 @@
7
7
  "$defs": {
8
8
  "boxShip": {
9
9
  "type": "object",
10
+ "patternProperties": { "^x-": { "description": "Added in contract 1 by #3595. An x- key the declaration holds on this object, with its value as written." } },
10
11
  "additionalProperties": false,
11
12
  "required": ["op", "gate", "env", "bookkeeping", "serving", "pending"],
12
13
  "properties": {
@@ -170,6 +171,7 @@
170
171
  },
171
172
  "member": {
172
173
  "type": "object",
174
+ "patternProperties": { "^x-": { "description": "Added in contract 1 by #3595. An x- key the declaration holds on this object, with its value as written." } },
173
175
  "required": ["name", "dir", "kind", "fields", "environments", "compare", "readable", "gateLedger", "gates", "box", "stewards", "stewardReasons"],
174
176
  "properties": {
175
177
  "name": { "type": "string" },
@@ -543,7 +545,11 @@
543
545
  "choice": { "description": "The record's choice as written, such as { option, reason }, or null while the record is proposed." },
544
546
  "answer": { "type": ["string", "null"], "description": "The chosen option's label, from options[]'s entry whose id is choice.option, or null while no option is chosen (#2855). Added by chant 0.94.0." },
545
547
  "decided_by": { "type": ["string", "null"], "description": "Who decided it, or null while it is proposed." },
546
- "decided_on": { "type": ["string", "null"], "description": "The day it was decided, or null while it is proposed." }
548
+ "decided_on": { "type": ["string", "null"], "description": "The day it was decided, or null while it is proposed." },
549
+ "approved": {
550
+ "type": "boolean",
551
+ "description": "Added in contract 1 by #3609. Whether the record's state counts as approved: the decision kind's approval rank for that state is above 0, the rule the factory's pick holds dispatch with. With chant's decision kind, decided, ratified and superseded are approved, and proposed and withdrawn are not. False when no decision record has the id, and for a kind that declares no approval ranks."
552
+ }
547
553
  }
548
554
  },
549
555
  "replication": {
@@ -583,6 +589,7 @@
583
589
  },
584
590
  "box": {
585
591
  "type": "object",
592
+ "patternProperties": { "^x-": { "description": "Added in contract 1 by #3595. An x- key the declaration holds on this object, with its value as written." } },
586
593
  "required": ["capabilities", "isolation", "intent", "services"],
587
594
  "properties": {
588
595
  "factory": {
@@ -681,6 +688,7 @@
681
688
  },
682
689
  "factory": {
683
690
  "type": "object",
691
+ "patternProperties": { "^x-": { "description": "Added in contract 1 by #3595. An x- key the declaration holds on this object, with its value as written." } },
684
692
  "required": ["builds", "check", "checks", "builders", "tiers", "builderFor", "publish"],
685
693
  "properties": {
686
694
  "builds": { "type": "array", "minItems": 1, "items": { "type": "string" }, "description": "The members the factory builds, in declaration order. A work item a person asks for constrains member:<the first>." },
@@ -723,6 +731,7 @@
723
731
  },
724
732
  "listing": {
725
733
  "type": "object",
734
+ "patternProperties": { "^x-": { "description": "Added in contract 1 by #3595. An x- key the declaration holds on this object, with its value as written." } },
726
735
  "required": ["published", "title", "line", "cover"],
727
736
  "properties": {
728
737
  "published": { "type": "boolean", "description": "Whether the box is listed; true unless declared otherwise." },
@@ -48,7 +48,7 @@ import { resolveBoxes, type ResolvedIsolation } from "./box-isolation";
48
48
  import { resolveBoxIntents, unresolvedIntent, type BoxIntent } from "./box-intent";
49
49
  import { loadKindRegistry, resolveMemberFields, type MemberFields } from "./kinds";
50
50
  import { factoryView, listingView, plantability, treeBytes, type FactoryView, type ListingView, type Plantable } from "./box-factory";
51
- import { declaredRecordKinds, readDeclaration, readerVersion, WorkspaceReadError, type Declaration, type ErrorLocation, type Member } from "./declaration";
51
+ import { declaredRecordKinds, extensionsAt, readDeclaration, readerVersion, WorkspaceReadError, type Declaration, type ErrorLocation, type Member } from "./declaration";
52
52
  import type { ReasonCode } from "./reason-codes";
53
53
  import { gateAdmissionFrom } from "./identity";
54
54
  import { scopeSource } from "./write-scope";
@@ -320,6 +320,18 @@ export interface StatusQuery {
320
320
  readStewards?: typeof readMemberStewards;
321
321
  }
322
322
 
323
+ /** A factory view with the declaration's x- keys on it and on its check, publish and tiers (#3595). */
324
+ function factoryWithX(view: FactoryView | null, pointer: string, x: (pointer: string) => Record<string, unknown>): FactoryView | null {
325
+ if (view === null) return null;
326
+ return {
327
+ ...view,
328
+ check: view.check === null ? null : { ...view.check, ...x(`${pointer}/check`) },
329
+ tiers: view.tiers.map((t, i) => ({ ...t, ...x(`${pointer}/tiers/${i}`) })),
330
+ publish: view.publish === null ? null : { ...view.publish, ...x(`${pointer}/publish`) },
331
+ ...x(pointer),
332
+ };
333
+ }
334
+
323
335
  class StatusError extends Error {
324
336
  constructor(
325
337
  readonly code: StatusErrorCode,
@@ -500,6 +512,10 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
500
512
  const own = await hasMemberLedger(m, found.dir);
501
513
  const gates = await readMemberGates(own ? `${MEMBERS_DIR}/${m.name}/${GATES_DIR}` : GATES_DIR, own ? "members" : "flat", commit, envs, found.dir, now, query.readGates, rules);
502
514
  const stewards = await (query.readStewards ?? readMemberStewards)(resolve(found.dir, m.dir), query.env, now, m.kind, m.box);
515
+ // The declaration's x- keys go back on the objects that hold them (#3595).
516
+ const x = (pointer: string) => extensionsAt(declaration, pointer);
517
+ const bp = m.box?.pointer ?? "";
518
+ const withX = <T extends object>(view: T | null, pointer: string): T | null => (view === null ? null : { ...view, ...x(pointer) });
503
519
  members.push({
504
520
  name: m.name,
505
521
  dir: m.dir,
@@ -514,7 +530,7 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
514
530
  m.box === null
515
531
  ? null
516
532
  : {
517
- capabilities: m.box.capabilities.map((c) => ({ name: c.name, broker: c.broker, scope: [...c.scope] })),
533
+ capabilities: m.box.capabilities.map((c) => ({ name: c.name, broker: c.broker, scope: [...c.scope], ...x(c.pointer) })),
518
534
  isolation: isolation.get(m.name) ?? null,
519
535
  intent: intents.get(m.name) ?? null,
520
536
  services: m.box.services.map((s) => ({
@@ -525,15 +541,18 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
525
541
  duration: s.duration,
526
542
  health: s.health,
527
543
  optional: s.optional,
544
+ ...x(s.pointer),
528
545
  })),
529
- factory: factoryView(m.box.factory, declaration.members),
530
- listing: listingView(m.box.listing, coverBytes),
546
+ factory: factoryWithX(factoryView(m.box.factory, declaration.members), `${bp}/factory`, x),
547
+ listing: withX(listingView(m.box.listing, coverBytes), `${bp}/listing`),
531
548
  publisher: m.box.publisher,
532
- ship: await shipView(m, found.dir, read),
533
- replicate: m.box.replicate === null ? null : (({ box: _box, ...rest }) => rest)(policyView(m.name, m.box.replicate)),
549
+ ship: withX(await shipView(m, found.dir, read), `${bp}/ship`),
550
+ replicate: withX(m.box.replicate === null ? null : (({ box: _box, ...rest }) => rest)(policyView(m.name, m.box.replicate)), `${bp}/replicate`),
551
+ ...x(bp),
534
552
  },
535
553
  stewards: stewards.stewards,
536
554
  stewardReasons: stewards.reasons,
555
+ ...x(m.pointer),
537
556
  });
538
557
  }
539
558
  // Several members read from one flat ledger see the same records; say so.
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The steward index behind `chant workspace status` (#3636): discovered
3
+ * once per tree, read back while the tree is the same, discovered again
4
+ * after an edit, a new file or a commit, and never written into the
5
+ * checkout.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { afterAll, afterEach, beforeEach, describe, expect, test } from "vitest";
11
+ import { cleanScratch, commitAll, git, repo, scratchDir } from "./__fixtures__/contract-repo";
12
+ import { declareSteward } from "../op/steward";
13
+ import type { OpConfig } from "../op/types";
14
+ import { discoverStewardIndex, readStewardIndex, type StewardIndex } from "./steward-index";
15
+ import { readMemberStewards } from "./status-stewards";
16
+
17
+ afterAll(cleanScratch);
18
+
19
+ let cache: string;
20
+ let before: string | undefined;
21
+ beforeEach(() => {
22
+ before = process.env.CHANT_CACHE_DIR;
23
+ cache = scratchDir("chant-steward-index-cache-");
24
+ process.env.CHANT_CACHE_DIR = cache;
25
+ });
26
+ afterEach(() => {
27
+ if (before === undefined) delete process.env.CHANT_CACHE_DIR;
28
+ else process.env.CHANT_CACHE_DIR = before;
29
+ delete process.env.CHANT_STEWARD_INDEX;
30
+ });
31
+
32
+ function op(name: string, extra: Partial<OpConfig> = {}): OpConfig {
33
+ return {
34
+ name,
35
+ overview: `${name} fixture`,
36
+ phases: [{ name: "only", steps: [{ kind: "activity", fn: "noop", args: {} }] }],
37
+ ...extra,
38
+ } as OpConfig;
39
+ }
40
+
41
+ /** A chant project whose one Op file declares a steward and exports another Op it doesn't list. */
42
+ function project(): string {
43
+ const tick = op("tick", { schedule: { cron: "0 * * * *" }, labels: { Env: "box" } });
44
+ const build = op("build", { workLease: { item: "W-001", kind: "work/work.kind.mjs" }, changesCheckout: true });
45
+ const steward = declareSteward({
46
+ name: "box-steward",
47
+ ops: [tick],
48
+ beside: [{ op: build, ready: { kind: "activity", fn: "ready", args: {} } }],
49
+ form: { default: "local", environments: { k3d: "fountain" } },
50
+ capabilities: ["inference"],
51
+ });
52
+ return repo(
53
+ {
54
+ "chant.config.json": "{}\n",
55
+ "ops/steward.op.ts": `export const steward = ${JSON.stringify(steward)};\nexport const lonely = { props: ${JSON.stringify(op("lonely"))} };\n`,
56
+ },
57
+ true,
58
+ );
59
+ }
60
+
61
+ /** `readStewardIndex`, counting the discoveries it makes. */
62
+ function counted(dir: string) {
63
+ let n = 0;
64
+ const discover = async (d: string): Promise<StewardIndex> => {
65
+ n++;
66
+ return discoverStewardIndex(d);
67
+ };
68
+ return { read: () => readStewardIndex(dir, { discover }), discoveries: () => n };
69
+ }
70
+
71
+ describe("the steward index (#3636)", () => {
72
+ test("holds what status reads of the stewards and Ops", async () => {
73
+ const root = project();
74
+ const index = await discoverStewardIndex(root);
75
+ expect(index.errors).toEqual([]);
76
+ expect(index.conflicts).toEqual([]);
77
+ expect(index.stewards).toHaveLength(1);
78
+ const [s] = index.stewards;
79
+ expect(s).toMatchObject({
80
+ name: "box-steward",
81
+ beside: [{ op: "build", ready: true }],
82
+ form: { default: "local", environments: { k3d: "fountain" } },
83
+ capabilities: ["inference"],
84
+ vault: null,
85
+ });
86
+ expect(s.filePath.endsWith(join("ops", "steward.op.ts"))).toBe(true);
87
+ expect(s.ops).toEqual([
88
+ { name: "tick", schedule: { cron: "0 * * * *" }, labels: { Env: "box" }, workLease: null, changesCheckout: false },
89
+ { name: "build", schedule: null, workLease: { kind: "work/work.kind.mjs" }, changesCheckout: true },
90
+ ]);
91
+ expect(index.otherOps).toEqual([{ name: "lonely", schedule: null, workLease: null, changesCheckout: false }]);
92
+ });
93
+
94
+ test("a second read of the same tree discovers nothing", async () => {
95
+ const root = project();
96
+ const c = counted(root);
97
+ const first = await c.read();
98
+ const second = await c.read();
99
+ expect(c.discoveries()).toBe(1);
100
+ expect(second).toEqual(JSON.parse(JSON.stringify(first)));
101
+ });
102
+
103
+ test("an edit, a new file or a commit discovers again", async () => {
104
+ const root = project();
105
+ const c = counted(root);
106
+ await c.read();
107
+ // A module already imported in this process isn't read again, so only the count is checked.
108
+ writeFileSync(join(root, "ops", "steward.op.ts"), "export const nothing = 1;\n");
109
+ await c.read();
110
+ expect(c.discoveries()).toBe(2);
111
+ await c.read();
112
+ expect(c.discoveries()).toBe(2);
113
+ mkdirSync(join(root, "lib"));
114
+ writeFileSync(join(root, "lib", "helper.ts"), "export const x = 1;\n");
115
+ await c.read();
116
+ expect(c.discoveries()).toBe(3);
117
+ commitAll(root);
118
+ await c.read();
119
+ expect(c.discoveries()).toBe(4);
120
+ });
121
+
122
+ test("an Op file that fails to import is not indexed, so the next read tries again", async () => {
123
+ const root = project();
124
+ writeFileSync(join(root, "ops", "broken.op.ts"), "throw new Error('not today');\n");
125
+ const c = counted(root);
126
+ expect((await c.read()).errors).toHaveLength(1);
127
+ expect((await c.read()).errors).toHaveLength(1);
128
+ expect(c.discoveries()).toBe(2);
129
+ });
130
+
131
+ test("CHANT_STEWARD_INDEX=0 discovers every time", async () => {
132
+ const root = project();
133
+ process.env.CHANT_STEWARD_INDEX = "0";
134
+ const c = counted(root);
135
+ await c.read();
136
+ await c.read();
137
+ expect(c.discoveries()).toBe(2);
138
+ expect(existsSync(join(cache, "workspace-stewards"))).toBe(false);
139
+ });
140
+
141
+ test("the index lives in the cache dir, never in the checkout", async () => {
142
+ const root = project();
143
+ const status = git(root, "status", "--porcelain", "--ignored");
144
+ await readStewardIndex(root);
145
+ expect(git(root, "status", "--porcelain", "--ignored")).toBe(status);
146
+ const [dir] = readdirSync(join(cache, "workspace-stewards"));
147
+ expect(readdirSync(join(cache, "workspace-stewards", dir)).filter((f) => f.endsWith(".json"))).toHaveLength(1);
148
+ });
149
+
150
+ test("the indexes of a checkout that is gone go on the next write", async () => {
151
+ const gone = project();
152
+ await readStewardIndex(gone);
153
+ rmSync(gone, { recursive: true, force: true });
154
+ await readStewardIndex(project());
155
+ expect(readdirSync(join(cache, "workspace-stewards"))).toHaveLength(1);
156
+ });
157
+
158
+ test("status lists the same stewards from the index as from discovery", async () => {
159
+ const root = project();
160
+ const now = "2027-01-01T00:00:00Z";
161
+ process.env.CHANT_STEWARD_INDEX = "0";
162
+ const discovered = await readMemberStewards(root, "k3d", now);
163
+ delete process.env.CHANT_STEWARD_INDEX;
164
+ await readMemberStewards(root, "k3d", now);
165
+ const indexed = await readMemberStewards(root, "k3d", now, "chant", null, {
166
+ readIndex: (d) => readStewardIndex(d, { discover: () => Promise.reject(new Error("should have been read from the index")) }),
167
+ });
168
+ expect(indexed).toEqual(discovered);
169
+ expect(indexed.stewards[0]).toMatchObject({ name: "box-steward", form: "fountain", file: "ops/steward.op.ts" });
170
+ });
171
+ });