@intentius/chant 0.100.0 → 0.101.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 (276) hide show
  1. package/dist/build.d.ts +6 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/build-options.d.ts +2 -0
  4. package/dist/cli/build-options.d.ts.map +1 -1
  5. package/dist/cli/commands/build.d.ts.map +1 -1
  6. package/dist/cli/commands/import.d.ts.map +1 -1
  7. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  8. package/dist/cli/main.d.ts.map +1 -1
  9. package/dist/cli/mcp/workspace-tools.d.ts +8 -0
  10. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  11. package/dist/cli/registry.d.ts +22 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/config.d.ts +11 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/lexicon.d.ts +24 -1
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  18. package/dist/lifecycle/plan-digest.d.ts +26 -5
  19. package/dist/lifecycle/plan-digest.d.ts.map +1 -1
  20. package/dist/lint/config.d.ts +4 -4
  21. package/dist/op/activities/activity-contracts.d.ts +1 -0
  22. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  23. package/dist/op/activities/propose-upgrade.d.ts +2 -0
  24. package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
  25. package/dist/op/index.d.ts +1 -1
  26. package/dist/op/index.d.ts.map +1 -1
  27. package/dist/serializer.d.ts +8 -0
  28. package/dist/serializer.d.ts.map +1 -1
  29. package/dist/telemetry-attribution.d.ts +77 -0
  30. package/dist/telemetry-attribution.d.ts.map +1 -0
  31. package/dist/workspace/agent-cli.d.ts +83 -0
  32. package/dist/workspace/agent-cli.d.ts.map +1 -0
  33. package/dist/workspace/changes-cli.d.ts.map +1 -1
  34. package/dist/workspace/changes.d.ts +8 -1
  35. package/dist/workspace/changes.d.ts.map +1 -1
  36. package/dist/workspace/checks/links.d.ts +1 -0
  37. package/dist/workspace/checks/links.d.ts.map +1 -1
  38. package/dist/workspace/checks/live.d.ts +40 -0
  39. package/dist/workspace/checks/live.d.ts.map +1 -0
  40. package/dist/workspace/checks.d.ts +21 -2
  41. package/dist/workspace/checks.d.ts.map +1 -1
  42. package/dist/workspace/compose-graph.d.ts +63 -0
  43. package/dist/workspace/compose-graph.d.ts.map +1 -1
  44. package/dist/workspace/decide.d.ts +1 -1
  45. package/dist/workspace/decide.d.ts.map +1 -1
  46. package/dist/workspace/declaration.d.ts +32 -0
  47. package/dist/workspace/declaration.d.ts.map +1 -1
  48. package/dist/workspace/declaration.schema.json +138 -3
  49. package/dist/workspace/export-cli.d.ts +12 -0
  50. package/dist/workspace/export-cli.d.ts.map +1 -0
  51. package/dist/workspace/export.d.ts +145 -0
  52. package/dist/workspace/export.d.ts.map +1 -0
  53. package/dist/workspace/graph-cli.d.ts.map +1 -1
  54. package/dist/workspace/import.d.ts +73 -0
  55. package/dist/workspace/import.d.ts.map +1 -0
  56. package/dist/workspace/kinds.d.ts +6 -2
  57. package/dist/workspace/kinds.d.ts.map +1 -1
  58. package/dist/workspace/lineage-adopt-cli.d.ts +15 -0
  59. package/dist/workspace/lineage-adopt-cli.d.ts.map +1 -0
  60. package/dist/workspace/lineage-adopt.d.ts +106 -0
  61. package/dist/workspace/lineage-adopt.d.ts.map +1 -0
  62. package/dist/workspace/lineage-check.d.ts +9 -2
  63. package/dist/workspace/lineage-check.d.ts.map +1 -1
  64. package/dist/workspace/lineage-cli.d.ts +6 -1
  65. package/dist/workspace/lineage-cli.d.ts.map +1 -1
  66. package/dist/workspace/lineage-hash-index.d.ts +110 -0
  67. package/dist/workspace/lineage-hash-index.d.ts.map +1 -0
  68. package/dist/workspace/lineage-init.d.ts +10 -0
  69. package/dist/workspace/lineage-init.d.ts.map +1 -1
  70. package/dist/workspace/lineage-lock.d.ts +147 -0
  71. package/dist/workspace/lineage-lock.d.ts.map +1 -1
  72. package/dist/workspace/lineage-migrations.d.ts +15 -3
  73. package/dist/workspace/lineage-migrations.d.ts.map +1 -1
  74. package/dist/workspace/lineage-provenance.d.ts +18 -0
  75. package/dist/workspace/lineage-provenance.d.ts.map +1 -0
  76. package/dist/workspace/lineage-upgrade-cli.d.ts +2 -0
  77. package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
  78. package/dist/workspace/lineage-upgrade.d.ts +30 -1
  79. package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
  80. package/dist/workspace/lineage-versions.d.ts +116 -0
  81. package/dist/workspace/lineage-versions.d.ts.map +1 -0
  82. package/dist/workspace/links.d.ts +31 -5
  83. package/dist/workspace/links.d.ts.map +1 -1
  84. package/dist/workspace/ls-generated.d.ts +37 -0
  85. package/dist/workspace/ls-generated.d.ts.map +1 -0
  86. package/dist/workspace/ls.d.ts +4 -0
  87. package/dist/workspace/ls.d.ts.map +1 -1
  88. package/dist/workspace/member-commands.d.ts.map +1 -1
  89. package/dist/workspace/nested-graph.d.ts +56 -0
  90. package/dist/workspace/nested-graph.d.ts.map +1 -0
  91. package/dist/workspace/nesting.d.ts +21 -0
  92. package/dist/workspace/nesting.d.ts.map +1 -0
  93. package/dist/workspace/pin-cli.d.ts +10 -0
  94. package/dist/workspace/pin-cli.d.ts.map +1 -0
  95. package/dist/workspace/pin-integrity.d.ts +51 -0
  96. package/dist/workspace/pin-integrity.d.ts.map +1 -0
  97. package/dist/workspace/reason-codes.d.ts +26 -2
  98. package/dist/workspace/reason-codes.d.ts.map +1 -1
  99. package/dist/workspace/record-sessions.d.ts +7 -11
  100. package/dist/workspace/record-sessions.d.ts.map +1 -1
  101. package/dist/workspace/records-cli.d.ts +30 -1
  102. package/dist/workspace/records-cli.d.ts.map +1 -1
  103. package/dist/workspace/records-close.d.ts +5 -2
  104. package/dist/workspace/records-close.d.ts.map +1 -1
  105. package/dist/workspace/records-write.d.ts +22 -4
  106. package/dist/workspace/records-write.d.ts.map +1 -1
  107. package/dist/workspace/records.d.ts +43 -5
  108. package/dist/workspace/records.d.ts.map +1 -1
  109. package/dist/workspace/returns.d.ts +129 -0
  110. package/dist/workspace/returns.d.ts.map +1 -0
  111. package/dist/workspace/status-gates.d.ts.map +1 -1
  112. package/dist/workspace/template-manifest.d.ts +11 -3
  113. package/dist/workspace/template-manifest.d.ts.map +1 -1
  114. package/dist/workspace/trust/attestor.d.ts +8 -0
  115. package/dist/workspace/trust/attestor.d.ts.map +1 -1
  116. package/dist/workspace/trust/dsse.d.ts +58 -0
  117. package/dist/workspace/trust/dsse.d.ts.map +1 -0
  118. package/dist/workspace/trust/evidence-cli.d.ts +66 -0
  119. package/dist/workspace/trust/evidence-cli.d.ts.map +1 -0
  120. package/dist/workspace/trust/evidence.d.ts +93 -0
  121. package/dist/workspace/trust/evidence.d.ts.map +1 -0
  122. package/dist/workspace/trust/policy.d.ts +54 -1
  123. package/dist/workspace/trust/policy.d.ts.map +1 -1
  124. package/dist/workspace/trust/provenance.d.ts +21 -1
  125. package/dist/workspace/trust/provenance.d.ts.map +1 -1
  126. package/dist/workspace/trust/rotation.d.ts +132 -0
  127. package/dist/workspace/trust/rotation.d.ts.map +1 -0
  128. package/dist/workspace/trust/seal.d.ts.map +1 -1
  129. package/dist/workspace/trust/signers-cli.d.ts +49 -0
  130. package/dist/workspace/trust/signers-cli.d.ts.map +1 -0
  131. package/dist/workspace/trust/ssh-commit.d.ts +12 -0
  132. package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
  133. package/dist/workspace/trust/test-repo.d.ts +13 -0
  134. package/dist/workspace/trust/test-repo.d.ts.map +1 -1
  135. package/dist/workspace/trust/verify.d.ts +15 -0
  136. package/dist/workspace/trust/verify.d.ts.map +1 -1
  137. package/dist/workspace/work-evidence.d.ts +1 -1
  138. package/dist/workspace/work-evidence.d.ts.map +1 -1
  139. package/dist/workspace/write-scope.d.ts +199 -0
  140. package/dist/workspace/write-scope.d.ts.map +1 -0
  141. package/package.json +1 -1
  142. package/src/build.ts +8 -0
  143. package/src/cli/build-options.ts +4 -1
  144. package/src/cli/commands/build.ts +2 -0
  145. package/src/cli/commands/import-live.test.ts +69 -1
  146. package/src/cli/commands/import.ts +48 -22
  147. package/src/cli/handlers/fan-out.test.ts +6 -6
  148. package/src/cli/handlers/fan-out.ts +2 -1
  149. package/src/cli/handlers/graph.test.ts +42 -0
  150. package/src/cli/handlers/graph.ts +22 -0
  151. package/src/cli/handlers/operator.ts +1 -1
  152. package/src/cli/main.test.ts +32 -0
  153. package/src/cli/main.ts +103 -6
  154. package/src/cli/mcp/workspace-tools.test.ts +1 -1
  155. package/src/cli/mcp/workspace-tools.ts +33 -2
  156. package/src/cli/registry.ts +22 -0
  157. package/src/cli/serve-mcp-workspace.test.ts +1 -1
  158. package/src/codegen/release-wiring.test.ts +5 -1
  159. package/src/components/fan-out-output.test.ts +1 -1
  160. package/src/components/fan-out.test.ts +1 -1
  161. package/src/components/promote.test.ts +1 -1
  162. package/src/config.ts +12 -0
  163. package/src/content-digest.test.ts +2 -2
  164. package/src/lexicon.ts +25 -1
  165. package/src/lifecycle/gate-ledger.test.ts +14 -0
  166. package/src/lifecycle/gate-ledger.ts +2 -1
  167. package/src/lifecycle/plan-digest.test.ts +54 -3
  168. package/src/lifecycle/plan-digest.ts +38 -8
  169. package/src/op/activities/activity-contracts.ts +1 -0
  170. package/src/op/activities/propose-upgrade.ts +8 -5
  171. package/src/op/gate-approval.test.ts +17 -0
  172. package/src/op/gate.ts +3 -3
  173. package/src/op/index.ts +1 -1
  174. package/src/serializer.ts +9 -0
  175. package/src/telemetry-attribution.test.ts +91 -0
  176. package/src/telemetry-attribution.ts +145 -0
  177. package/src/workspace/agent-cli.ts +134 -0
  178. package/src/workspace/agent.schema.json +356 -0
  179. package/src/workspace/behold-kinds.test.ts +1 -1
  180. package/src/workspace/changes-cli.ts +5 -0
  181. package/src/workspace/changes.schema.json +179 -1
  182. package/src/workspace/changes.ts +58 -4
  183. package/src/workspace/check-live.test.ts +192 -0
  184. package/src/workspace/check.schema.json +64 -0
  185. package/src/workspace/checks/links.ts +23 -2
  186. package/src/workspace/checks/live.ts +113 -0
  187. package/src/workspace/checks.test.ts +2 -2
  188. package/src/workspace/checks.ts +20 -3
  189. package/src/workspace/compose-graph.test.ts +47 -0
  190. package/src/workspace/compose-graph.ts +120 -3
  191. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +7 -1
  192. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +5 -0
  193. package/src/workspace/declaration.schema.json +138 -3
  194. package/src/workspace/declaration.ts +106 -1
  195. package/src/workspace/declared-kinds.test.ts +33 -0
  196. package/src/workspace/evidence.schema.json +279 -0
  197. package/src/workspace/export-cli.ts +180 -0
  198. package/src/workspace/export-import.test.ts +282 -0
  199. package/src/workspace/export.ts +486 -0
  200. package/src/workspace/graph-cli.ts +47 -1
  201. package/src/workspace/graph-contract.test.ts +89 -4
  202. package/src/workspace/graph.schema.json +206 -1
  203. package/src/workspace/import.ts +325 -0
  204. package/src/workspace/kinds.test.ts +5 -5
  205. package/src/workspace/kinds.ts +25 -3
  206. package/src/workspace/lineage-adopt-cli.ts +103 -0
  207. package/src/workspace/lineage-adopt.test.ts +552 -0
  208. package/src/workspace/lineage-adopt.ts +452 -0
  209. package/src/workspace/lineage-check.ts +26 -5
  210. package/src/workspace/lineage-cli.ts +12 -2
  211. package/src/workspace/lineage-hash-index.ts +305 -0
  212. package/src/workspace/lineage-init.test.ts +9 -0
  213. package/src/workspace/lineage-init.ts +29 -9
  214. package/src/workspace/lineage-lock.ts +54 -0
  215. package/src/workspace/lineage-migrations.test.ts +27 -0
  216. package/src/workspace/lineage-migrations.ts +44 -14
  217. package/src/workspace/lineage-provenance.ts +40 -0
  218. package/src/workspace/lineage-upgrade-cli.ts +9 -5
  219. package/src/workspace/lineage-upgrade.test.ts +92 -1
  220. package/src/workspace/lineage-upgrade.ts +111 -16
  221. package/src/workspace/lineage-versions.ts +348 -0
  222. package/src/workspace/links.test.ts +121 -2
  223. package/src/workspace/links.ts +97 -6
  224. package/src/workspace/ls-contract.test.ts +84 -1
  225. package/src/workspace/ls-generated.ts +111 -0
  226. package/src/workspace/ls.schema.json +19 -0
  227. package/src/workspace/ls.ts +8 -1
  228. package/src/workspace/member-commands.ts +3 -1
  229. package/src/workspace/nested-graph.test.ts +176 -0
  230. package/src/workspace/nested-graph.ts +169 -0
  231. package/src/workspace/nesting.ts +37 -0
  232. package/src/workspace/pin-cli.test.ts +71 -0
  233. package/src/workspace/pin-cli.ts +57 -0
  234. package/src/workspace/pin-integrity.test.ts +121 -0
  235. package/src/workspace/pin-integrity.ts +104 -0
  236. package/src/workspace/points-write.schema.json +1 -0
  237. package/src/workspace/read-contract.test.ts +24 -0
  238. package/src/workspace/reason-codes.test.ts +10 -0
  239. package/src/workspace/reason-codes.ts +29 -2
  240. package/src/workspace/record-sessions.ts +12 -14
  241. package/src/workspace/records-amend.schema.json +4 -0
  242. package/src/workspace/records-cli.ts +93 -12
  243. package/src/workspace/records-close.schema.json +6 -2
  244. package/src/workspace/records-close.ts +10 -2
  245. package/src/workspace/records-formats.test.ts +13 -5
  246. package/src/workspace/records-new.schema.json +4 -0
  247. package/src/workspace/records-review.schema.json +4 -0
  248. package/src/workspace/records-sessions.test.ts +23 -6
  249. package/src/workspace/records-write.test.ts +65 -8
  250. package/src/workspace/records-write.ts +70 -6
  251. package/src/workspace/records.schema.json +55 -3
  252. package/src/workspace/records.ts +96 -5
  253. package/src/workspace/returns.ts +328 -0
  254. package/src/workspace/signers.schema.json +206 -0
  255. package/src/workspace/status-gates.ts +2 -1
  256. package/src/workspace/template-manifest.ts +22 -4
  257. package/src/workspace/trust/attestor.ts +15 -0
  258. package/src/workspace/trust/dsse.ts +134 -0
  259. package/src/workspace/trust/evidence-cli.ts +195 -0
  260. package/src/workspace/trust/evidence.test.ts +241 -0
  261. package/src/workspace/trust/evidence.ts +207 -0
  262. package/src/workspace/trust/policy.ts +110 -3
  263. package/src/workspace/trust/provenance.ts +41 -4
  264. package/src/workspace/trust/record-seal.test.ts +1 -1
  265. package/src/workspace/trust/rotation.test.ts +258 -0
  266. package/src/workspace/trust/rotation.ts +336 -0
  267. package/src/workspace/trust/seal.ts +11 -0
  268. package/src/workspace/trust/signers-cli.ts +178 -0
  269. package/src/workspace/trust/ssh-commit.ts +53 -4
  270. package/src/workspace/trust/test-repo.ts +18 -0
  271. package/src/workspace/trust/trust.test.ts +8 -2
  272. package/src/workspace/trust/verify-cli.ts +1 -0
  273. package/src/workspace/trust/verify.ts +22 -0
  274. package/src/workspace/work-evidence.schema.json +4 -0
  275. package/src/workspace/write-scope.test.ts +340 -0
  276. package/src/workspace/write-scope.ts +448 -0
@@ -20,7 +20,7 @@ import { WORKSPACE_UPGRADE_GATE_OP } from "../op/gate-name";
20
20
  import { LockError } from "./lineage-lock";
21
21
  import { applyStagedUpgrade, describeStaged, stageUpgrade, type ChantRunner, type StagedUpgrade } from "./lineage-upgrade";
22
22
 
23
- const USAGE = "chant workspace upgrade [<scope>] [--to <ref|dir>] [--allow-code] [--dry-run] [--output <patch file>] [--json]";
23
+ const USAGE = "chant workspace upgrade [<scope>] [--to <ref|dir>] [--source <repo>[#<member>]] [--allow-code] [--dry-run] [--output <patch file>] [--json]";
24
24
 
25
25
  /** The exit code of a gated upgrade: the same as `chant run`'s gated run. */
26
26
  export const UPGRADE_GATED_EXIT = 3;
@@ -29,6 +29,8 @@ export interface UpgradeCommandOptions {
29
29
  root: string;
30
30
  scope?: string;
31
31
  to?: string;
32
+ /** Move the scope to another template (#2551). */
33
+ source?: string;
32
34
  allowCode?: boolean;
33
35
  dryRun?: boolean;
34
36
  output?: string;
@@ -53,6 +55,7 @@ export async function upgradeCommand(opts: UpgradeCommandOptions): Promise<Upgra
53
55
  root: opts.root,
54
56
  scope: opts.scope,
55
57
  to: opts.to,
58
+ ...(opts.source !== undefined ? { source: opts.source } : {}),
56
59
  allowCode: opts.allowCode,
57
60
  runChant: opts.runChant,
58
61
  });
@@ -72,7 +75,7 @@ export async function upgradeCommand(opts: UpgradeCommandOptions): Promise<Upgra
72
75
  );
73
76
  }
74
77
  if (!staged.changed && staged.checksOk) {
75
- if (!opts.json) console.error(formatSuccess(`scope "${staged.scope}" is already at ${staged.to ?? "its source"}; nothing to upgrade`));
78
+ if (!opts.json) console.error(formatSuccess(`scope "${staged.gate}" is already at ${staged.to ?? "its source"}; nothing to upgrade`));
76
79
  return report("up-to-date", 0);
77
80
  }
78
81
  if (opts.output) writeFileSync(resolve(opts.output), staged.patch);
@@ -90,11 +93,11 @@ export async function upgradeCommand(opts: UpgradeCommandOptions): Promise<Upgra
90
93
  return report("dry-run", 0);
91
94
  }
92
95
 
93
- const gate = staged.scope;
96
+ const gate = staged.gate;
94
97
  const check = await evaluateGate(opts.ledger ?? gitGateLedgerPort({ cwd: staged.repo }), {
95
98
  op: WORKSPACE_UPGRADE_GATE_OP,
96
99
  gate,
97
- description: `upgrade ${staged.template} ${staged.from ?? ""} -> ${staged.to ?? ""} in scope ${staged.scope}`.replace(/\s+/g, " "),
100
+ description: `upgrade ${staged.template} ${staged.from ?? ""} -> ${staged.to ?? ""} in scope ${staged.member ?? staged.scope}`.replace(/\s+/g, " "),
98
101
  planDigest: staged.digest,
99
102
  ...(staged.governance ? { approval: staged.governance.approval } : {}),
100
103
  ...(opts.now ? { now: opts.now } : {}),
@@ -115,7 +118,7 @@ export async function upgradeCommand(opts: UpgradeCommandOptions): Promise<Upgra
115
118
 
116
119
  applyStagedUpgrade(staged);
117
120
  if (!opts.json) {
118
- console.error(formatSuccess(`applied the approved upgrade of "${staged.scope}" (${staged.digest}), approved by ${check.resolution.resolvedBy}. Review and commit it.`));
121
+ console.error(formatSuccess(`applied the approved upgrade of "${staged.gate}" (${staged.digest}), approved by ${check.resolution.resolvedBy}. Review and commit it.`));
119
122
  if (staged.manualSteps.length > 0) {
120
123
  console.error(formatInfo(`${staged.manualSteps.length} manual step(s) are open; \`chant workspace check\` fails until each is merged and resolved.`));
121
124
  }
@@ -137,6 +140,7 @@ export async function runWorkspaceUpgrade(ctx: CommandContext): Promise<number>
137
140
  root: process.cwd(),
138
141
  scope: args.extraPositional,
139
142
  to: args.migrateTo,
143
+ source: args.source,
140
144
  allowCode: args.allowCode,
141
145
  dryRun: args.dryRun,
142
146
  output: args.output,
@@ -110,7 +110,7 @@ describe("chant workspace upgrade", () => {
110
110
  expect(staged.written.sort()).toEqual(["README.md", "src/new.ts"]);
111
111
  expect(staged.manualSteps).toEqual([]);
112
112
  expect(staged.changedPaths).toEqual([LOCK_FILE, "README.md", "src/a.ts", "src/new.ts"]);
113
- expect(staged.digest).toMatch(/^sha256:[0-9a-f]{64}$/);
113
+ expect(staged.digest).toMatch(/^jcs1-sha256:[0-9a-f]{64}$/);
114
114
  // The project's tree is untouched until the gate is approved.
115
115
  expect(read(proj, "README.md")).toBe("starter\n");
116
116
  expect(existsSync(join(proj, "src/new.ts"))).toBe(false);
@@ -571,3 +571,94 @@ describe("chant workspace upgrade with template parameters (#2627)", () => {
571
571
  await expect(stageUpgrade({ root: proj, to: "v2.0.0", runChant: passing })).rejects.toThrow(/region has no default/);
572
572
  });
573
573
  });
574
+
575
+ describe("chant workspace upgrade of a member scope (#2550)", () => {
576
+ let ws: string;
577
+ const declaration = {
578
+ name: "ws",
579
+ schema: 1,
580
+ members: [
581
+ { name: "shop", dir: "shop", kind: "other", because: "made from a template" },
582
+ { name: "plain", dir: "plain", kind: "other", because: "written by hand" },
583
+ ],
584
+ };
585
+
586
+ beforeEach(async () => {
587
+ ws = join(root, "ws");
588
+ mkdirSync(ws);
589
+ git(ws, ["init", "-q", "-b", "main"]);
590
+ put(ws, "chant.workspace.json", JSON.stringify(declaration));
591
+ put(ws, "plain/readme.md", "by hand\n");
592
+ const made = await initFromCommand({ from: `${tpl}@v1.0.0`, path: join(ws, "shop") });
593
+ expect(made.error).toBeUndefined();
594
+ git(ws, ["add", "-A"]);
595
+ git(ws, ["commit", "-q", "-m", "workspace"]);
596
+ });
597
+
598
+ test("upgrades the member from the workspace root, writes only inside it, and gates under the member's name", async () => {
599
+ release("v2.0.0", { "README.md": "starter v2\n", "src/new.ts": "new\n" });
600
+ const l = ledger();
601
+ const result = await upgradeCommand({ root: ws, scope: "shop", to: "v2.0.0", runChant: passing, ledger: l, json: true });
602
+ expect(result.outcome).toBe("gated");
603
+ expect(result.exitCode).toBe(3);
604
+ expect(l.pending[0].gate).toBe("shop");
605
+ const staged = result.staged!;
606
+ expect(staged.member).toBe("shop");
607
+ expect(staged.scope).toBe(".");
608
+ expect(staged.changedPaths.every((p) => p.startsWith("shop/"))).toBe(true);
609
+
610
+ l.approve(staged.digest, new Date().toISOString());
611
+ const applied = await upgradeCommand({ root: ws, scope: "shop", to: "v2.0.0", runChant: passing, ledger: l, json: true });
612
+ expect(applied.outcome).toBe("applied");
613
+ expect(read(ws, "shop/README.md")).toBe("starter v2\n");
614
+ expect(read(ws, "plain/readme.md")).toBe("by hand\n");
615
+ expect(readLock(join(ws, "shop"))!.scopes["."].ref).toBe("v2.0.0");
616
+ });
617
+
618
+ test("a member is named by directory too, and a member with no lock is refused with the reason", async () => {
619
+ release("v2.0.0", { "README.md": "starter v2\n" });
620
+ const staged = await stageUpgrade({ root: ws, scope: "shop/", to: "v2.0.0", runChant: passing });
621
+ try {
622
+ expect(staged.member).toBe("shop");
623
+ } finally {
624
+ staged.dispose();
625
+ }
626
+ await expect(stageUpgrade({ root: ws, scope: "plain", runChant: passing })).rejects.toThrow(/member "plain" \(plain\) has no \.chant\/workspace\.lock\.json, so it was not made from a template/);
627
+ await expect(stageUpgrade({ root: ws, scope: "nobody", runChant: passing })).rejects.toThrow(/no .*workspace\.lock\.json/);
628
+ });
629
+
630
+ test("a scope the root lock holds is used as written, even when a member has the same directory", async () => {
631
+ // The root lock records the member's subtree as a scope: the member's own lock is not consulted.
632
+ const own = readLock(join(ws, "shop"))!;
633
+ // The recorded repository is relative to the lock's directory, so point it at the template as the root sees it.
634
+ const moved = { ...own.scopes["."], source: { ...own.scopes["."].source, url: tpl } } as (typeof own.scopes)["."];
635
+ writeLock(ws, { lockVersion: own.lockVersion, scopes: { shop: moved } });
636
+ git(ws, ["rm", "-rq", "--cached", "shop/.chant"]);
637
+ rmSync(join(ws, "shop/.chant"), { recursive: true });
638
+ commitProject2();
639
+ release("v2.0.0", { "README.md": "starter v2\n" });
640
+ const staged = await stageUpgrade({ root: ws, scope: "shop", to: "v2.0.0", runChant: passing });
641
+ try {
642
+ expect(staged.member).toBeUndefined();
643
+ expect(staged.scope).toBe("shop");
644
+ expect(staged.written).toEqual(["README.md"]);
645
+ expect(staged.changedPaths.sort()).toEqual([".chant/workspace.lock.json", "shop/README.md"]);
646
+ } finally {
647
+ staged.dispose();
648
+ }
649
+ });
650
+
651
+ test("a scope made by init --template is refused with its reason", async () => {
652
+ const lock = readLock(join(ws, "shop"))!;
653
+ lock.scopes["."] = { ...lock.scopes["."], kind: "template", source: { type: "lexicon", lexicon: "aws", template: "basic" } as never, template: "aws/basic" } as never;
654
+ writeLock(join(ws, "shop"), lock);
655
+ git(ws, ["add", "-A"]);
656
+ git(ws, ["commit", "-q", "-m", "lock"]);
657
+ await expect(stageUpgrade({ root: ws, scope: "shop", runChant: passing })).rejects.toThrow(/made by `chant init --template` \(aws\/basic\) is refused: .*no versioned source/);
658
+ });
659
+
660
+ function commitProject2(): void {
661
+ git(ws, ["add", "-A"]);
662
+ git(ws, ["commit", "-q", "-m", "root lock"]);
663
+ }
664
+ });
@@ -40,7 +40,7 @@ import { basename, dirname, join, relative, resolve, sep } from "node:path";
40
40
  import { computePlanDigest } from "../lifecycle/plan-digest";
41
41
  import type { ResolvedGateApproval } from "../op/gate-approval";
42
42
  import { checkLineage, findingKey, type CheckFinding } from "./lineage-check";
43
- import { dirLabel, git, parseDirSpec, readTemplateDir, readTemplateTree, recordedDirPath } from "./lineage-init";
43
+ import { dirLabel, git, parseDirSpec, parseTemplateSource, portableUrl, readTemplateDir, readTemplateTree, recordedDirPath } from "./lineage-init";
44
44
  import {
45
45
  LOCK_FILE,
46
46
  LockError,
@@ -60,6 +60,7 @@ import { reportPlan, runChantMigrations, type ChantMigrationReport } from "./cha
60
60
  import { readDeclaration, readerVersion, WorkspaceReadError } from "./declaration";
61
61
  import { assertCodeAllowed, planMigrations, runMigration, splitMigrations, type LoadedMigration } from "./lineage-migrations";
62
62
  import { applyUpstream, type UpdateResult } from "./lineage-update";
63
+ import { pathsInsideNested } from "./nesting";
63
64
  import { carryParameters, readManifest, substituteParameters } from "./template-manifest";
64
65
  import { repinSubstituted, type RepinnedRecord } from "./template-pins";
65
66
  import { workingTree } from "./tree";
@@ -113,7 +114,11 @@ export const spawnChant: ChantRunner = (command, cwd) =>
113
114
  export interface UpgradeOptions {
114
115
  /** The directory holding `.chant/workspace.lock.json`. */
115
116
  root: string;
116
- /** The scope to upgrade: `"."` or a vendor scope's directory. */
117
+ /**
118
+ * The scope to upgrade: `"."`, a scope of the lock at `root` (a vendor
119
+ * scope's directory, or a member subtree recorded in it), or the name or
120
+ * directory of a declared member that keeps a lock of its own (#2550).
121
+ */
117
122
  scope?: string;
118
123
  /**
119
124
  * The target ref (git) or label (vendor). Defaults to the ref the scope is
@@ -124,6 +129,12 @@ export interface UpgradeOptions {
124
129
  to?: string;
125
130
  /** Run migrations whose body is code. */
126
131
  allowCode?: boolean;
132
+ /**
133
+ * `<repo>[#<member>]`: move the scope to this template (#2551). The chain
134
+ * starts with the new template's bridge migration from the scope's current
135
+ * template. Needs `to`, and a git scope.
136
+ */
137
+ source?: string;
127
138
  /** Replaces the child-process build and lint. For tests. */
128
139
  runChant?: ChantRunner;
129
140
  }
@@ -138,8 +149,15 @@ export interface GovernanceChange {
138
149
  }
139
150
 
140
151
  export interface StagedUpgrade {
152
+ /** The scope in the lock that was upgraded: `"."` for a member with a lock of its own. */
141
153
  scope: string;
154
+ /** The declared member whose own lock this upgraded, when the scope argument named one. */
155
+ member?: string;
156
+ /** What the gate and the proposal branch are named: the member, else the scope. */
157
+ gate: string;
142
158
  template: string;
159
+ /** Set when the upgrade moves the scope to another template (`--source`, #2551): that template's id. */
160
+ switchedTo?: string;
143
161
  kind: Lineage["kind"];
144
162
  /** The pin before and after. */
145
163
  from: string | null;
@@ -226,11 +244,13 @@ function instantiate(
226
244
  * fail (a server that refuses fetches by commit); the base is then left empty,
227
245
  * and every edited file the template changed becomes a manual step.
228
246
  */
229
- function fetchGit(root: string, lineage: Lineage, ref: string): Upstream {
247
+ function fetchGit(root: string, lineage: Lineage, ref: string, onto?: Onto): Upstream {
230
248
  const source = lineage.source;
231
249
  if (source.type !== "git") throw new UpgradeError("not a git source");
232
- const url = source.url.startsWith(".") ? resolve(root, source.url) : source.url;
233
- const label = `${source.repo}@${ref}`;
250
+ const baseUrl = source.url.startsWith(".") ? resolve(root, source.url) : source.url;
251
+ // With --source (#2551) the target comes from the other template; the base always from the scope's own.
252
+ const url = onto ? onto.url : baseUrl;
253
+ const label = `${onto ? onto.repo : source.repo}@${ref}`;
234
254
  const scratch = mkdtempSync(join(tmpdir(), "chant-upgrade-"));
235
255
  try {
236
256
  git(scratch, ["init", "-q"]);
@@ -242,7 +262,7 @@ function fetchGit(root: string, lineage: Lineage, ref: string): Upstream {
242
262
  throw new UpgradeError(`could not fetch ${label}${stderr ? `: ${stderr}` : ""}`);
243
263
  }
244
264
  const commit = git(scratch, ["rev-parse", "FETCH_HEAD^{commit}"]);
245
- const target = readTemplateTree(scratch, commit, source.path, label);
265
+ const target = readTemplateTree(scratch, commit, onto ? onto.path : source.path, label);
246
266
 
247
267
  const raw = new Map<string, Buffer>();
248
268
  const executable = new Set<string>();
@@ -258,7 +278,7 @@ function fetchGit(root: string, lineage: Lineage, ref: string): Upstream {
258
278
  const baseCommit = lineage.address?.commit;
259
279
  if (baseCommit && baseCommit !== commit) {
260
280
  try {
261
- git(scratch, ["fetch", "-q", "--depth", "1", url, baseCommit]);
281
+ git(scratch, ["fetch", "-q", "--depth", "1", baseUrl, baseCommit]);
262
282
  const oldLabel = `${source.repo}@${baseCommit.slice(0, 12)}`;
263
283
  const old = readTemplateTree(scratch, baseCommit, source.path, oldLabel);
264
284
  // The base is rebuilt the way init wrote it: the old version's
@@ -297,7 +317,7 @@ function dirRefusal(lineage: Lineage, why: string): UpgradeError {
297
317
  const source = lineage.source as Extract<LineageSource, { type: "dir" }>;
298
318
  return new UpgradeError(
299
319
  `${why}. The scope was made from the directory ${dirLabel(source.path, source.member)}, which has no git history to rebuild a merge base from. ` +
300
- `Upgrade with --to <dir> while ${source.path} still holds the files the scope was made from, or adopt the scope into a git lineage with \`chant workspace adopt-lineage\` (#2551).`,
320
+ `Upgrade with --to <dir> while ${source.path} still holds the files the scope was made from, or move the scope onto the git repository the directory was copied from with \`chant workspace adopt-lineage --from <repo>[#<member>]\`.`,
301
321
  );
302
322
  }
303
323
 
@@ -359,11 +379,19 @@ function readDir(root: string, lineage: Lineage, to: string | undefined): Upstre
359
379
  };
360
380
  }
361
381
 
362
- async function fetchUpstream(root: string, lineage: Lineage, ref: string | undefined): Promise<Upstream> {
382
+ /** The template an upgrade moves the scope to (`--source`, #2551). */
383
+ interface Onto {
384
+ url: string;
385
+ repo: string;
386
+ path?: string;
387
+ id: string;
388
+ }
389
+
390
+ async function fetchUpstream(root: string, lineage: Lineage, ref: string | undefined, onto?: Onto): Promise<Upstream> {
363
391
  const source = lineage.source;
364
392
  if (source.type === "git") {
365
393
  if (!ref) throw new UpgradeError("the scope records no ref; pass --to <ref>");
366
- return fetchGit(root, lineage, ref);
394
+ return fetchGit(root, lineage, ref, onto);
367
395
  }
368
396
  if (source.type === "local" || source.type === "archive") {
369
397
  const { resolveVendorSource } = await import("../cli/commands/vendor");
@@ -372,7 +400,7 @@ async function fetchUpstream(root: string, lineage: Lineage, ref: string | undef
372
400
  }
373
401
  if (source.type === "dir") return readDir(root, lineage, ref);
374
402
  throw new UpgradeError(
375
- `scope made by \`chant init --template\` (${lineage.template}) cannot be upgraded yet: its merge base is the older lexicon's render, which is not available offline. Upgrade git-sourced and vendor scopes for now.`,
403
+ `scope made by \`chant init --template\` (${lineage.template}) is refused: it came from a lexicon's built-in template, which has no versioned source to fetch, and the merge base would be the render of the chant version that wrote it, which chant cannot rebuild offline. Scopes made by \`chant init --from\` (a git repository or a directory) and \`chant vendor\` scopes upgrade.`,
376
404
  );
377
405
  }
378
406
 
@@ -486,6 +514,40 @@ function chantMemberDirs(dir: string): string[] {
486
514
  }
487
515
  }
488
516
 
517
+ /**
518
+ * Resolve the scope argument against `root` (#2550). A scope the lock at
519
+ * `root` holds is used as it is. Otherwise a member the workspace
520
+ * declaration names (by name or directory) that keeps a lock of its own is
521
+ * upgraded from its directory, as the project it is, so its patch, gate and
522
+ * build stay inside it. A member without a lock was not made from a template.
523
+ */
524
+ export function resolveUpgradeTarget(root: string, scopeArg: string | undefined): { root: string; scope: string; member?: string } {
525
+ const scope = scopeKey(scopeArg ?? ".");
526
+ const lock = readLock(root);
527
+ if (lock?.scopes[scope]) return { root, scope };
528
+ if (scope !== ".") {
529
+ let declared: Array<{ name: string; dir: string; kind: string }> = [];
530
+ try {
531
+ declared = readDeclaration(workingTree(root)).members;
532
+ } catch (err) {
533
+ if (!(err instanceof WorkspaceReadError)) throw err;
534
+ }
535
+ const hit = declared.find((m) => m.dir !== "." && (m.name === scopeArg || scopeKey(m.dir) === scope));
536
+ if (hit?.kind === "workspace") {
537
+ // A nested workspace upgrades itself; the outer one never writes inside it (D2, #2551).
538
+ throw new UpgradeError(`member "${hit.name}" is a nested workspace, which upgrades itself: run chant workspace upgrade in ${hit.dir}`);
539
+ }
540
+ if (hit) {
541
+ const memberRoot = join(root, hit.dir);
542
+ if (readLock(memberRoot)) return { root: memberRoot, scope: ".", member: hit.name };
543
+ throw new UpgradeError(
544
+ `member "${hit.name}" (${hit.dir}) has no ${LOCK_FILE}, so it was not made from a template and has nothing to upgrade. \`chant init --from <template> ${hit.dir}\` writes one; \`chant workspace adopt-lineage\` is for an older member.`,
545
+ );
546
+ }
547
+ }
548
+ return { root, scope };
549
+ }
550
+
489
551
  /**
490
552
  * Stage an upgrade: steps 1 to 5 and the digest of step 6. Nothing in the
491
553
  * project's tree changes. The caller decides the gate, then applies the patch
@@ -493,8 +555,7 @@ function chantMemberDirs(dir: string): string[] {
493
555
  * and always calls `dispose()`.
494
556
  */
495
557
  export async function stageUpgrade(options: UpgradeOptions): Promise<StagedUpgrade> {
496
- const root = resolve(options.root);
497
- const scope = scopeKey(options.scope ?? ".");
558
+ const { root, scope, member } = resolveUpgradeTarget(resolve(options.root), options.scope);
498
559
  const lock = readLock(root);
499
560
  if (!lock) throw new UpgradeError(`no ${LOCK_FILE} in ${root}`);
500
561
  const lineage = lock.scopes[scope];
@@ -534,16 +595,29 @@ export async function stageUpgrade(options: UpgradeOptions): Promise<StagedUpgra
534
595
  );
535
596
  }
536
597
 
598
+ // A move to another template (#2551): a git scope only, and to a named ref.
599
+ let onto: Onto | undefined;
600
+ if (options.source !== undefined) {
601
+ const spec = parseTemplateSource(options.source, root, "--source");
602
+ if (spec.ref !== undefined) throw new UpgradeError(`--source ${options.source}: name the version with --to, not "@${spec.ref}"`);
603
+ if (options.to === undefined) throw new UpgradeError("--source moves the scope to another template, so it needs --to <ref>: a version of that template");
604
+ if (lineage.source.type !== "git") {
605
+ throw new UpgradeError(`scope "${scope}" has a ${lineage.source.type} source; only a git scope moves to another template. A directory scope moves onto git with chant workspace adopt-lineage first.`);
606
+ }
607
+ onto = { url: spec.url, repo: spec.repo, ...(spec.member ? { path: spec.member } : {}), id: spec.id };
608
+ }
609
+ const switching = onto !== undefined && onto.id !== lineage.template;
610
+
537
611
  // 1. Fetch. A directory source has no ref: `--to` names the directory.
538
612
  const fromDir = lineage.source.type === "dir";
539
613
  const ref = fromDir ? undefined : options.to ?? lineage.ref;
540
- const upstream = await fetchUpstream(root, lineage, fromDir ? options.to : ref);
614
+ const upstream = await fetchUpstream(root, lineage, fromDir ? options.to : ref, onto);
541
615
 
542
616
  // 2. The merge base, offline from here on.
543
617
  const base = rebuildBase(join(root, scope), lineage, upstream.base);
544
618
 
545
619
  // Plan the chain before anything is staged, so a gap refuses early.
546
- const plan = lineage.kind === "template" ? planMigrations(lineage, ref, upstream.migrations) : { chain: [] };
620
+ const plan = lineage.kind === "template" ? planMigrations(lineage, ref, upstream.migrations, onto?.id) : { chain: [] };
547
621
  assertCodeAllowed(plan.chain, !!options.allowCode);
548
622
 
549
623
  // 3. The worktree, inside the project so the project's packages resolve.
@@ -613,6 +687,10 @@ export async function stageUpgrade(options: UpgradeOptions): Promise<StagedUpgra
613
687
  }
614
688
  if (upstream.commit) staged.address = { ...staged.address!, commit: upstream.commit, tree: upstream.tree };
615
689
  if (upstream.source) staged.source = upstream.source;
690
+ if (onto) {
691
+ staged.template = onto.id;
692
+ staged.source = { type: "git", repo: onto.repo, url: portableUrl(onto.url, root), ...(onto.path ? { path: onto.path } : {}) };
693
+ }
616
694
  writeLock(worktreeProject, next);
617
695
  const lockText = renderLock(next);
618
696
 
@@ -621,10 +699,23 @@ export async function stageUpgrade(options: UpgradeOptions): Promise<StagedUpgra
621
699
  if (!lockTracked) gitOut(worktree, ["rm", "--cached", "-q", "--ignore-unmatch", "--", lockRepoPath]);
622
700
  const patch = gitOut(worktree, ["diff", "--cached", "--binary", "--full-index", "--no-color", "--no-ext-diff", head]);
623
701
  const changedPaths = gitOut(worktree, ["diff", "--cached", "--name-only", "-z", head]).split("\0").filter(Boolean).sort();
702
+ // An upgrade writes inside its scope and the lock that records it, and nowhere else.
703
+ const outside = changedPaths.filter((p) => scopeRepoPath !== "." && p !== lockRepoPath && p !== scopeRepoPath && !p.startsWith(`${scopeRepoPath}/`));
704
+ if (outside.length > 0) {
705
+ throw new UpgradeError(`the upgrade would change ${outside.slice(0, 5).join(", ")}${outside.length > 5 ? ", ..." : ""}, outside the scope "${scopeRepoPath}". An upgrade writes only inside its own scope.`);
706
+ }
624
707
  const lockChanged = !lockTracked && lockText !== readFileSync(join(root, LOCK_FILE), "utf-8");
625
708
  const changed = patch.length > 0 || lockChanged;
626
709
  const digest = computePlanDigest(UPGRADE_PLAN_KIND, lockTracked ? { scope, patch } : { scope, patch, lock: lockText });
627
710
 
711
+ // The outer workspace never writes inside a nested one (D2, #2551): that one upgrades itself.
712
+ const inNested = pathsInsideNested(worktreeProject, projectRel, changedPaths);
713
+ if (inNested.length > 0) {
714
+ throw new UpgradeError(
715
+ `the upgrade would change files inside the nested workspace ${inNested[0].member} (${inNested.slice(0, 5).map((p) => p.path).join(", ")}). A nested workspace upgrades itself with its own chant workspace upgrade, and the outer one never writes inside it.`,
716
+ );
717
+ }
718
+
628
719
  // Governance: decided by the rules at HEAD, never by the upgraded files.
629
720
  const governed = changedPaths.filter(isGovernancePath);
630
721
  let governance: GovernanceChange | null = null;
@@ -682,7 +773,10 @@ export async function stageUpgrade(options: UpgradeOptions): Promise<StagedUpgra
682
773
 
683
774
  return {
684
775
  scope,
776
+ ...(member !== undefined ? { member } : {}),
777
+ gate: member ?? scope,
685
778
  template: lineage.template,
779
+ ...(switching ? { switchedTo: onto!.id } : {}),
686
780
  kind: lineage.kind,
687
781
  from: fromDir ? sourcePin(lineage.source) : lineage.ref ?? null,
688
782
  to: fromDir ? sourcePin(staged.source) : staged.ref ?? null,
@@ -781,7 +875,8 @@ export function commitStagedUpgrade(staged: StagedUpgrade, branch: string, messa
781
875
  export function describeStaged(staged: StagedUpgrade): string[] {
782
876
  const lines: string[] = [];
783
877
  const at = (p: string) => (staged.scope === "." ? p : `${staged.scope}/${p}`);
784
- lines.push(`${staged.scope} ${staged.template} ${staged.from ?? "(no ref)"} -> ${staged.to ?? "(no ref)"}`);
878
+ lines.push(`${staged.gate} ${staged.template} ${staged.from ?? "(no ref)"} -> ${staged.to ?? "(no ref)"}`);
879
+ if (staged.switchedTo) lines.push(` template: ${staged.template} -> ${staged.switchedTo}`);
785
880
  const chantIds = new Set(staged.chantMigrations.map((m) => m.id));
786
881
  for (const id of staged.migrations) if (!chantIds.has(id)) lines.push(` migration: ${id}`);
787
882
  for (const m of staged.chantMigrations) {