@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
@@ -0,0 +1,305 @@
1
+ /**
2
+ * The hash index that `chant workspace adopt-lineage` matches a scope against
3
+ * (#2551, D9, ws-006).
4
+ *
5
+ * For each tagged version of a template, the index lists every file the
6
+ * template has at that version with the SHA-256 of its content. It is computed
7
+ * on demand from the template's tags: no registry holds it, and a template
8
+ * needs no extra publishing to be adoptable. A template's CI may publish a copy
9
+ * (`chant workspace hash-index`) and an adopter may pass it with `--index`.
10
+ * That copy is a cache only. chant reuses an entry only while the tag still
11
+ * names the commit the entry records, and it recomputes the entry it adopts
12
+ * from the template itself before anything reaches the lock.
13
+ *
14
+ * ws-006 also names parameter masking: rendering each version with and without
15
+ * its parameters, so a file that differs only by a parameter value still
16
+ * matches. The index hashes the files as the tag holds them, with the
17
+ * template's `chant.template.json` left out, since a project never receives
18
+ * it (#2627). A file a parameter changed reads as edited when matched against
19
+ * a scope's files; adopt-lineage records it with its substituted hash once
20
+ * the version is chosen. Moving a directory lineage onto git renders every
21
+ * candidate with the recorded parameters instead (./lineage-adopt.ts).
22
+ *
23
+ * The network steps are `git ls-remote --tags` and one `git fetch` of the tags
24
+ * to compute, both catalogued in `test/egress-catalogue.ts`. A local
25
+ * repository reaches nothing.
26
+ */
27
+
28
+ import { execFileSync } from "node:child_process";
29
+ import { createHash } from "node:crypto";
30
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
31
+ import { tmpdir } from "node:os";
32
+ import { join } from "node:path";
33
+ import { z } from "zod";
34
+ import { git } from "./lineage-init";
35
+ import { LOCK_FILE, LockError } from "./lineage-lock";
36
+ import { MIGRATIONS_DIR } from "./lineage-migrations";
37
+ import { compareVersions, parseVersion } from "./lineage-version";
38
+ import { TEMPLATE_MANIFEST } from "./template-manifest";
39
+
40
+ /** The index format this chant reads and writes. */
41
+ export const HASH_INDEX_VERSION = 1;
42
+
43
+ const Sha256 = z.string().regex(/^sha256:[0-9a-f]{64}$/);
44
+ const ObjectId = z.string().regex(/^[0-9a-f]{40,64}$/);
45
+
46
+ const EntrySchema = z
47
+ .object({
48
+ tag: z.string().min(1),
49
+ commit: ObjectId,
50
+ /** The tree of the template's directory at the tag. */
51
+ tree: ObjectId,
52
+ /** Per file, relative to the template's directory: `sha256:<hex>` of its content. */
53
+ files: z.record(z.string(), Sha256),
54
+ })
55
+ .strict();
56
+ export type HashIndexEntry = z.infer<typeof EntrySchema>;
57
+
58
+ const IndexSchema = z
59
+ .object({
60
+ indexVersion: z.literal(HASH_INDEX_VERSION),
61
+ /** The template id, as the lock writes it: `github.com/acme/starter#service`. */
62
+ template: z.string().min(1),
63
+ /** Oldest version first. */
64
+ tags: z.array(EntrySchema),
65
+ })
66
+ .strict();
67
+ export type HashIndex = z.infer<typeof IndexSchema>;
68
+
69
+ /** Read and validate an index file, such as a copy a template's CI published. */
70
+ export function readHashIndex(path: string): HashIndex {
71
+ if (!existsSync(path)) throw new LockError(`no hash index at ${path}`);
72
+ let raw: unknown;
73
+ try {
74
+ raw = JSON.parse(readFileSync(path, "utf-8"));
75
+ } catch (err) {
76
+ throw new LockError(`${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
77
+ }
78
+ const parsed = IndexSchema.safeParse(raw);
79
+ if (!parsed.success) {
80
+ throw new LockError(`invalid hash index ${path}: ${parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
81
+ }
82
+ return parsed.data;
83
+ }
84
+
85
+ export function renderHashIndex(index: HashIndex): string {
86
+ return JSON.stringify(IndexSchema.parse(index), null, 2) + "\n";
87
+ }
88
+
89
+ /** Whether a template path is left out of the index: files a project never receives from the template. */
90
+ export function indexExcludes(path: string): boolean {
91
+ return path === LOCK_FILE || path === TEMPLATE_MANIFEST || path.startsWith(`${MIGRATIONS_DIR}/`);
92
+ }
93
+
94
+ /** A tag glob (`*` and `?`) as a regular expression over the whole tag name. */
95
+ function globRegExp(glob: string): RegExp {
96
+ const body = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
97
+ return new RegExp(`^${body}$`);
98
+ }
99
+
100
+ export interface RemoteTag {
101
+ tag: string;
102
+ /** The commit the tag names, peeled through an annotated tag. */
103
+ commit: string;
104
+ }
105
+
106
+ /**
107
+ * A scratch repository to read a template's tags in. Every method works on it;
108
+ * `dispose()` deletes it.
109
+ */
110
+ export class TemplateTags {
111
+ readonly scratch: string;
112
+ private readonly blobHashes = new Map<string, string>();
113
+ private readonly fetched = new Set<string>();
114
+
115
+ constructor(
116
+ readonly url: string,
117
+ readonly member: string | undefined,
118
+ readonly label: string,
119
+ ) {
120
+ this.scratch = mkdtempSync(join(tmpdir(), "chant-hash-index-"));
121
+ git(this.scratch, ["init", "-q"]);
122
+ }
123
+
124
+ dispose(): void {
125
+ rmSync(this.scratch, { recursive: true, force: true });
126
+ }
127
+
128
+ /**
129
+ * The template's version tags, oldest first: every tag whose name reads as
130
+ * a version, narrowed by `pattern` when given (`chant-v*`).
131
+ */
132
+ listTags(pattern?: string): RemoteTag[] {
133
+ let out: string;
134
+ try {
135
+ // A network step of adopt-lineage and hash-index, catalogued in test/egress-catalogue.ts.
136
+ out = git(this.scratch, ["ls-remote", "--tags", this.url]);
137
+ } catch (err) {
138
+ const stderr = (err as { stderr?: string }).stderr?.toString().trim();
139
+ throw new LockError(`could not list the tags of ${this.label}${stderr ? `: ${stderr}` : ""}`);
140
+ }
141
+ const byTag = new Map<string, string>();
142
+ const peeled = new Set<string>();
143
+ for (const line of out.split("\n")) {
144
+ const [sha, ref] = line.split("\t");
145
+ if (!sha || !ref?.startsWith("refs/tags/")) continue;
146
+ const name = ref.slice("refs/tags/".length);
147
+ if (name.endsWith("^{}")) {
148
+ const tag = name.slice(0, -3);
149
+ byTag.set(tag, sha);
150
+ peeled.add(tag);
151
+ } else if (!peeled.has(name)) {
152
+ byTag.set(name, sha);
153
+ }
154
+ }
155
+ const match = pattern ? globRegExp(pattern) : null;
156
+ return [...byTag.entries()]
157
+ .filter(([tag]) => parseVersion(tag) !== null && (!match || match.test(tag)))
158
+ .map(([tag, commit]) => ({ tag, commit }))
159
+ .sort((a, b) => compareVersions(parseVersion(a.tag)!, parseVersion(b.tag)!) || a.tag.localeCompare(b.tag));
160
+ }
161
+
162
+ /** Fetch the given tags, one commit deep each, in as few `git fetch` calls as the argument limit allows. */
163
+ fetchTags(tags: string[]): void {
164
+ const todo = tags.filter((t) => !this.fetched.has(t));
165
+ for (let i = 0; i < todo.length; i += 100) {
166
+ const batch = todo.slice(i, i + 100);
167
+ try {
168
+ // A network step of adopt-lineage and hash-index, catalogued in test/egress-catalogue.ts.
169
+ git(this.scratch, ["fetch", "-q", "--depth", "1", "--no-tags", this.url, ...batch.map((t) => `+refs/tags/${t}:refs/tags/${t}`)]);
170
+ } catch (err) {
171
+ const stderr = (err as { stderr?: string }).stderr?.toString().trim();
172
+ throw new LockError(`could not fetch the tags of ${this.label}${stderr ? `: ${stderr}` : ""}`);
173
+ }
174
+ for (const t of batch) this.fetched.add(t);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Fetch one ref that is not a version tag, such as a branch or a commit, one
180
+ * commit deep, and return the commit it names.
181
+ */
182
+ fetchRef(ref: string): RemoteTag {
183
+ try {
184
+ // A network step of adopt-lineage, catalogued in test/egress-catalogue.ts.
185
+ git(this.scratch, ["fetch", "-q", "--depth", "1", "--no-tags", this.url, ref]);
186
+ } catch (err) {
187
+ const stderr = (err as { stderr?: string }).stderr?.toString().trim();
188
+ throw new LockError(`could not fetch ${this.label}@${ref}${stderr ? `: ${stderr}` : ""}`);
189
+ }
190
+ return { tag: ref, commit: git(this.scratch, ["rev-parse", "FETCH_HEAD^{commit}"]) };
191
+ }
192
+
193
+ /**
194
+ * The index entry of one fetched tag: its tree and the SHA-256 of every file
195
+ * under the template's directory. Null when the directory does not exist at
196
+ * that tag: a template kept in one directory of a larger repository is
197
+ * absent from the tags cut before it was added.
198
+ */
199
+ entry(tag: RemoteTag): HashIndexEntry | null {
200
+ let tree: string;
201
+ try {
202
+ tree = git(this.scratch, ["rev-parse", this.member ? `${tag.commit}:${this.member}` : `${tag.commit}^{tree}`]);
203
+ if (git(this.scratch, ["cat-file", "-t", tree]) !== "tree") throw new Error("not a tree");
204
+ } catch {
205
+ return null;
206
+ }
207
+ const listing = execFileSync("git", ["ls-tree", "-r", "-z", tree], { cwd: this.scratch, maxBuffer: 256 * 1024 * 1024 }).toString("utf-8");
208
+ const blobs: Array<{ path: string; sha: string }> = [];
209
+ for (const row of listing.split("\0")) {
210
+ if (!row) continue;
211
+ const tab = row.indexOf("\t");
212
+ const [mode, type, sha] = row.slice(0, tab).split(" ");
213
+ const path = row.slice(tab + 1);
214
+ // The same files `chant init --from` copies: no symbolic links, submodules, lock or migrations.
215
+ if (type !== "blob" || mode === "120000" || indexExcludes(path)) continue;
216
+ blobs.push({ path, sha });
217
+ }
218
+ this.hashBlobs(blobs.map((b) => b.sha));
219
+ const files: Record<string, string> = {};
220
+ for (const b of blobs.sort((x, y) => (x.path < y.path ? -1 : x.path > y.path ? 1 : 0))) files[b.path] = this.blobHashes.get(b.sha)!;
221
+ return { tag: tag.tag, commit: tag.commit, tree, files };
222
+ }
223
+
224
+ /** SHA-256 each blob not seen yet, read in one `git cat-file --batch`. Blobs shared between tags are hashed once. */
225
+ private hashBlobs(ids: string[]): void {
226
+ const todo = [...new Set(ids)].filter((id) => !this.blobHashes.has(id));
227
+ if (todo.length === 0) return;
228
+ const out = execFileSync("git", ["cat-file", "--batch"], { cwd: this.scratch, input: todo.join("\n") + "\n", maxBuffer: 1024 * 1024 * 1024 });
229
+ let at = 0;
230
+ for (const id of todo) {
231
+ const eol = out.indexOf(0x0a, at);
232
+ const header = out.subarray(at, eol).toString("utf-8").split(" ");
233
+ if (header[0] !== id || header[1] !== "blob") throw new LockError(`${this.label}: could not read blob ${id}`);
234
+ const size = Number(header[2]);
235
+ const data = out.subarray(eol + 1, eol + 1 + size);
236
+ this.blobHashes.set(id, `sha256:${createHash("sha256").update(data).digest("hex")}`);
237
+ at = eol + 1 + size + 1;
238
+ }
239
+ }
240
+ }
241
+
242
+ export interface ComputedIndex {
243
+ index: HashIndex;
244
+ /** Tags whose entry came from the cache, unverified until one is adopted. */
245
+ cached: Set<string>;
246
+ }
247
+
248
+ /**
249
+ * The index of a template, from its tags. With `cache`, an entry is reused when
250
+ * its tag still names the recorded commit; the others are fetched and
251
+ * computed. `only` restricts the index to one ref the user named: a version
252
+ * tag, or any other ref git can fetch, such as a branch or a commit.
253
+ */
254
+ export function computeHashIndex(
255
+ tags: TemplateTags,
256
+ template: string,
257
+ options: { pattern?: string; cache?: HashIndex; only?: string } = {},
258
+ ): ComputedIndex {
259
+ let remote: RemoteTag[];
260
+ if (options.only !== undefined) {
261
+ // A named ref: a version tag when the template has one by that name, otherwise any ref git can fetch.
262
+ const only = options.only;
263
+ const tag = tags.listTags().find((t) => t.tag === only);
264
+ if (!tag) {
265
+ const ref = tags.fetchRef(only);
266
+ const entry = tags.entry(ref);
267
+ if (!entry) throw new LockError(`${tags.label}@${only} has no directory ${tags.member}`);
268
+ return { index: { indexVersion: HASH_INDEX_VERSION, template, tags: [entry] }, cached: new Set() };
269
+ }
270
+ remote = [tag];
271
+ } else {
272
+ remote = tags.listTags(options.pattern);
273
+ }
274
+ if (remote.length === 0) {
275
+ throw new LockError(
276
+ `${tags.label} has no tag that reads as a version${options.pattern ? ` and matches ${options.pattern}` : ""}; adopt-lineage matches against tagged releases`,
277
+ );
278
+ }
279
+ if (options.cache && options.cache.template !== template) {
280
+ throw new LockError(`the hash index is for ${options.cache.template}, not ${template}`);
281
+ }
282
+ const fromCache = new Map((options.cache?.tags ?? []).map((e) => [e.tag, e]));
283
+ const cached = new Set<string>();
284
+ const entries: HashIndexEntry[] = [];
285
+ const toCompute = remote.filter((t) => {
286
+ const hit = fromCache.get(t.tag);
287
+ return !hit || hit.commit !== t.commit;
288
+ });
289
+ tags.fetchTags(toCompute.map((t) => t.tag));
290
+ const computed = new Map(toCompute.map((t) => [t.tag, tags.entry(t)]));
291
+ for (const t of remote) {
292
+ if (computed.has(t.tag)) {
293
+ // A tag without the template's directory has no entry.
294
+ const own = computed.get(t.tag);
295
+ if (own) entries.push(own);
296
+ } else {
297
+ entries.push(fromCache.get(t.tag)!);
298
+ cached.add(t.tag);
299
+ }
300
+ }
301
+ if (entries.length === 0) {
302
+ throw new LockError(`${tags.label}: no version tag${options.only !== undefined ? ` ${options.only}` : ""} has the directory ${tags.member}`);
303
+ }
304
+ return { index: { indexVersion: HASH_INDEX_VERSION, template, tags: entries }, cached };
305
+ }
@@ -180,6 +180,15 @@ describe("chant init --from with parameters (#2627)", () => {
180
180
  expect(scope.files["chant.template.json"]).toBeUndefined();
181
181
  // The merge base is the file as written, with the value in it.
182
182
  expect(scope.files["src/main.ts"].sha256).toBe(fileHash(readFileSync(join(target, "src/main.ts"))));
183
+ // The host-bound parameter, with the listed files that carry it, for export and import (#2552).
184
+ expect(scope.hostBound).toEqual({ url: ["src/main.ts"] });
185
+ });
186
+
187
+ test("a template with no host-bound parameter records no hostBound (#2552)", async () => {
188
+ declare({ parameters: { name: MANIFEST.parameters.name }, files: ["package.json"] }, { "src/main.ts": "plain\n" });
189
+ const target = join(root, "proj");
190
+ expect((await initFromCommand({ from: `${tpl}@main#svc`, path: target })).error).toBeUndefined();
191
+ expect(readLock(target)!.scopes["."]).not.toHaveProperty("hostBound");
183
192
  });
184
193
 
185
194
  test("an undeclared --param is refused with the declared names listed, and nothing is written", async () => {
@@ -35,7 +35,7 @@ import {
35
35
  type Lineage,
36
36
  } from "./lineage-lock";
37
37
  import { MIGRATIONS_DIR } from "./lineage-migrations";
38
- import { TEMPLATE_MANIFEST, readManifest, resolveParameters, substituteParameters } from "./template-manifest";
38
+ import { TEMPLATE_MANIFEST, hostBoundFiles, readManifest, resolveParameters, substituteParameters } from "./template-manifest";
39
39
  import { repinSubstituted, type RepinnedRecord } from "./template-pins";
40
40
 
41
41
  // ── The template spec ────────────────────────────────────────────────────────
@@ -71,14 +71,31 @@ function splitMember(spec: string, where: string): { rest: string; member?: stri
71
71
  * `git@host:path`, a local path, or `owner/name` for a GitHub repository.
72
72
  */
73
73
  export function parseTemplateSpec(spec: string, cwd: string = process.cwd()): TemplateSpec {
74
- const { rest, member } = splitMember(spec, "the repository");
74
+ const parsed = parseTemplateSource(spec, cwd, "--from");
75
+ if (parsed.ref === undefined) {
76
+ throw new LockError(`--from ${spec}: expected <repo>@<ref>[#<member>], e.g. acme/starter@v1.2.0, or an existing directory`);
77
+ }
78
+ return { ...parsed, ref: parsed.ref };
79
+ }
80
+
81
+ /**
82
+ * Parse `<repo>[@<ref>][#<member>]`, where the ref may be left out: the form
83
+ * `chant workspace adopt-lineage --from`, `hash-index --from` and
84
+ * `upgrade --source` take (#2551). `flag` names the option in messages.
85
+ */
86
+ export function parseTemplateSource(spec: string, cwd: string = process.cwd(), flag = "--from"): Omit<TemplateSpec, "ref"> & { ref?: string } {
87
+ const { rest: whole, member } = splitMember(spec, "the repository");
88
+ let rest = whole;
89
+ let ref: string | undefined;
75
90
  const at = rest.lastIndexOf("@");
76
91
  const lastSep = Math.max(rest.lastIndexOf("/"), rest.lastIndexOf(":"));
77
- if (at <= 0 || at < lastSep || at === rest.length - 1) {
78
- throw new LockError(`--from ${spec}: expected <repo>@<ref>[#<member>], e.g. acme/starter@v1.2.0, or an existing directory`);
92
+ if (at > 0 && at > lastSep) {
93
+ if (at === rest.length - 1) throw new LockError(`${flag} ${spec}: the ref after "@" is empty; expected <repo>@<ref>[#<member>]`);
94
+ ref = rest.slice(at + 1);
95
+ rest = rest.slice(0, at);
79
96
  }
80
- const repo = rest.slice(0, at);
81
- const ref = rest.slice(at + 1);
97
+ const repo = rest;
98
+ if (!repo) throw new LockError(`${flag} ${spec}: no repository`);
82
99
 
83
100
  let url: string;
84
101
  let repoId: string;
@@ -99,9 +116,9 @@ export function parseTemplateSpec(spec: string, cwd: string = process.cwd()): Te
99
116
  url = `https://${repo}${repo.endsWith(".git") ? "" : ".git"}`;
100
117
  repoId = repo.replace(/\.git$/, "");
101
118
  } else {
102
- throw new LockError(`--from ${spec}: "${repo}" is not a URL, a local repository or owner/name`);
119
+ throw new LockError(`${flag} ${spec}: "${repo}" is not a URL, a local repository or owner/name`);
103
120
  }
104
- return { repo, url, ref, member, id: member ? `${repoId}#${member}` : repoId };
121
+ return { repo, url, ...(ref !== undefined ? { ref } : {}), member, id: member ? `${repoId}#${member}` : repoId };
105
122
  }
106
123
 
107
124
  // ── Fetch ────────────────────────────────────────────────────────────────────
@@ -415,10 +432,12 @@ export async function initFromCommand(options: InitFromOptions): Promise<InitFro
415
432
  let parameters: Record<string, string>;
416
433
  let contents: Map<string, Buffer>;
417
434
  let repinned: RepinnedRecord[];
435
+ let hostBound: Record<string, string[]> | undefined;
418
436
  try {
419
437
  const raw = new Map([...fetched.files].map(([path, f]) => [path, f.data]));
420
438
  const manifest = readManifest(raw);
421
439
  parameters = resolveParameters(manifest, options.params ?? {});
440
+ hostBound = hostBoundFiles(raw, manifest);
422
441
  // Records that pin a substituted file get its new hash, so a copy's pins hold (#2549).
423
442
  ({ files: contents, repinned } = repinSubstituted(raw, substituteParameters(raw, manifest, parameters), manifest?.files ?? []));
424
443
  } catch (err) {
@@ -445,6 +464,7 @@ export async function initFromCommand(options: InitFromOptions): Promise<InitFro
445
464
 
446
465
  const common = {
447
466
  parameters,
467
+ ...(hostBound ? { hostBound } : {}),
448
468
  ...(repinned.length > 0 ? { repinned: repinned.filter((r) => written.has(r.record)) } : {}),
449
469
  migrations: [],
450
470
  files: fileEntries(written, declaredFilesAt(targetDir)),
@@ -487,7 +507,7 @@ export async function initFromCommand(options: InitFromOptions): Promise<InitFro
487
507
  }
488
508
 
489
509
  /** A local repository is recorded relative to the project, so the lock does not name this machine's paths. */
490
- function portableUrl(url: string, targetDir: string): string {
510
+ export function portableUrl(url: string, targetDir: string): string {
491
511
  if (!isAbsolute(url)) return url;
492
512
  const rel = relative(targetDir, url).split(sep).join("/");
493
513
  return rel.startsWith(".") ? rel : `./${rel}`;
@@ -120,6 +120,49 @@ const ManualStepSchema = z
120
120
  .strict();
121
121
  export type ManualStep = z.infer<typeof ManualStepSchema>;
122
122
 
123
+ const CommitId = z.string().regex(/^[0-9a-f]{40,64}$/);
124
+
125
+ /**
126
+ * How a scope got its lineage from `chant workspace adopt-lineage` (#2551, D5,
127
+ * D9, requirement P7), rather than from `chant init`.
128
+ *
129
+ * - `by: "files"`: the scope had no lineage. Its files were matched against
130
+ * the template's versions, and the history before the lock is vouched for
131
+ * by an exact commit range, `commits.to` and every commit before it. The
132
+ * range is the one `.chant/trust.json` lists under `adopted`; once that
133
+ * entry is at the base revision, an admin has admitted it, and the scope
134
+ * reads as provenance `adopted` (D5). Until then it reads as `unattested`.
135
+ * - `by: "lineage"`: the scope had a directory lineage (#2647), and it was
136
+ * moved onto a git source whose files at the chosen ref reproduce every hash
137
+ * the lock recorded. The merge base is unchanged, so no history needs
138
+ * vouching for, and `previous` keeps the source it replaced.
139
+ */
140
+ const AdoptionSchema = z
141
+ .object({
142
+ by: z.enum(["files", "lineage"]),
143
+ /** HEAD when the scope was adopted: the range `.chant/trust.json` admits ends here. */
144
+ commits: z.object({ to: CommitId }).strict(),
145
+ /** How the chosen version held up against the scope. */
146
+ match: z
147
+ .object({
148
+ /** Files the template has at that version. */
149
+ files: z.number().int().nonnegative(),
150
+ /** Of those, the ones the scope holds (or the lock recorded) byte for byte. */
151
+ identical: z.number().int().nonnegative(),
152
+ /** The ones it holds with other content: edited since. */
153
+ edited: z.number().int().nonnegative(),
154
+ /** The ones it does not hold. */
155
+ missing: z.number().int().nonnegative(),
156
+ })
157
+ .strict(),
158
+ /** Where the hash index came from: computed from the template, or a cached copy chant re-checked at the chosen version. */
159
+ index: z.enum(["computed", "cache"]),
160
+ /** For `by: "lineage"`: the template and source the lineage had before. */
161
+ previous: z.object({ template: z.string().min(1), source: SourceSchema }).strict().optional(),
162
+ })
163
+ .strict();
164
+ export type Adoption = z.infer<typeof AdoptionSchema>;
165
+
123
166
  const LineageSchema = z
124
167
  .object({
125
168
  kind: z.enum(["template", "vendor"]),
@@ -133,6 +176,13 @@ const LineageSchema = z
133
176
  address: AddressSchema.nullable(),
134
177
  /** The parameter values the template was instantiated with. Always empty for vendor scopes. */
135
178
  parameters: z.record(z.string(), z.unknown()),
179
+ /**
180
+ * The parameters the template marks `hostBound` (#2524 D9, #2552), each
181
+ * with the files, relative to the scope, whose template text carries its
182
+ * placeholder. `chant workspace export` switches their values for the
183
+ * export, and `import` switches them back. Absent when the template has none.
184
+ */
185
+ hostBound: z.record(z.string(), z.array(z.string().min(1))).optional(),
136
186
  /**
137
187
  * Records whose evidence pins were re-pinned to the substituted content
138
188
  * of a parameterised file (#2549): each record's path in the scope, and the
@@ -144,6 +194,8 @@ const LineageSchema = z
144
194
  /** Per file, relative to the scope directory, in sorted order. */
145
195
  files: z.record(z.string(), LockFileEntrySchema),
146
196
  manualSteps: z.array(ManualStepSchema),
197
+ /** Present when the lineage was adopted rather than written at init (#2551). */
198
+ adoption: AdoptionSchema.optional(),
147
199
  })
148
200
  .strict();
149
201
  export type Lineage = z.infer<typeof LineageSchema>;
@@ -255,10 +307,12 @@ function canonical(lock: LineageLock): LineageLock {
255
307
  ...(s.ref !== undefined ? { ref: s.ref } : {}),
256
308
  address: s.address,
257
309
  parameters: s.parameters,
310
+ ...(s.hostBound !== undefined ? { hostBound: Object.fromEntries(Object.keys(s.hostBound).sort().map((k) => [k, [...s.hostBound![k]].sort()])) } : {}),
258
311
  ...(s.repinned !== undefined ? { repinned: s.repinned } : {}),
259
312
  migrations: s.migrations,
260
313
  files,
261
314
  manualSteps: [...s.manualSteps].sort((a, b) => a.path.localeCompare(b.path)),
315
+ ...(s.adoption !== undefined ? { adoption: s.adoption } : {}),
262
316
  };
263
317
  }
264
318
  return { lockVersion: lock.lockVersion, scopes };
@@ -85,6 +85,33 @@ describe("planMigrations", () => {
85
85
  // With nothing to run, no version is needed.
86
86
  expect(planMigrations(lineage("main"), "main", []).chain).toEqual([]);
87
87
  });
88
+
89
+ // #2551 — moving a scope to another template starts the chain with a bridge.
90
+ test("a switch starts with the bridge from the scope's template, then the new template's own migrations", () => {
91
+ const upstream = "github.com/acme/upstream";
92
+ const plan = planMigrations(lineage("v1.4.0"), "v3.0.0", [
93
+ migration("bridge-old", "<1.0.0", "1.0.0", "github.com/acme/starter"),
94
+ migration("bridge", ">=1.0.0 <2.0.0", "2.0.0", "github.com/acme/starter"),
95
+ migration("own-2", ">=1.0.0 <2.0.0", "2.0.0"),
96
+ migration("own-3", "2.x", "3.0.0"),
97
+ migration("other-bridge", "1.x", "2.0.0", "github.com/someone/else"),
98
+ ], upstream);
99
+ // own-2 lands where the bridge already did, so it does not run; the fork's version 1.4.0 means nothing to upstream.
100
+ expect(plan.chain.map((m) => m.migration.id)).toEqual(["bridge", "own-3"]);
101
+ // No downgrade check across templates: the fork's v5 can bridge to upstream's v3.
102
+ expect(planMigrations(lineage("v5.0.0"), "v3.0.0", [migration("b", ">=5.0.0", "3.0.0", "github.com/acme/starter")], upstream).chain.map((m) => m.migration.id)).toEqual(["b"]);
103
+ });
104
+
105
+ test("a switch without a bridge is refused when the new template has migrations, and allowed when it has none", () => {
106
+ const upstream = "github.com/acme/upstream";
107
+ expect(() => planMigrations(lineage("v1.0.0"), "v2.0.0", [migration("own", "1.x", "2.0.0")], upstream)).toThrow(/needs a bridge migration/);
108
+ expect(() => planMigrations(lineage("v1.0.0"), "v2.0.0", [migration("b", ">=3.0.0", "2.0.0", "github.com/acme/starter"), migration("own", "1.x", "2.0.0")], upstream)).toThrow(
109
+ /needs a bridge migration.*found b: >=3\.0\.0/,
110
+ );
111
+ expect(planMigrations(lineage("v1.0.0"), "v2.0.0", [], upstream).chain).toEqual([]);
112
+ // The same id is no switch.
113
+ expect(planMigrations(lineage("v1.0.0"), "v2.0.0", [migration("a", "1.x", "2.0.0")], "github.com/acme/starter").chain.map((m) => m.migration.id)).toEqual(["a"]);
114
+ });
88
115
  });
89
116
 
90
117
  describe("migration files", () => {
@@ -161,39 +161,69 @@ export interface MigrationPlan {
161
161
  /**
162
162
  * The migrations that take `lineage` from its version to `targetRef`, in order.
163
163
  *
164
+ * With `switchTo`, the upgrade also moves the scope to another template, the
165
+ * one with that id (#2551). The chain then starts with a bridge migration: one
166
+ * the new template ships whose `from.template` names the scope's current
167
+ * template and whose `from.versions` accepts the scope's version. Its `to` is a
168
+ * version of the new template, and the new template's own migrations carry on
169
+ * from there. This is how a fork-born scope, adopted against the fork, comes
170
+ * forward onto the template the fork came from.
171
+ *
164
172
  * Refused, with a {@link MigrationError}:
165
- * - a target below the scope's version;
173
+ * - a target below the scope's version (without a switch: versions of two
174
+ * templates do not compare);
166
175
  * - a pending migration when either end of the upgrade is not a version, so
167
176
  * no chain can be computed;
168
177
  * - a gap: a migration in range whose `from` does not accept the version the
169
- * scope has reached by then.
178
+ * scope has reached by then;
179
+ * - a switch with no bridge from the scope's version, when the new template
180
+ * has migrations of its own, since the scope's place in its history is
181
+ * then unknown.
170
182
  */
171
- export function planMigrations(lineage: Lineage, targetRef: string | undefined, available: LoadedMigration[]): MigrationPlan {
183
+ export function planMigrations(lineage: Lineage, targetRef: string | undefined, available: LoadedMigration[], switchTo?: string): MigrationPlan {
172
184
  const from = parseVersion(lineage.ref);
173
185
  const to = parseVersion(targetRef);
174
- if (from && to && compareVersions(to, from) < 0) {
186
+ const applied = new Set(lineage.migrations);
187
+ const switching = switchTo !== undefined && switchTo !== lineage.template;
188
+ if (!switching && from && to && compareVersions(to, from) < 0) {
175
189
  throw new MigrationError(`the target ${targetRef} (${formatVersion(to)}) is older than the scope's ${lineage.ref} (${formatVersion(from)}); an upgrade only goes forward`);
176
190
  }
177
- const applied = new Set(lineage.migrations);
178
- const mine = available.filter(
179
- ({ migration: m }) => (m.from.template === undefined || m.from.template === lineage.template) && !applied.has(m.id),
180
- );
181
- if (mine.length === 0) return { chain: [], from, to };
191
+ const target = switching ? switchTo : lineage.template;
192
+ const own = available.filter(({ migration: m }) => (m.from.template === undefined || m.from.template === target) && !applied.has(m.id));
193
+ const bridges = switching ? available.filter(({ migration: m }) => m.from.template === lineage.template && !applied.has(m.id)) : [];
194
+ if (own.length === 0 && bridges.length === 0) return { chain: [], from, to };
182
195
 
183
196
  if (!from || !to) {
197
+ const pending = [...bridges, ...own];
184
198
  const which = !from ? `the scope's ref ${lineage.ref ?? "(none)"}` : `the target ${targetRef ?? "(none)"}`;
185
199
  throw new MigrationError(
186
- `the template has ${mine.length} migration(s) not yet applied (${mine.map((m) => m.migration.id).join(", ")}), but ${which} is not a version, so no chain can be planned. Pin tagged versions, such as v1.2.0.`,
200
+ `the template has ${pending.length} migration(s) not yet applied (${pending.map((m) => m.migration.id).join(", ")}), but ${which} is not a version, so no chain can be planned. Pin tagged versions, such as v1.2.0.`,
187
201
  );
188
202
  }
189
203
 
190
- const inRange = mine
204
+ let at = from;
205
+ const chain: LoadedMigration[] = [];
206
+ if (switching) {
207
+ // The bridge: the one that accepts the scope's version and lands furthest without passing the target.
208
+ const fits = bridges
209
+ .map((m) => ({ ...m, target: parseVersion(m.migration.to)! }))
210
+ .filter((m) => satisfies(from, m.migration.from.versions) && compareVersions(m.target, to) <= 0)
211
+ .sort((a, b) => compareVersions(b.target, a.target) || a.migration.id.localeCompare(b.migration.id));
212
+ if (fits.length === 0) {
213
+ if (own.length === 0) return { chain: [], from, to };
214
+ throw new MigrationError(
215
+ `moving scope from ${lineage.template} to ${switchTo} needs a bridge migration: one in ${switchTo} with from.template "${lineage.template}" whose from.versions accepts ${formatVersion(from)}${bridges.length > 0 ? ` (found ${bridges.map((m) => `${m.migration.id}: ${m.migration.from.versions}`).join(", ")})` : ""}. Without it the scope's place in ${switchTo}'s history is unknown, so the upgrade is refused.`,
216
+ );
217
+ }
218
+ chain.push({ migration: fits[0].migration, file: fits[0].file });
219
+ at = fits[0].target;
220
+ }
221
+
222
+ const inRange = own
191
223
  .map((m) => ({ ...m, target: parseVersion(m.migration.to)! }))
192
- .filter((m) => compareVersions(m.target, from) > 0 && compareVersions(m.target, to) <= 0)
224
+ .filter((m) => compareVersions(m.target, at) > 0 && compareVersions(m.target, to) <= 0)
193
225
  .sort((a, b) => compareVersions(a.target, b.target) || a.migration.id.localeCompare(b.migration.id));
194
226
 
195
- const chain: LoadedMigration[] = [];
196
- let at = from;
197
227
  let i = 0;
198
228
  while (i < inRange.length) {
199
229
  const step = inRange[i].target;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The provenance of an adopted lineage (#2551, D5, requirement P7).
3
+ *
4
+ * `chant workspace adopt-lineage` records the commit it adopted at, and adds
5
+ * the range ending there to `.chant/trust.json`. That file is policy, always
6
+ * read at the base revision (./trust/policy.ts), so an adoption counts only
7
+ * once its range is in the policy at base, which takes a protected write by
8
+ * an admin. Until then the scope reads as `unattested`, like any lineage
9
+ * chant wrote itself.
10
+ */
11
+
12
+ import { execFileSync } from "node:child_process";
13
+ import type { Lineage } from "./lineage-lock";
14
+ import { isAdopted, policyAtBase, resolveBase } from "./trust/provenance";
15
+
16
+ export interface LineageProvenance {
17
+ level: "adopted" | "unattested";
18
+ reason: string;
19
+ }
20
+
21
+ /** Null for a lineage that makes no adoption claim: one chant wrote at init, vendor or upgrade, or a directory lineage moved onto git. */
22
+ export function lineageProvenance(root: string, lineage: Lineage): LineageProvenance | null {
23
+ const adoption = lineage.adoption;
24
+ if (!adoption || adoption.by !== "files") return null;
25
+ const to = adoption.commits.to;
26
+ let repo: string;
27
+ try {
28
+ repo = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: root, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
29
+ } catch {
30
+ return { level: "unattested", reason: "not in a git repository, so the adopted range cannot be checked" };
31
+ }
32
+ const base = resolveBase(repo);
33
+ if (!base.commit) return { level: "unattested", reason: base.problem ?? "no base revision" };
34
+ const policy = policyAtBase(repo, base);
35
+ if (isAdopted(repo, policy, to)) return { level: "adopted", reason: `the policy at ${base.commit.slice(0, 12)} adopts the range up to ${to.slice(0, 12)}` };
36
+ return {
37
+ level: "unattested",
38
+ reason: `the range up to ${to.slice(0, 12)} is not in .chant/trust.json at ${base.commit.slice(0, 12)} (${base.from}); it counts once that change is merged`,
39
+ };
40
+ }