@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,452 @@
1
+ /**
2
+ * `chant workspace adopt-lineage`: give a scope a git lineage it can be
3
+ * upgraded from (#2551, D5, D9, requirement P7).
4
+ *
5
+ * Two cases, told apart by what the lock already holds for the scope.
6
+ *
7
+ * A scope with no lineage, such as a project made before the lock existed or
8
+ * copied by hand from a template or a fork of one, has no record of where its
9
+ * files came from. Adoption recovers it from the template's own versions:
10
+ *
11
+ * 1. Compute the template's hash index from its tags (./lineage-hash-index.ts),
12
+ * or reuse a cached copy for the tags whose commit it still names.
13
+ * 2. Score each version against the scope: how many of the template's files
14
+ * the scope holds byte for byte. The best version wins; ties go to the
15
+ * version whose files the scope accounts for best, then to the oldest.
16
+ * 3. Re-check a winner that came from the cache against the template itself,
17
+ * so a stale or forged cache never reaches the lock.
18
+ * 4. Record a lineage pinned to that version. Each file the template has
19
+ * there is recorded with the template's hash, after the template's
20
+ * parameters are substituted (#2627), as its merge base, so a file the
21
+ * scope edited shows as edited and merges on upgrade like any other.
22
+ * 5. Record the adoption under P7: the exact commit range it vouches for,
23
+ * everything up to HEAD, goes into the lineage and into
24
+ * `.chant/trust.json`'s `adopted` list. That file is policy, read at the
25
+ * base revision, and editing it is a protected write, so the range counts
26
+ * only once an admin has merged it. The scope then reads as `adopted`
27
+ * (./lineage-provenance.ts).
28
+ *
29
+ * A scope with a directory lineage (#2647) already has a lineage, but no
30
+ * history to rebuild a merge base from once the directory changes. The
31
+ * studio's boxes are made this way, with `chant init --from ~/box/template`,
32
+ * and a kit release replaces that directory. Adoption moves such a scope onto
33
+ * the git repository the directory was copied from: every candidate version
34
+ * is rendered with the parameters the lock recorded, and only a version that
35
+ * reproduces every hash the lock recorded qualifies. The merge base is then
36
+ * the same files, so the lineage keeps its files, parameters and migrations,
37
+ * and only its source, ref and address change. Nothing about the scope's
38
+ * history is claimed, so no commit range is admitted.
39
+ */
40
+
41
+ import { execFileSync } from "node:child_process";
42
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
43
+ import { dirname, join, resolve } from "node:path";
44
+ import { computeHashIndex, indexExcludes, TemplateTags, type HashIndex, type HashIndexEntry } from "./lineage-hash-index";
45
+ import { parseTemplateSource, portableUrl, readTemplateTree } from "./lineage-init";
46
+ import {
47
+ LOCK_FILE,
48
+ LockError,
49
+ contentDigest,
50
+ declaredFilesAt,
51
+ emptyLock,
52
+ fileEntries,
53
+ fileHash,
54
+ readLock,
55
+ scopeKey,
56
+ writeLock,
57
+ type Adoption,
58
+ type Lineage,
59
+ } from "./lineage-lock";
60
+ import { splitMigrations } from "./lineage-migrations";
61
+ import { carryParameters, readManifest, resolveParameters, substituteParameters } from "./template-manifest";
62
+ import { repinSubstituted, type RepinnedRecord } from "./template-pins";
63
+
64
+ /** Where the trust policy lives, relative to the repository root (./trust/policy.ts). */
65
+ const TRUST_FILE = ".chant/trust.json";
66
+
67
+ export interface AdoptOptions {
68
+ /** The directory that holds (or will hold) `.chant/workspace.lock.json`. */
69
+ root: string;
70
+ /** The scope to adopt, relative to `root`. Defaults to `"."`. */
71
+ scope?: string;
72
+ /** `<repo>[@<ref>][#<member>]`: the template. With a ref, adopt at exactly that version. */
73
+ from: string;
74
+ /** Only tags matching this glob are candidates, such as `kit-v*`. */
75
+ tags?: string;
76
+ /** A cached hash index, such as a copy the template's CI published. */
77
+ cache?: HashIndex;
78
+ /** `--param name=value` values for a template with a `chant.template.json`; only for a scope with no lineage. */
79
+ params?: Record<string, string>;
80
+ dryRun?: boolean;
81
+ }
82
+
83
+ export interface VersionScore {
84
+ tag: string;
85
+ commit: string;
86
+ /** Files the template has at the version. */
87
+ files: number;
88
+ identical: number;
89
+ edited: number;
90
+ missing: number;
91
+ }
92
+
93
+ export interface AdoptionResult {
94
+ scope: string;
95
+ /** `files` for a scope with no lineage, `lineage` for a directory lineage moved onto git. */
96
+ by: Adoption["by"];
97
+ template: string;
98
+ chosen: VersionScore;
99
+ /** The next best versions that do not tie with the chosen one, best first, at most three. */
100
+ alternatives: VersionScore[];
101
+ /** Other versions that score exactly as the chosen one does. `@<ref>` in `--from` picks one of them. */
102
+ ties: string[];
103
+ /** How many versions were compared. */
104
+ compared: number;
105
+ /** The files, by how the scope holds them (for `lineage`, by how the lock recorded them). */
106
+ identical: string[];
107
+ edited: string[];
108
+ missing: string[];
109
+ index: Adoption["index"];
110
+ /** The lineage the lock records. */
111
+ lineage: Lineage;
112
+ /** The `.chant/trust.json` entry the adoption adds, for `files`. Null when it is already there or for `lineage`. */
113
+ trust: { path: string; entry: { to: string; note: string } } | null;
114
+ /** Whether the lock (and the trust entry) were written: false for a dry run. */
115
+ written: boolean;
116
+ /** Whether a `.gitignore` covers the lock, so it would not be committed without `git add -f`. */
117
+ lockIgnored: boolean;
118
+ }
119
+
120
+ function gitRaw(cwd: string, args: string[]): string {
121
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024 });
122
+ }
123
+
124
+ function isIgnored(root: string, path: string): boolean {
125
+ try {
126
+ gitRaw(root, ["check-ignore", "-q", "--no-index", "--", path]);
127
+ return true;
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ /** Score one version against a lookup of the scope's hashes. */
134
+ function score(entry: { tag: string; commit: string; files: Record<string, string> }, have: (path: string) => string | undefined): VersionScore {
135
+ let identical = 0;
136
+ let edited = 0;
137
+ let missing = 0;
138
+ for (const [path, sha] of Object.entries(entry.files)) {
139
+ const h = have(path);
140
+ if (h === undefined) missing++;
141
+ else if (h === sha) identical++;
142
+ else edited++;
143
+ }
144
+ return { tag: entry.tag, commit: entry.commit, files: Object.keys(entry.files).length, identical, edited, missing };
145
+ }
146
+
147
+ type Ranked = VersionScore & { order: number };
148
+
149
+ /**
150
+ * Best first: most identical files, then fewest template files the scope does
151
+ * not hold as they are, then the oldest version. Versions that tie hold the
152
+ * same files, so the scope fits any of them. Adopting the oldest means an
153
+ * upgrade replays every migration since; one that no longer applies refuses
154
+ * the upgrade, where adopting the newest would skip it without a word.
155
+ */
156
+ function rank(a: Ranked, b: Ranked): number {
157
+ return b.identical - a.identical || a.edited + a.missing - (b.edited + b.missing) || a.order - b.order;
158
+ }
159
+
160
+ const ties = (best: VersionScore) => (r: VersionScore) => r.identical === best.identical && r.edited + r.missing === best.edited + best.missing;
161
+ const plain = ({ order: _o, ...s }: Ranked): VersionScore => s;
162
+
163
+ /**
164
+ * HEAD of the repository holding the scope, once the scope and the lock have
165
+ * no uncommitted changes: an adoption names an exact commit, and uncommitted
166
+ * files are in none.
167
+ */
168
+ function adoptedAt(root: string, scope: string): { repo: string; head: string } {
169
+ let repo: string;
170
+ let head: string;
171
+ try {
172
+ repo = gitRaw(root, ["rev-parse", "--show-toplevel"]).trim();
173
+ head = gitRaw(root, ["rev-parse", "HEAD"]).trim();
174
+ } catch {
175
+ throw new LockError("adopt-lineage records the exact commit it adopts at, so the scope must be in a git repository with at least one commit");
176
+ }
177
+ // Untrimmed: each porcelain line starts with its two status columns, which may be spaces.
178
+ const dirty = gitRaw(root, ["status", "--porcelain", "--untracked-files=all", "--", scope === "." ? "." : scope, LOCK_FILE])
179
+ .split("\n")
180
+ .filter(Boolean);
181
+ if (dirty.length > 0) {
182
+ throw new LockError(
183
+ `the scope has uncommitted changes (${dirty.slice(0, 5).map((l) => l.slice(3)).join(", ")}${dirty.length > 5 ? ", …" : ""}). Commit them first: an adoption names an exact commit, and they are in none.`,
184
+ );
185
+ }
186
+ return { repo, head };
187
+ }
188
+
189
+ /** A version's files as a project made from it holds them: parameters substituted, manifest and migrations left out. */
190
+ function instantiate(
191
+ raw: Map<string, Buffer>,
192
+ values: (manifest: ReturnType<typeof readManifest>) => Record<string, string>,
193
+ ): { files: Map<string, Buffer>; parameters: Record<string, string>; repinned: RepinnedRecord[] } {
194
+ const manifest = readManifest(raw);
195
+ const parameters = values(manifest);
196
+ const { files, repinned } = repinSubstituted(raw, substituteParameters(raw, manifest, parameters), manifest?.files ?? []);
197
+ const kept = new Map<string, Buffer>();
198
+ for (const [path, data] of splitMigrations(files).files) if (!indexExcludes(path)) kept.set(path, data);
199
+ return { files: kept, parameters, repinned };
200
+ }
201
+
202
+ function readRaw(tags: TemplateTags, entry: HashIndexEntry, member: string | undefined, label: string): Map<string, Buffer> {
203
+ const read = readTemplateTree(tags.scratch, entry.commit, member, `${label}@${entry.tag}`);
204
+ return new Map([...read.files].map(([path, f]) => [path, f.data]));
205
+ }
206
+
207
+ /** Add the adoption's range to `.chant/trust.json`, keeping everything else in it. Returns null when an entry already covers it. */
208
+ function trustEntry(repo: string, head: string, note: string): { path: string; entry: { to: string; note: string }; text: string } | null {
209
+ const abs = join(repo, TRUST_FILE);
210
+ let config: Record<string, unknown> = { schema: 1 };
211
+ if (existsSync(abs)) {
212
+ try {
213
+ config = JSON.parse(readFileSync(abs, "utf-8")) as Record<string, unknown>;
214
+ } catch (err) {
215
+ throw new LockError(`${TRUST_FILE} is not valid JSON, so the adoption cannot be added to it: ${err instanceof Error ? err.message : String(err)}`);
216
+ }
217
+ }
218
+ const adopted = Array.isArray(config.adopted) ? (config.adopted as Array<{ to?: unknown }>) : [];
219
+ if (adopted.some((r) => r.to === head)) return null;
220
+ const entry = { to: head, note };
221
+ config.adopted = [...adopted, entry];
222
+ return { path: TRUST_FILE, entry, text: JSON.stringify(config, null, 2) + "\n" };
223
+ }
224
+
225
+ /**
226
+ * Adopt a lineage for the scope, and record it unless `dryRun`. Throws a
227
+ * {@link LockError} when the scope has a lineage that is not a directory
228
+ * lineage, when no version fits, or when the template cannot be read.
229
+ */
230
+ export function adoptLineage(options: AdoptOptions): AdoptionResult {
231
+ const root = resolve(options.root);
232
+ const scope = scopeKey(options.scope ?? ".");
233
+ const scopeDir = join(root, scope);
234
+ if (!existsSync(scopeDir) || !statSync(scopeDir).isDirectory()) throw new LockError(`scope "${scope}" is not a directory under ${root}`);
235
+ const lock = readLock(root) ?? emptyLock();
236
+ const existing = lock.scopes[scope];
237
+ if (existing && existing.source.type !== "dir") {
238
+ throw new LockError(
239
+ `${LOCK_FILE} already has a lineage for scope "${scope}" (${existing.template}, ${existing.source.type}). adopt-lineage gives a lineage to a scope with none, or moves a directory lineage onto git`,
240
+ );
241
+ }
242
+ if (existing && existing.manualSteps.length > 0) {
243
+ throw new LockError(`scope "${scope}" has ${existing.manualSteps.length} open manual step(s); resolve them before moving its lineage`);
244
+ }
245
+ if (existing && options.params && Object.keys(options.params).length > 0) {
246
+ throw new LockError(`scope "${scope}" already records its parameters; --param is only for a scope with no lineage`);
247
+ }
248
+ const spec = parseTemplateSource(options.from, root, "--from");
249
+ const { repo, head } = adoptedAt(root, scope);
250
+ const label = spec.member ? `${spec.repo}#${spec.member}` : spec.repo;
251
+ const tags = new TemplateTags(spec.url, spec.member, label);
252
+ try {
253
+ const result = existing
254
+ ? fromDirectoryLineage(tags, existing, { spec, label, pattern: options.tags, head })
255
+ : fromFiles(tags, scopeDir, { spec, label, pattern: options.tags, cache: options.cache, params: options.params ?? {}, head });
256
+ const lineage: Lineage = { ...result.lineage, source: { type: "git", repo: spec.repo, url: portableUrl(spec.url, root), ...(spec.member ? { path: spec.member } : {}) } };
257
+ const trust = lineage.adoption!.by === "files" ? trustEntry(repo, head, `adopt-lineage ${scope} ${spec.id}@${result.chosen.tag}`) : null;
258
+ if (!options.dryRun) {
259
+ lock.scopes[scope] = lineage;
260
+ writeLock(root, lock);
261
+ if (trust) {
262
+ mkdirSync(dirname(join(repo, trust.path)), { recursive: true });
263
+ writeFileSync(join(repo, trust.path), trust.text);
264
+ }
265
+ }
266
+ return {
267
+ ...result,
268
+ scope,
269
+ template: spec.id,
270
+ lineage,
271
+ trust: trust ? { path: trust.path, entry: trust.entry } : null,
272
+ written: !options.dryRun,
273
+ lockIgnored: isIgnored(root, LOCK_FILE),
274
+ };
275
+ } finally {
276
+ tags.dispose();
277
+ }
278
+ }
279
+
280
+ type Found = Omit<AdoptionResult, "scope" | "template" | "trust" | "written" | "lockIgnored">;
281
+
282
+ /** A scope with no lineage: matched by its files. */
283
+ function fromFiles(
284
+ tags: TemplateTags,
285
+ scopeDir: string,
286
+ o: { spec: ReturnType<typeof parseTemplateSource>; label: string; pattern?: string; cache?: HashIndex; params: Record<string, string>; head: string },
287
+ ): Found {
288
+ const hashes = new Map<string, string | undefined>();
289
+ const local = (path: string): string | undefined => {
290
+ if (!hashes.has(path)) {
291
+ const abs = join(scopeDir, path);
292
+ hashes.set(path, existsSync(abs) && statSync(abs).isFile() ? fileHash(readFileSync(abs)) : undefined);
293
+ }
294
+ return hashes.get(path);
295
+ };
296
+
297
+ let { index, cached } = computeHashIndex(tags, o.spec.id, { pattern: o.pattern, cache: o.cache, only: o.spec.ref });
298
+ for (;;) {
299
+ const ranked = index.tags.map((entry, order) => ({ ...score(entry, local), order })).sort(rank);
300
+ const best = ranked[0];
301
+ if (!best || best.identical === 0) {
302
+ throw new LockError(
303
+ `no version of ${o.label} shares a file with the scope (compared ${index.tags.length}); check --from${o.spec.member ? "" : " and its #<member>"}, or --tags`,
304
+ );
305
+ }
306
+ const entry = index.tags[best.order];
307
+ if (cached.has(entry.tag)) {
308
+ // Re-check a cached winner against the template itself: the lock records only hashes chant computed.
309
+ tags.fetchTags([entry.tag]);
310
+ const fresh = tags.entry({ tag: entry.tag, commit: entry.commit });
311
+ if (fresh === null || fresh.tree !== entry.tree || JSON.stringify(fresh.files) !== JSON.stringify(entry.files)) {
312
+ // A stale or wrong cache: drop it and compute every version from the template.
313
+ ({ index, cached } = computeHashIndex(tags, o.spec.id, { pattern: o.pattern, only: o.spec.ref }));
314
+ continue;
315
+ }
316
+ }
317
+ const usedCache = cached.size > 0;
318
+
319
+ // The version as a project made from it holds it: the scope's own parameter values substituted (#2627).
320
+ const made = instantiate(readRaw(tags, entry, o.spec.member, o.label), (manifest) => resolveParameters(manifest, o.params));
321
+ const files = fileEntries(made.files, declaredFilesAt(scopeDir));
322
+ const identical: string[] = [];
323
+ const edited: string[] = [];
324
+ const missing: string[] = [];
325
+ for (const [path, e] of Object.entries(files)) {
326
+ const have = local(path);
327
+ (have === undefined ? missing : have === e.sha256 ? identical : edited).push(path);
328
+ }
329
+ const match = { files: Object.keys(files).length, identical: identical.length, edited: edited.length, missing: missing.length };
330
+ const lineage: Lineage = {
331
+ kind: "template",
332
+ template: o.spec.id,
333
+ source: { type: "git", repo: o.spec.repo, url: o.spec.url },
334
+ ref: entry.tag,
335
+ address: { digest: contentDigest(made.files), commit: entry.commit, tree: entry.tree },
336
+ parameters: made.parameters,
337
+ ...(made.repinned.length > 0 ? { repinned: made.repinned } : {}),
338
+ migrations: [],
339
+ files,
340
+ manualSteps: [],
341
+ adoption: { by: "files", commits: { to: o.head }, match, index: usedCache ? "cache" : "computed" },
342
+ };
343
+ return {
344
+ by: "files",
345
+ chosen: { tag: entry.tag, commit: entry.commit, ...match },
346
+ alternatives: ranked.slice(1).filter((r) => !ties(best)(r)).slice(0, 3).map(plain),
347
+ ties: ranked.slice(1).filter(ties(best)).map((r) => r.tag),
348
+ compared: index.tags.length,
349
+ identical,
350
+ edited,
351
+ missing,
352
+ index: lineage.adoption!.index,
353
+ lineage,
354
+ };
355
+ }
356
+ }
357
+
358
+ /**
359
+ * A directory lineage moved onto git: the version must reproduce, with the
360
+ * recorded parameters, the hash of every file the lock records (generated
361
+ * files aside, since they are rebuilt and never merged).
362
+ */
363
+ function fromDirectoryLineage(
364
+ tags: TemplateTags,
365
+ existing: Lineage,
366
+ o: { spec: ReturnType<typeof parseTemplateSource>; label: string; pattern?: string; head: string },
367
+ ): Found {
368
+ const { index } = computeHashIndex(tags, o.spec.id, { pattern: o.pattern, only: o.spec.ref });
369
+ const recorded = Object.entries(existing.files).filter(([, e]) => e.class !== "generated");
370
+ const candidates: Array<Ranked & { entry: HashIndexEntry; made: ReturnType<typeof instantiate>; exact: boolean; differ: string[] }> = [];
371
+ index.tags.forEach((entry, order) => {
372
+ let made: ReturnType<typeof instantiate>;
373
+ try {
374
+ made = instantiate(readRaw(tags, entry, o.spec.member, o.label), (manifest) => carryParameters(manifest, existing.parameters));
375
+ } catch {
376
+ // A version whose manifest the recorded parameters do not satisfy cannot be the one the scope was made from.
377
+ return;
378
+ }
379
+ const at = new Map([...made.files].map(([p, d]) => [p, fileHash(d)]));
380
+ const s = score({ tag: entry.tag, commit: entry.commit, files: Object.fromEntries(recorded.map(([p, e]) => [p, e.sha256])) }, (p) => at.get(p));
381
+ const differ = recorded.filter(([p, e]) => at.get(p) !== e.sha256).map(([p]) => p);
382
+ candidates.push({ ...s, files: made.files.size, order, entry, made, exact: contentDigest(made.files) === existing.address?.digest, differ });
383
+ });
384
+ const fits = candidates.filter((c) => c.differ.length === 0).sort((a, b) => Number(b.exact) - Number(a.exact) || a.order - b.order);
385
+ if (fits.length === 0) {
386
+ const near = [...candidates].sort((a, b) => a.differ.length - b.differ.length || a.order - b.order)[0];
387
+ throw new LockError(
388
+ `no version of ${o.label} reproduces the files the scope was made from (compared ${index.tags.length}${o.pattern ? `, tags ${o.pattern}` : ""})` +
389
+ (near ? `; the nearest, ${near.tag}, differs in ${near.differ.slice(0, 5).join(", ")}${near.differ.length > 5 ? ", …" : ""}` : "") +
390
+ `. Name the commit the directory was copied from with --from <repo>@<ref>, or upgrade from a directory that still holds those files.`,
391
+ );
392
+ }
393
+ const chosen = fits[0];
394
+ const files = Object.keys(existing.files);
395
+ const match = { files: chosen.files, identical: recorded.length, edited: 0, missing: 0 };
396
+ const lineage: Lineage = {
397
+ ...existing,
398
+ template: o.spec.id,
399
+ source: { type: "git", repo: o.spec.repo, url: o.spec.url },
400
+ ref: chosen.entry.tag,
401
+ address: { digest: contentDigest(chosen.made.files), commit: chosen.entry.commit, tree: chosen.entry.tree },
402
+ adoption: {
403
+ by: "lineage",
404
+ commits: { to: o.head },
405
+ match,
406
+ index: "computed",
407
+ previous: { template: existing.template, source: existing.source },
408
+ },
409
+ };
410
+ const same = (c: (typeof fits)[number]) => c.exact === chosen.exact;
411
+ return {
412
+ by: "lineage",
413
+ chosen: { tag: chosen.tag, commit: chosen.commit, ...match },
414
+ alternatives: [],
415
+ ties: fits.slice(1).filter(same).map((c) => c.tag),
416
+ compared: index.tags.length,
417
+ identical: files.filter((p) => existing.files[p].class !== "generated"),
418
+ edited: [],
419
+ missing: [],
420
+ index: "computed",
421
+ lineage,
422
+ };
423
+ }
424
+
425
+ /** One line per fact, for the terminal. */
426
+ export function describeAdoption(p: AdoptionResult): string[] {
427
+ const at = (path: string) => (p.scope === "." ? path : `${p.scope}/${path}`);
428
+ const c = p.chosen;
429
+ const lines = [`${p.scope} ${p.template}@${c.tag} (${c.commit.slice(0, 12)})`];
430
+ if (p.by === "lineage") {
431
+ const prev = p.lineage.adoption!.previous!;
432
+ lines.push(` moved from ${prev.template}: every one of the ${c.identical} recorded file(s) has the same content at ${c.tag}; ${p.compared} version(s) compared`);
433
+ } else {
434
+ lines.push(` matched ${c.identical} of ${c.files} template file(s); ${c.edited} edited, ${c.missing} not in the scope; ${p.compared} version(s) compared`);
435
+ }
436
+ if (p.ties.length > 0) {
437
+ const named = p.ties.length > 3 ? `${p.ties[0]} to ${p.ties[p.ties.length - 1]}` : p.ties.join(", ");
438
+ lines.push(` ${p.ties.length} later version(s) fit equally (${named}); the oldest is chosen. Name one with --from <repo>@<ref> if the scope came from it.`);
439
+ }
440
+ for (const a of p.alternatives) lines.push(` next: ${a.tag}, ${a.identical} of ${a.files} identical, ${a.edited} edited, ${a.missing} missing`);
441
+ for (const path of p.edited) lines.push(` edited: ${at(path)}`);
442
+ for (const path of p.missing) lines.push(` not in the scope: ${at(path)}`);
443
+ if (p.by === "files") {
444
+ lines.push(` index: ${p.index === "cache" ? "from the cache, re-checked at the chosen version" : "computed from the template"}`);
445
+ lines.push(
446
+ p.trust
447
+ ? ` adopted range: up to ${p.trust.entry.to.slice(0, 12)}, added to ${p.trust.path}; it counts once that change is merged to the base branch`
448
+ : ` adopted range: up to ${p.lineage.adoption!.commits.to.slice(0, 12)}, already in ${TRUST_FILE}`,
449
+ );
450
+ }
451
+ return lines;
452
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `chant workspace check [--at <rev>] [--json] [--format stylish|json|sarif] [--generated] [--kind <kind file>]`:
2
+ * `chant workspace check [--at <rev>] [--json] [--format stylish|json|sarif] [--generated] [--kind <kind file>] [--live --env <env>]`:
3
3
  * the lineage checks (#2550, D9), and the declaration checks (#2535, D16)
4
4
  * when a declaration sits between the current directory and the git root.
5
5
  *
@@ -12,7 +12,10 @@
12
12
  * pipelines (#2542) and generated files (#2541) as well as the declaration
13
13
  * itself (#2641). `--generated` runs each declared generator too. `--kind`
14
14
  * reads a record kind's records and reports the files they pin by hash that
15
- * have changed since (#2549).
15
+ * have changed since (#2549). `--live --env <env>` resolves each declared
16
+ * member link against the live graph of `<env>` (`./checks/live.ts`, #2549):
17
+ * it is the one option that reaches a network, through each member's own
18
+ * `chant graph --live`, and is catalogued as egress.
16
19
  *
17
20
  * `chant workspace upgrade` runs the same checks in its staging worktree
18
21
  * before it reaches its gate.
@@ -88,7 +91,7 @@ export function findingKey(f: CheckFinding): string {
88
91
  }
89
92
 
90
93
  const USAGE =
91
- "chant workspace check [--at <rev>] [--json] [--format stylish|json|sarif] [--generated] [--kind <kind file>]\n chant workspace check --changes <base>..<head> [--work <id>] [--severity off|warn|fail] [--kind <kind file>...] [--json]";
94
+ "chant workspace check [--at <rev>] [--json] [--format stylish|json|sarif] [--generated] [--kind <kind file>] [--live --env <env>]\n chant workspace check --changes <base>..<head> [--work <id>] [--severity off|warn|fail] [--kind <kind file>...] [--json]";
92
95
  const FORMATS = ["stylish", "json", "sarif"] as const;
93
96
 
94
97
  /** A lock finding as a lint diagnostic, for `--format json` and `--format sarif`. */
@@ -149,7 +152,7 @@ export type CheckDocument =
149
152
  * to `cwd`. `runGenerators` is `--generated`; with `at` it does nothing,
150
153
  * since the member facts describe the working tree, not the revision.
151
154
  */
152
- export async function runChecks(cwd: string, at?: string, options: { runGenerators?: boolean; kind?: string } = {}): Promise<CheckDocument> {
155
+ export async function runChecks(cwd: string, at?: string, options: { runGenerators?: boolean; kind?: string; live?: { env: string; onStderr?: (text: string) => void } } = {}): Promise<CheckDocument> {
153
156
  // Loaded here, not at the top: `workspace upgrade` imports this module for checkLineage alone.
154
157
  const [{ findDeclarationDir, readDeclaration, readerVersion }, { gitTop, gitTree, resolveCommit, workingTree }] = await Promise.all([
155
158
  import("./declaration"),
@@ -196,6 +199,7 @@ export async function runChecks(cwd: string, at?: string, options: { runGenerato
196
199
  runGenerators: options.runGenerators === true,
197
200
  ...(commit !== null ? { tree } : {}),
198
201
  ...(records ? { records } : {}),
202
+ ...(options.live ? { live: options.live } : {}),
199
203
  });
200
204
  let name: string | null = null;
201
205
  try {
@@ -238,6 +242,19 @@ export async function runWorkspaceCheck(ctx: CommandContext): Promise<number> {
238
242
  if (handed !== undefined) return handed;
239
243
  return (await import("./changes-cli")).runWorkspaceChanges(ctx, root);
240
244
  }
245
+ if (ctx.args.live) {
246
+ // A live read is of the account now, and it needs the environment each member's `chant graph --live` reads.
247
+ const problem =
248
+ ctx.args.at !== undefined
249
+ ? "--live reads the account as it stands now, and --at reads the source at a revision; chant workspace check takes one of them"
250
+ : !ctx.args.env
251
+ ? "--live needs an environment, as each member's chant graph --live does: --live --env <env>"
252
+ : undefined;
253
+ if (problem) {
254
+ console.error(formatError({ message: problem, hint: USAGE }));
255
+ return 1;
256
+ }
257
+ }
241
258
  const format = (ctx.args.format || "stylish") as (typeof FORMATS)[number];
242
259
  if (!FORMATS.includes(format)) {
243
260
  console.error(formatError({ message: `--format ${format} is not a check format; use stylish, json or sarif`, hint: USAGE }));
@@ -250,12 +267,16 @@ export async function runWorkspaceCheck(ctx: CommandContext): Promise<number> {
250
267
  const handed = await handToRootChant(root, ctx.args.at);
251
268
  if (handed !== undefined) return handed;
252
269
  }
253
- const doc = await runChecks(root, ctx.args.at, { runGenerators: ctx.args.generated === true, ...(ctx.args.kind !== undefined ? { kind: ctx.args.kind } : {}) });
270
+ const doc = await runChecks(root, ctx.args.at, { runGenerators: ctx.args.generated === true, ...(ctx.args.kind !== undefined ? { kind: ctx.args.kind } : {}), ...(ctx.args.live && ctx.args.env ? { live: { env: ctx.args.env, onStderr: (text: string) => process.stderr.write(text) } } : {}) });
254
271
  if ("error" in doc) {
255
272
  if (ctx.args.json || format === "json") console.log(JSON.stringify(doc, null, 2));
256
273
  else console.error(formatError({ message: `${doc.error.code}: ${doc.error.message}`, hint: USAGE }));
257
274
  return 1;
258
275
  }
276
+ if (ctx.args.live && !doc.declaration) {
277
+ console.error(formatError({ message: "--live resolves a workspace's member links, and there is no chant.workspace.json between here and the git root", hint: USAGE }));
278
+ return 1;
279
+ }
259
280
  const { declaration, ok } = doc;
260
281
  const report = { lock: doc.lock, findings: doc.findings };
261
282
 
@@ -11,7 +11,8 @@
11
11
 
12
12
  import { formatError, formatSuccess } from "../cli/format";
13
13
  import type { CommandContext } from "../cli/registry";
14
- import { LOCK_FILE, LockError, readLock, resolveManualStep, scopeStatus, writeLock, type LineageSource, type ManualStep } from "./lineage-lock";
14
+ import { LOCK_FILE, LockError, readLock, resolveManualStep, scopeStatus, writeLock, type Adoption, type LineageSource, type ManualStep } from "./lineage-lock";
15
+ import { lineageProvenance, type LineageProvenance } from "./lineage-provenance";
15
16
 
16
17
  const USAGE = "chant workspace lineage [--json] | chant workspace lineage resolve <path>";
17
18
 
@@ -28,6 +29,10 @@ export interface LineageScopeView {
28
29
  edited: string[];
29
30
  missing: string[];
30
31
  manualSteps: ManualStep[];
32
+ /** How adopt-lineage recorded the lineage (#2551), when it did. */
33
+ adoption?: Adoption;
34
+ /** For a lineage adopted by its files: whether the policy at base admits the adopted range (D5, P7). */
35
+ provenance?: LineageProvenance;
31
36
  }
32
37
 
33
38
  /** One line naming a scope's source kind and where it points (#2647). */
@@ -52,7 +57,7 @@ export function lineageView(root: string): { lock: string; scopes: LineageScopeV
52
57
  if (!lock) return null;
53
58
  const scopes = Object.entries(lock.scopes).map(([scope, lineage]) => {
54
59
  const st = scopeStatus(root, scope, lineage);
55
- return {
60
+ const view: LineageScopeView = {
56
61
  scope,
57
62
  kind: lineage.kind,
58
63
  ...(lineage.name !== undefined ? { name: lineage.name } : {}),
@@ -64,7 +69,10 @@ export function lineageView(root: string): { lock: string; scopes: LineageScopeV
64
69
  edited: st.customised,
65
70
  missing: st.missing,
66
71
  manualSteps: lineage.manualSteps,
72
+ ...(lineage.adoption ? { adoption: lineage.adoption } : {}),
67
73
  };
74
+ const provenance = lineageProvenance(root, lineage);
75
+ return provenance ? { ...view, provenance } : view;
68
76
  });
69
77
  return { lock: LOCK_FILE, scopes };
70
78
  }
@@ -93,6 +101,8 @@ export async function runWorkspaceLineage(ctx: CommandContext): Promise<number>
93
101
  const at = typeof s.address?.commit === "string" ? ` (${s.address.commit.slice(0, 12)})` : "";
94
102
  lines.push(`${s.scope} ${s.kind}${s.name ? ` ${s.name}` : ""} ${s.template}${pin}${at}`);
95
103
  lines.push(` source: ${describeSource(s.source)}`);
104
+ if (s.adoption?.by === "lineage" && s.adoption.previous) lines.push(` moved from ${describeSource(s.adoption.previous.source)} by adopt-lineage`);
105
+ if (s.adoption?.by === "files") lines.push(` adopted up to ${s.adoption.commits.to.slice(0, 12)}: ${s.provenance?.level ?? "unattested"}, ${s.provenance?.reason ?? ""}`.replace(/, $/, ""));
96
106
  lines.push(` ${s.files} file(s), ${s.edited.length} edited, ${s.missing.length} deleted`);
97
107
  for (const m of s.manualSteps) lines.push(` manual step: ${s.scope === "." ? "" : `${s.scope}/`}${m.path} (${m.reason})`);
98
108
  }