@clossys/launcher 0.3.0 → 0.4.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 (286) hide show
  1. package/README.md +1328 -60
  2. package/contracts/conversation-contract.md +2 -1
  3. package/contracts/product-ci-workflow.yml +74 -0
  4. package/contracts/repository-inventory.json +53 -0
  5. package/dist/admission-fixture.d.ts +168 -0
  6. package/dist/admission-fixture.d.ts.map +1 -0
  7. package/dist/admission-fixture.js +453 -0
  8. package/dist/admission-fixture.js.map +1 -0
  9. package/dist/admission.d.ts +124 -0
  10. package/dist/admission.d.ts.map +1 -0
  11. package/dist/admission.js +799 -0
  12. package/dist/admission.js.map +1 -0
  13. package/dist/agents-guide.d.ts +9 -0
  14. package/dist/agents-guide.d.ts.map +1 -0
  15. package/dist/agents-guide.js +26 -0
  16. package/dist/agents-guide.js.map +1 -0
  17. package/dist/apply-command-options.check.d.ts +12 -0
  18. package/dist/apply-command-options.check.d.ts.map +1 -0
  19. package/dist/apply-command-options.check.js +20 -0
  20. package/dist/apply-command-options.check.js.map +1 -0
  21. package/dist/apply-plan-cli.d.ts +39 -1
  22. package/dist/apply-plan-cli.d.ts.map +1 -1
  23. package/dist/apply-plan-cli.js +432 -15
  24. package/dist/apply-plan-cli.js.map +1 -1
  25. package/dist/apply-plan.d.ts +46 -59
  26. package/dist/apply-plan.d.ts.map +1 -1
  27. package/dist/apply-plan.js +112 -97
  28. package/dist/apply-plan.js.map +1 -1
  29. package/dist/apply-step-fixture.d.ts +87 -0
  30. package/dist/apply-step-fixture.d.ts.map +1 -0
  31. package/dist/apply-step-fixture.js +199 -0
  32. package/dist/apply-step-fixture.js.map +1 -0
  33. package/dist/apply-store.d.ts +93 -0
  34. package/dist/apply-store.d.ts.map +1 -0
  35. package/dist/apply-store.js +625 -0
  36. package/dist/apply-store.js.map +1 -0
  37. package/dist/approval-sheet.d.ts +21 -0
  38. package/dist/approval-sheet.d.ts.map +1 -0
  39. package/dist/approval-sheet.js +157 -0
  40. package/dist/approval-sheet.js.map +1 -0
  41. package/dist/body-command.d.ts +42 -0
  42. package/dist/body-command.d.ts.map +1 -0
  43. package/dist/body-command.js +143 -0
  44. package/dist/body-command.js.map +1 -0
  45. package/dist/change-set-contract.d.ts +381 -0
  46. package/dist/change-set-contract.d.ts.map +1 -0
  47. package/dist/change-set-contract.js +738 -0
  48. package/dist/change-set-contract.js.map +1 -0
  49. package/dist/change-set-digest.d.ts +28 -0
  50. package/dist/change-set-digest.d.ts.map +1 -0
  51. package/dist/change-set-digest.js +65 -0
  52. package/dist/change-set-digest.js.map +1 -0
  53. package/dist/check-cli.d.ts.map +1 -1
  54. package/dist/check-cli.js +14 -3
  55. package/dist/check-cli.js.map +1 -1
  56. package/dist/cli.d.ts +17 -6
  57. package/dist/cli.d.ts.map +1 -1
  58. package/dist/cli.js +84 -23
  59. package/dist/cli.js.map +1 -1
  60. package/dist/core.d.ts +79 -22
  61. package/dist/core.d.ts.map +1 -1
  62. package/dist/core.js +843 -268
  63. package/dist/core.js.map +1 -1
  64. package/dist/dry-materialize.d.ts +63 -0
  65. package/dist/dry-materialize.d.ts.map +1 -0
  66. package/dist/dry-materialize.js +330 -0
  67. package/dist/dry-materialize.js.map +1 -0
  68. package/dist/generated/contract-schema.generated.d.ts +97 -0
  69. package/dist/generated/contract-schema.generated.d.ts.map +1 -0
  70. package/dist/generated/contract-schema.generated.js +496 -0
  71. package/dist/generated/contract-schema.generated.js.map +1 -0
  72. package/dist/generated/package-scope.generated.d.ts +6 -0
  73. package/dist/generated/package-scope.generated.d.ts.map +1 -0
  74. package/dist/generated/package-scope.generated.js +10 -0
  75. package/dist/generated/package-scope.generated.js.map +1 -0
  76. package/dist/generated/plan-contracts.generated.d.ts +3 -0
  77. package/dist/generated/plan-contracts.generated.d.ts.map +1 -0
  78. package/dist/generated/plan-contracts.generated.js +2840 -0
  79. package/dist/generated/plan-contracts.generated.js.map +1 -0
  80. package/dist/host.d.ts.map +1 -1
  81. package/dist/host.js +11 -0
  82. package/dist/host.js.map +1 -1
  83. package/dist/identity.d.ts +15 -0
  84. package/dist/identity.d.ts.map +1 -0
  85. package/dist/identity.js +48 -0
  86. package/dist/identity.js.map +1 -0
  87. package/dist/index.d.ts +34 -5
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +19 -2
  90. package/dist/index.js.map +1 -1
  91. package/dist/inventory-adoption.d.ts +24 -5
  92. package/dist/inventory-adoption.d.ts.map +1 -1
  93. package/dist/inventory-adoption.js +70 -25
  94. package/dist/inventory-adoption.js.map +1 -1
  95. package/dist/inventory-choice.d.ts +40 -0
  96. package/dist/inventory-choice.d.ts.map +1 -0
  97. package/dist/inventory-choice.js +156 -0
  98. package/dist/inventory-choice.js.map +1 -0
  99. package/dist/inventory-contract.d.ts +89 -0
  100. package/dist/inventory-contract.d.ts.map +1 -0
  101. package/dist/inventory-contract.js +121 -0
  102. package/dist/inventory-contract.js.map +1 -0
  103. package/dist/key-editor.d.ts +30 -0
  104. package/dist/key-editor.d.ts.map +1 -0
  105. package/dist/key-editor.js +445 -0
  106. package/dist/key-editor.js.map +1 -0
  107. package/dist/ledger-contract.d.ts +187 -0
  108. package/dist/ledger-contract.d.ts.map +1 -0
  109. package/dist/ledger-contract.js +532 -0
  110. package/dist/ledger-contract.js.map +1 -0
  111. package/dist/ledger-trust.d.ts +90 -0
  112. package/dist/ledger-trust.d.ts.map +1 -0
  113. package/dist/ledger-trust.js +198 -0
  114. package/dist/ledger-trust.js.map +1 -0
  115. package/dist/lockfile-invariants.d.ts +48 -0
  116. package/dist/lockfile-invariants.d.ts.map +1 -0
  117. package/dist/lockfile-invariants.js +375 -0
  118. package/dist/lockfile-invariants.js.map +1 -0
  119. package/dist/lockfile-readers.d.ts +72 -0
  120. package/dist/lockfile-readers.d.ts.map +1 -0
  121. package/dist/lockfile-readers.js +713 -0
  122. package/dist/lockfile-readers.js.map +1 -0
  123. package/dist/lockfile-regen.d.ts +106 -0
  124. package/dist/lockfile-regen.d.ts.map +1 -0
  125. package/dist/lockfile-regen.js +760 -0
  126. package/dist/lockfile-regen.js.map +1 -0
  127. package/dist/lockfile-tool-env.d.ts +29 -0
  128. package/dist/lockfile-tool-env.d.ts.map +1 -0
  129. package/dist/lockfile-tool-env.js +111 -0
  130. package/dist/lockfile-tool-env.js.map +1 -0
  131. package/dist/materialize.d.ts +113 -0
  132. package/dist/materialize.d.ts.map +1 -0
  133. package/dist/materialize.js +840 -0
  134. package/dist/materialize.js.map +1 -0
  135. package/dist/observe-repository.d.ts +90 -0
  136. package/dist/observe-repository.d.ts.map +1 -0
  137. package/dist/observe-repository.js +1367 -0
  138. package/dist/observe-repository.js.map +1 -0
  139. package/dist/plan-bundle-setup-fixture.d.ts +68 -0
  140. package/dist/plan-bundle-setup-fixture.d.ts.map +1 -0
  141. package/dist/plan-bundle-setup-fixture.js +167 -0
  142. package/dist/plan-bundle-setup-fixture.js.map +1 -0
  143. package/dist/plan-bundle.d.ts +250 -0
  144. package/dist/plan-bundle.d.ts.map +1 -0
  145. package/dist/plan-bundle.js +827 -0
  146. package/dist/plan-bundle.js.map +1 -0
  147. package/dist/plan-command.d.ts +29 -0
  148. package/dist/plan-command.d.ts.map +1 -0
  149. package/dist/plan-command.js +493 -0
  150. package/dist/plan-command.js.map +1 -0
  151. package/dist/plan-contract.d.ts +153 -0
  152. package/dist/plan-contract.d.ts.map +1 -0
  153. package/dist/plan-contract.js +61 -0
  154. package/dist/plan-contract.js.map +1 -0
  155. package/dist/plan-digest.d.ts +25 -0
  156. package/dist/plan-digest.d.ts.map +1 -0
  157. package/dist/plan-digest.js +106 -0
  158. package/dist/plan-digest.js.map +1 -0
  159. package/dist/plan-rules.d.ts +23 -0
  160. package/dist/plan-rules.d.ts.map +1 -0
  161. package/dist/plan-rules.js +177 -0
  162. package/dist/plan-rules.js.map +1 -0
  163. package/dist/planned-bundle.d.ts +20 -0
  164. package/dist/planned-bundle.d.ts.map +1 -0
  165. package/dist/planned-bundle.js +191 -0
  166. package/dist/planned-bundle.js.map +1 -0
  167. package/dist/product-repository.d.ts +4 -0
  168. package/dist/product-repository.d.ts.map +1 -1
  169. package/dist/product-repository.js +9 -1
  170. package/dist/product-repository.js.map +1 -1
  171. package/dist/provenance-gate.d.ts +48 -0
  172. package/dist/provenance-gate.d.ts.map +1 -0
  173. package/dist/provenance-gate.js +324 -0
  174. package/dist/provenance-gate.js.map +1 -0
  175. package/dist/pull-request-body.d.ts +45 -0
  176. package/dist/pull-request-body.d.ts.map +1 -0
  177. package/dist/pull-request-body.js +232 -0
  178. package/dist/pull-request-body.js.map +1 -0
  179. package/dist/registry-snapshot.d.ts +141 -0
  180. package/dist/registry-snapshot.d.ts.map +1 -0
  181. package/dist/registry-snapshot.js +483 -0
  182. package/dist/registry-snapshot.js.map +1 -0
  183. package/dist/release-age-edit.d.ts +52 -0
  184. package/dist/release-age-edit.d.ts.map +1 -0
  185. package/dist/release-age-edit.js +413 -0
  186. package/dist/release-age-edit.js.map +1 -0
  187. package/dist/root-entries.d.ts +36 -0
  188. package/dist/root-entries.d.ts.map +1 -0
  189. package/dist/root-entries.js +80 -0
  190. package/dist/root-entries.js.map +1 -0
  191. package/dist/setup-template-scripts.d.ts +34 -0
  192. package/dist/setup-template-scripts.d.ts.map +1 -0
  193. package/dist/setup-template-scripts.js +557 -0
  194. package/dist/setup-template-scripts.js.map +1 -0
  195. package/dist/setup-templates.d.ts +54 -0
  196. package/dist/setup-templates.d.ts.map +1 -0
  197. package/dist/setup-templates.js +427 -0
  198. package/dist/setup-templates.js.map +1 -0
  199. package/dist/skills.d.ts +34 -1
  200. package/dist/skills.d.ts.map +1 -1
  201. package/dist/skills.js +129 -17
  202. package/dist/skills.js.map +1 -1
  203. package/dist/status.d.ts +63 -0
  204. package/dist/status.d.ts.map +1 -0
  205. package/dist/status.js +539 -0
  206. package/dist/status.js.map +1 -0
  207. package/dist/types.d.ts +151 -13
  208. package/dist/types.d.ts.map +1 -1
  209. package/package.json +4 -5
  210. package/skeleton/README.md +14 -9
  211. package/skeleton/package.json +2 -1
  212. package/skill/SKILL.md +20 -16
  213. package/skill-catalogue/advisor/SKILL.md +100 -11
  214. package/skill-catalogue/architect/SKILL.md +2 -13
  215. package/skill-catalogue/bouncer/SKILL.md +2 -13
  216. package/skill-catalogue/builder/SKILL.md +2 -13
  217. package/skill-catalogue/butler/SKILL.md +2 -13
  218. package/skill-catalogue/controller/SKILL.md +2 -13
  219. package/skill-catalogue/customer/SKILL.md +4 -13
  220. package/skill-catalogue/designer/SKILL.md +9 -14
  221. package/skill-catalogue/giver/SKILL.md +2 -13
  222. package/skill-catalogue/influencer/SKILL.md +2 -13
  223. package/skill-catalogue/inspector/SKILL.md +2 -13
  224. package/skill-catalogue/integrator/SKILL.md +2 -13
  225. package/skill-catalogue/keeper/SKILL.md +2 -13
  226. package/skill-catalogue/launcher/SKILL.md +20 -16
  227. package/skill-catalogue/locksmith/SKILL.md +2 -13
  228. package/skill-catalogue/messenger/SKILL.md +2 -13
  229. package/skill-catalogue/observer/SKILL.md +2 -13
  230. package/skill-catalogue/publisher/SKILL.md +11 -17
  231. package/skill-catalogue/starter/SKILL.md +3 -13
  232. package/skill-catalogue/strategist/SKILL.md +14 -17
  233. package/skill-catalogue/writer/SKILL.md +7 -14
  234. package/src/admission-fixture.ts +572 -0
  235. package/src/admission.ts +816 -0
  236. package/src/agents-guide.ts +29 -0
  237. package/src/apply-command-options.check.ts +27 -0
  238. package/src/apply-plan-cli.ts +454 -14
  239. package/src/apply-plan.ts +112 -124
  240. package/src/apply-step-fixture.ts +236 -0
  241. package/src/apply-store.ts +584 -0
  242. package/src/approval-sheet.ts +164 -0
  243. package/src/body-command.ts +162 -0
  244. package/src/change-set-contract.ts +937 -0
  245. package/src/change-set-digest.ts +70 -0
  246. package/src/check-cli.ts +14 -3
  247. package/src/cli.ts +90 -22
  248. package/src/core.ts +973 -275
  249. package/src/dry-materialize.ts +353 -0
  250. package/src/generated/contract-schema.generated.ts +520 -0
  251. package/src/generated/package-scope.generated.ts +10 -0
  252. package/src/generated/plan-contracts.generated.ts +2840 -0
  253. package/src/host.ts +10 -0
  254. package/src/identity.ts +51 -0
  255. package/src/index.ts +72 -3
  256. package/src/inventory-adoption.ts +107 -29
  257. package/src/inventory-choice.ts +172 -0
  258. package/src/inventory-contract.ts +166 -0
  259. package/src/key-editor.ts +446 -0
  260. package/src/ledger-contract.ts +637 -0
  261. package/src/ledger-trust.ts +267 -0
  262. package/src/lockfile-invariants.ts +421 -0
  263. package/src/lockfile-readers.ts +749 -0
  264. package/src/lockfile-regen.ts +851 -0
  265. package/src/lockfile-tool-env.ts +131 -0
  266. package/src/materialize.ts +886 -0
  267. package/src/observe-repository.ts +1365 -0
  268. package/src/plan-bundle-setup-fixture.ts +200 -0
  269. package/src/plan-bundle.ts +964 -0
  270. package/src/plan-command.ts +509 -0
  271. package/src/plan-contract.ts +179 -0
  272. package/src/plan-digest.ts +102 -0
  273. package/src/plan-rules.ts +188 -0
  274. package/src/planned-bundle.ts +211 -0
  275. package/src/product-repository.ts +10 -1
  276. package/src/provenance-gate.ts +352 -0
  277. package/src/pull-request-body.ts +261 -0
  278. package/src/registry-snapshot.ts +534 -0
  279. package/src/release-age-edit.ts +430 -0
  280. package/src/root-entries.ts +81 -0
  281. package/src/setup-template-scripts.ts +571 -0
  282. package/src/setup-templates.ts +471 -0
  283. package/src/skills.ts +161 -18
  284. package/src/status.ts +557 -0
  285. package/src/types.ts +148 -13
  286. package/CHANGELOG.md +0 -131
@@ -0,0 +1,166 @@
1
+ // The hub repository inventory (issues #996, #1334 and #1179), validated
2
+ // against the shared contract docs/contracts/repository-inventory.json (in
3
+ // the public repository; that exact path does not ship in this package,
4
+ // but this package's build packs and ships its own copy of the contract).
5
+ // This package's build packs that file into src/generated/ beside the plan
6
+ // and brief contracts, and checks it with the generated copy of the one
7
+ // contract checker @clossys/advisor also uses -- so an inventory Launcher
8
+ // writes, an inventory Launcher reads back, and a repository id Advisor
9
+ // offers on its repository-choice card are all judged by one definition.
10
+ //
11
+ // Refusals follow the checker's conventions: they name a field by its
12
+ // position (for example `repositories[2].id`), never quote a repository id
13
+ // or any other value from the document, and report a JSON syntax error by
14
+ // position only.
15
+
16
+ import { readContractDocument, validateAgainstContract } from "./generated/contract-schema.generated.js";
17
+ import type { ContractViolation } from "./generated/contract-schema.generated.js";
18
+ import { inventoryKey } from "./identity.js";
19
+ import { loadContract } from "./plan-contract.js";
20
+
21
+ /** The contract's file name, as packed. */
22
+ export const INVENTORY_CONTRACT = "repository-inventory.json";
23
+
24
+ /** Points a refusal at the one place the shape is written down. */
25
+ export const INVENTORY_CONTRACT_POINTER = "see docs/contracts/repository-inventory.json";
26
+
27
+ /**
28
+ * The result of checking one inventory document: its repository ids, in
29
+ * document order, or the reason it is refused. `reason` names every field
30
+ * at fault, separated by `; `, and ends with a pointer to the contract.
31
+ */
32
+ export type InventoryValidation =
33
+ | { readonly valid: true; readonly ids: readonly string[] }
34
+ | { readonly valid: false; readonly reason: string };
35
+
36
+ /** One inventory entry, exactly as the contract allows it. */
37
+ export interface InventoryEntry {
38
+ readonly id: string;
39
+ readonly packages?: readonly { readonly name: string; readonly version?: string; readonly wiring?: string }[];
40
+ }
41
+
42
+ function describeViolation(violation: ContractViolation): string {
43
+ return violation.path === "" ? violation.message : `${violation.path} ${violation.message}`;
44
+ }
45
+
46
+ /**
47
+ * The first id, if any, that names the same repository as an earlier one
48
+ * under `inventoryKey()`: its position and the earlier position.
49
+ */
50
+ export function firstRepeatedRepository(
51
+ ids: readonly string[],
52
+ hubOwner?: string,
53
+ ): { readonly index: number; readonly earlier: number } | undefined {
54
+ const seen = new Map<string, number>();
55
+ for (const [index, id] of ids.entries()) {
56
+ const key = inventoryKey(id, hubOwner);
57
+ const earlier = seen.get(key);
58
+ if (earlier !== undefined) return { index, earlier };
59
+ seen.set(key, index);
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ /** Options for reading an inventory document. */
65
+ export interface InventoryReadOptions {
66
+ /**
67
+ * The hub's owner, whenever the caller knows it. A bare id and
68
+ * `<hubOwner>/<id>` then name the same repository, so a document listing
69
+ * both is refused instead of carrying two entries for one repository.
70
+ */
71
+ readonly hubOwner?: string;
72
+ }
73
+
74
+ /** A valid document's entries, as internal readers need them; `InventoryValidation` exposes only the ids. */
75
+ export type InventoryRead =
76
+ | { readonly valid: true; readonly ids: readonly string[]; readonly entries: readonly InventoryEntry[] }
77
+ | { readonly valid: false; readonly reason: string };
78
+
79
+ function checkInventoryValue(value: unknown, options: InventoryReadOptions): InventoryRead {
80
+ const violations = validateAgainstContract(loadContract(INVENTORY_CONTRACT), value, loadContract);
81
+ if (violations.length > 0) {
82
+ return { valid: false, reason: `${violations.map(describeViolation).join("; ")} (${INVENTORY_CONTRACT_POINTER})` };
83
+ }
84
+ const entries = (value as { readonly repositories: readonly InventoryEntry[] }).repositories;
85
+ const ids = entries.map((entry) => entry.id);
86
+ const repeated = firstRepeatedRepository(ids, options.hubOwner);
87
+ if (repeated !== undefined) {
88
+ const rule =
89
+ options.hubOwner === undefined
90
+ ? "repository ids are compared case-insensitively"
91
+ : "repository ids are compared case-insensitively, and a bare id names a repository of the hub's own owner";
92
+ return {
93
+ valid: false,
94
+ reason: `repositories[${repeated.index}].id names the same repository as repositories[${repeated.earlier}].id (${rule}; ${INVENTORY_CONTRACT_POINTER})`,
95
+ };
96
+ }
97
+ return { valid: true, ids, entries };
98
+ }
99
+
100
+ function withoutEntries(read: InventoryRead): InventoryValidation {
101
+ return read.valid ? { valid: true, ids: read.ids } : read;
102
+ }
103
+
104
+ /**
105
+ * Validates an already-parsed value against the inventory contract through
106
+ * the shared checker, then applies the duplicate rule. Never throws for a
107
+ * bad value.
108
+ */
109
+ export function validateInventoryValue(value: unknown, options: InventoryReadOptions = {}): InventoryValidation {
110
+ return withoutEntries(checkInventoryValue(value, options));
111
+ }
112
+
113
+ /** A lone surrogate in a JS string, which has no UTF-8 encoding; TextEncoder would silently replace it. */
114
+ const LONE_SURROGATE = /[\uD800-\uDFFF]/u;
115
+
116
+ /**
117
+ * Reads and checks one inventory document. Bytes -- as every file read
118
+ * passes them, from `WorkspaceHost.readBytes()` -- go straight to the
119
+ * shared strict reader, so bytes that are not valid UTF-8 are refused
120
+ * rather than silently replaced. Text is accepted for a document built in
121
+ * memory, and one holding a lone surrogate is refused before encoding.
122
+ */
123
+ export function readInventoryDocument(raw: string | Uint8Array, options: InventoryReadOptions = {}): InventoryRead {
124
+ if (typeof raw === "string" && LONE_SURROGATE.test(raw)) {
125
+ return { valid: false, reason: `is not well-formed Unicode: it contains a lone surrogate, which has no UTF-8 encoding (${INVENTORY_CONTRACT_POINTER})` };
126
+ }
127
+ let value: unknown;
128
+ try {
129
+ value = readContractDocument(typeof raw === "string" ? new TextEncoder().encode(raw) : raw);
130
+ } catch (cause) {
131
+ return { valid: false, reason: `${cause instanceof Error ? cause.message : String(cause)} (${INVENTORY_CONTRACT_POINTER})` };
132
+ }
133
+ return checkInventoryValue(value, options);
134
+ }
135
+
136
+ /**
137
+ * Strictly validates an inventory document against
138
+ * docs/contracts/repository-inventory.json. It is read as strict JSON by the
139
+ * shared reader -- bytes that are not valid UTF-8, a syntax error (reported
140
+ * by position only), a key repeated in any object, or a leading byte order
141
+ * mark is refused -- and the value is then checked by the shared contract
142
+ * checker and the duplicate rule. Every read of an inventory file passes
143
+ * its exact bytes: `--inventory`, the stored `clossys/.state/inventory.json`
144
+ * on every run, `readInventoryRepositories()`, and the document
145
+ * `launcher --repositories` writes, before it writes it. The contract's
146
+ * source path does not ship in this package -- this package's build packs
147
+ * and ships its own copy of it -- and the stored inventory path is
148
+ * generated on the hub at runtime and is not part of this package at all.
149
+ */
150
+ export function validateInventoryDocument(raw: string | Uint8Array, options: InventoryReadOptions = {}): InventoryValidation {
151
+ return withoutEntries(readInventoryDocument(raw, options));
152
+ }
153
+
154
+ /**
155
+ * Whether one id satisfies the contract's `definitions/repositoryId` -- a
156
+ * bare repository name or `owner/name`, by the same GitHub owner and
157
+ * repository rules Launcher applies when it resolves a sibling clone.
158
+ */
159
+ export function isValidInventoryId(id: string): boolean {
160
+ return validateAgainstContract({ $ref: `${INVENTORY_CONTRACT}#/definitions/repositoryId` }, id, loadContract).length === 0;
161
+ }
162
+
163
+ /** The inventory document text Launcher writes for these entries: two-space JSON and a final newline. */
164
+ export function renderInventoryDocument(entries: readonly InventoryEntry[]): string {
165
+ return `${JSON.stringify({ schemaVersion: 1, repositories: entries }, null, 2)}\n`;
166
+ }
@@ -0,0 +1,446 @@
1
+ // A JSON-pointer edit that keeps the file's indent, key order, and final
2
+ // newline (RFC apply-approved-plan, the keyed writes in section 4.3 and the
3
+ // declare-root-entry edit in D27). package.json keys and a Controller
4
+ // profile's rootEntries go through this one editor. After the edit, the
5
+ // text is parsed and rendered again; the two renders are compared byte for
6
+ // byte, and a difference is refused rather than written.
7
+ //
8
+ // This module is pure: it reads no file, clock, network, or process.
9
+
10
+ /** One JSON-pointer edit. `remove` deletes the value the pointer addresses; otherwise `value` is written there. */
11
+ export interface JsonPointerEdit {
12
+ readonly pointer: string;
13
+ readonly value?: unknown;
14
+ readonly remove?: boolean;
15
+ }
16
+
17
+ interface JsonStyle {
18
+ readonly indent: string;
19
+ readonly newline: string;
20
+ readonly finalNewline: boolean;
21
+ /** The characters between `:` and the value. A pretty file uses one space; a minified file uses none. */
22
+ readonly colon: string;
23
+ }
24
+
25
+ /** What `valueAtJsonPointer` found. `found` is false when any token along the pointer is absent. */
26
+ export interface JsonPointerValue {
27
+ readonly found: boolean;
28
+ readonly value: unknown;
29
+ }
30
+
31
+ /** Thrown when an edit would rewrite bytes outside the edited tokens. */
32
+ export class JsonEditUnstableError extends TypeError {
33
+ constructor() {
34
+ super("a json pointer edit did not preserve unchanged bytes");
35
+ }
36
+ }
37
+
38
+ function decodeToken(token: string): string {
39
+ return token.replace(/~1/g, "/").replace(/~0/g, "~");
40
+ }
41
+
42
+ function tokensOf(pointer: string): string[] {
43
+ if (pointer === "") return [];
44
+ if (!pointer.startsWith("/")) throw new TypeError("a json pointer does not start with /");
45
+ return pointer.split("/").slice(1).map(decodeToken);
46
+ }
47
+
48
+ function encodeToken(token: string): string {
49
+ return token.replace(/~/g, "~0").replace(/\//g, "~1");
50
+ }
51
+
52
+ function pointerFromTokens(tokens: readonly string[]): string {
53
+ if (tokens.length === 0) return "";
54
+ return `/${tokens.map(encodeToken).join("/")}`;
55
+ }
56
+
57
+ type ValueSpan = { readonly start: number; readonly end: number };
58
+
59
+ type ObjectEntry = { readonly key: string; readonly keyRaw: string; readonly valuePointer: string };
60
+
61
+ function skipWs(text: string, index: number): number {
62
+ while (index < text.length && /\s/u.test(text[index]!)) index += 1;
63
+ return index;
64
+ }
65
+
66
+ function parseString(text: string, index: number): { end: number; value: string } {
67
+ if (text[index] !== '"') throw new TypeError("a json pointer edit was given text that is not JSON");
68
+ index += 1;
69
+ let value = "";
70
+ while (index < text.length) {
71
+ const ch = text[index]!;
72
+ if (ch === '"') return { end: index + 1, value };
73
+ if (ch === "\\") {
74
+ index += 1;
75
+ if (index >= text.length) throw new TypeError("a json pointer edit was given text that is not JSON");
76
+ value += text[index]!;
77
+ index += 1;
78
+ continue;
79
+ }
80
+ value += ch;
81
+ index += 1;
82
+ }
83
+ throw new TypeError("a json pointer edit was given text that is not JSON");
84
+ }
85
+
86
+ function parseLiteral(text: string, index: number): { end: number; value: unknown } {
87
+ const start = index;
88
+ if (text.startsWith("true", index)) return { end: index + 4, value: true };
89
+ if (text.startsWith("false", index)) return { end: index + 5, value: false };
90
+ if (text.startsWith("null", index)) return { end: index + 4, value: null };
91
+ if (text[index] === "-") index += 1;
92
+ while (index < text.length && /[0-9.eE+-]/u.test(text[index]!)) index += 1;
93
+ if (index === start || (text[start] === "-" && index === start + 1)) {
94
+ throw new TypeError("a json pointer edit was given text that is not JSON");
95
+ }
96
+ const slice = text.slice(start, index);
97
+ const value = JSON.parse(slice) as unknown;
98
+ return { end: index, value };
99
+ }
100
+
101
+ function assertStableObjectKey(keyRaw: string, key: string): void {
102
+ if (keyRaw !== JSON.stringify(key)) throw new JsonEditUnstableError();
103
+ }
104
+
105
+ function indexJsonSpans(text: string): { value: unknown; spans: Map<string, ValueSpan>; objectEntries: Map<string, readonly ObjectEntry[]> } {
106
+ const spans = new Map<string, ValueSpan>();
107
+ const objectEntries = new Map<string, readonly ObjectEntry[]>();
108
+ function parseValue(index: number, pointer: string): { end: number; value: unknown } {
109
+ index = skipWs(text, index);
110
+ const start = index;
111
+ const ch = text[index];
112
+ if (ch === '"') {
113
+ const parsed = parseString(text, index);
114
+ spans.set(pointer, { start, end: parsed.end });
115
+ return { end: parsed.end, value: parsed.value };
116
+ }
117
+ if (ch === "{") {
118
+ index += 1;
119
+ const record: Record<string, unknown> = {};
120
+ index = skipWs(text, index);
121
+ if (text[index] === "}") {
122
+ spans.set(pointer, { start, end: index + 1 });
123
+ objectEntries.set(pointer, []);
124
+ return { end: index + 1, value: record };
125
+ }
126
+ const entries: ObjectEntry[] = [];
127
+ while (index < text.length) {
128
+ index = skipWs(text, index);
129
+ const keyStart = index;
130
+ const keyParsed = parseString(text, index);
131
+ assertStableObjectKey(text.slice(keyStart, keyParsed.end), keyParsed.value);
132
+ if (Object.hasOwn(record, keyParsed.value)) throw new JsonEditUnstableError();
133
+ index = keyParsed.end;
134
+ index = skipWs(text, index);
135
+ if (text[index] !== ":") throw new TypeError("a json pointer edit was given text that is not JSON");
136
+ index += 1;
137
+ const valuePointer = pointerFromTokens([...tokensOf(pointer), keyParsed.value]);
138
+ const child = parseValue(index, valuePointer);
139
+ index = child.end;
140
+ entries.push({ key: keyParsed.value, keyRaw: text.slice(keyStart, keyParsed.end), valuePointer });
141
+ record[keyParsed.value] = child.value;
142
+ index = skipWs(text, index);
143
+ if (text[index] === ",") {
144
+ index += 1;
145
+ continue;
146
+ }
147
+ if (text[index] === "}") {
148
+ spans.set(pointer, { start, end: index + 1 });
149
+ objectEntries.set(pointer, entries);
150
+ return { end: index + 1, value: record };
151
+ }
152
+ throw new TypeError("a json pointer edit was given text that is not JSON");
153
+ }
154
+ throw new TypeError("a json pointer edit was given text that is not JSON");
155
+ }
156
+ if (ch === "[") {
157
+ index += 1;
158
+ const array: unknown[] = [];
159
+ index = skipWs(text, index);
160
+ if (text[index] === "]") {
161
+ spans.set(pointer, { start, end: index + 1 });
162
+ return { end: index + 1, value: array };
163
+ }
164
+ let position = 0;
165
+ while (index < text.length) {
166
+ const child = parseValue(index, pointerFromTokens([...tokensOf(pointer), String(position)]));
167
+ index = child.end;
168
+ array.push(child.value);
169
+ position += 1;
170
+ index = skipWs(text, index);
171
+ if (text[index] === ",") {
172
+ index += 1;
173
+ continue;
174
+ }
175
+ if (text[index] === "]") {
176
+ spans.set(pointer, { start, end: index + 1 });
177
+ return { end: index + 1, value: array };
178
+ }
179
+ throw new TypeError("a json pointer edit was given text that is not JSON");
180
+ }
181
+ throw new TypeError("a json pointer edit was given text that is not JSON");
182
+ }
183
+ const literal = parseLiteral(text, index);
184
+ spans.set(pointer, { start, end: literal.end });
185
+ return { end: literal.end, value: literal.value };
186
+ }
187
+ const parsed = parseValue(0, "");
188
+ if (skipWs(text, parsed.end) !== text.length) throw new TypeError("a json pointer edit was given text that is not JSON");
189
+ return { value: parsed.value, spans, objectEntries };
190
+ }
191
+
192
+ function editTargets(edits: readonly JsonPointerEdit[]): Set<string> {
193
+ const targets = new Set<string>();
194
+ for (const edit of edits) {
195
+ const tokens = tokensOf(edit.pointer);
196
+ if (tokens.length === 0) {
197
+ targets.add("");
198
+ continue;
199
+ }
200
+ const last = tokens[tokens.length - 1]!;
201
+ if (last === "-") targets.add(pointerFromTokens(tokens.slice(0, -1)));
202
+ else targets.add(edit.pointer);
203
+ }
204
+ return targets;
205
+ }
206
+
207
+ function subtreeModified(pointer: string, modified: ReadonlySet<string>): boolean {
208
+ if (modified.has(pointer)) return true;
209
+ const prefix = pointer === "" ? "/" : `${pointer}/`;
210
+ for (const target of modified) {
211
+ if (target.startsWith(prefix)) return true;
212
+ }
213
+ return false;
214
+ }
215
+
216
+ function renderPreserving(
217
+ text: string,
218
+ value: unknown,
219
+ pointer: string,
220
+ style: JsonStyle,
221
+ depth: number,
222
+ spans: ReadonlyMap<string, ValueSpan>,
223
+ modified: ReadonlySet<string>,
224
+ objectEntries: ReadonlyMap<string, readonly ObjectEntry[]>,
225
+ ): string {
226
+ if (!subtreeModified(pointer, modified)) {
227
+ const span = spans.get(pointer);
228
+ if (span !== undefined) return text.slice(span.start, span.end);
229
+ }
230
+ if (value === null) return "null";
231
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return JSON.stringify(value);
232
+ if (Array.isArray(value)) {
233
+ if (value.length === 0) return "[]";
234
+ const rendered = value.map((entry, index) =>
235
+ renderPreserving(text, entry, pointerFromTokens([...tokensOf(pointer), String(index)]), style, depth + 1, spans, modified, objectEntries),
236
+ );
237
+ if (style.indent === "") return `[${rendered.join(",")}]`;
238
+ const pad = style.indent.repeat(depth + 1);
239
+ const inner = rendered.map((entry) => `${pad}${entry}`).join(`,${style.newline}`);
240
+ return `[${style.newline}${inner}${style.newline}${style.indent.repeat(depth)}]`;
241
+ }
242
+ if (typeof value === "object") {
243
+ const record = value as Record<string, unknown>;
244
+ const keys = Object.keys(record);
245
+ if (keys.length === 0) return "{}";
246
+ const sourceEntries = objectEntries.get(pointer);
247
+ const rendered =
248
+ sourceEntries === undefined
249
+ ? keys.map((key) => {
250
+ const childPointer = pointerFromTokens([...tokensOf(pointer), key]);
251
+ return `${JSON.stringify(key)}:${style.colon}${renderPreserving(text, record[key], childPointer, style, depth + 1, spans, modified, objectEntries)}`;
252
+ })
253
+ : (() => {
254
+ const written = new Set<string>();
255
+ const parts: string[] = [];
256
+ for (const entry of sourceEntries) {
257
+ if (!Object.hasOwn(record, entry.key)) continue;
258
+ written.add(entry.key);
259
+ parts.push(
260
+ `${entry.keyRaw}:${style.colon}${renderPreserving(text, record[entry.key], entry.valuePointer, style, depth + 1, spans, modified, objectEntries)}`,
261
+ );
262
+ }
263
+ for (const key of keys) {
264
+ if (written.has(key)) continue;
265
+ const childPointer = pointerFromTokens([...tokensOf(pointer), key]);
266
+ parts.push(`${JSON.stringify(key)}:${style.colon}${renderPreserving(text, record[key], childPointer, style, depth + 1, spans, modified, objectEntries)}`);
267
+ }
268
+ return parts;
269
+ })();
270
+ if (style.indent === "") return `{${rendered.join(",")}}`;
271
+ const pad = style.indent.repeat(depth + 1);
272
+ const inner = rendered.map((entry) => `${pad}${entry}`).join(`,${style.newline}`);
273
+ return `{${style.newline}${inner}${style.newline}${style.indent.repeat(depth)}}`;
274
+ }
275
+ throw new TypeError("a json pointer edit was given a value JSON cannot render");
276
+ }
277
+
278
+ function unchangedBytesPreserved(original: string, edited: string, spans: ReadonlyMap<string, ValueSpan>, modified: ReadonlySet<string>): boolean {
279
+ for (const [pointer, span] of spans) {
280
+ if (subtreeModified(pointer, modified)) continue;
281
+ const slice = original.slice(span.start, span.end);
282
+ if (!edited.includes(slice)) return false;
283
+ }
284
+ return true;
285
+ }
286
+
287
+ function detectStyle(text: string): JsonStyle {
288
+ const newline = text.includes("\r\n") ? "\r\n" : "\n";
289
+ const escaped = newline === "\r\n" ? "\\r\\n" : "\\n";
290
+ const indent = new RegExp(`${escaped}([ \\t]+)\\S`, "u").exec(text)?.[1] ?? "";
291
+ return { indent, newline, finalNewline: text.endsWith("\n"), colon: /:[ \t]/u.test(text) ? " " : "" };
292
+ }
293
+
294
+ function renderValue(value: unknown, style: JsonStyle, depth: number): string {
295
+ if (value === null) return "null";
296
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return JSON.stringify(value);
297
+ if (Array.isArray(value)) {
298
+ if (value.length === 0) return "[]";
299
+ const rendered = value.map((entry) => renderValue(entry, style, depth + 1));
300
+ if (style.indent === "") return `[${rendered.join(",")}]`;
301
+ const pad = style.indent.repeat(depth + 1);
302
+ const inner = rendered.map((entry) => `${pad}${entry}`).join(`,${style.newline}`);
303
+ return `[${style.newline}${inner}${style.newline}${style.indent.repeat(depth)}]`;
304
+ }
305
+ if (typeof value === "object") {
306
+ const record = value as Record<string, unknown>;
307
+ const keys = Object.keys(record);
308
+ if (keys.length === 0) return "{}";
309
+ const rendered = keys.map((key) => `${JSON.stringify(key)}:${style.colon}${renderValue(record[key], style, depth + 1)}`);
310
+ if (style.indent === "") return `{${rendered.join(",")}}`;
311
+ const pad = style.indent.repeat(depth + 1);
312
+ const inner = rendered.map((entry) => `${pad}${entry}`).join(`,${style.newline}`);
313
+ return `{${style.newline}${inner}${style.newline}${style.indent.repeat(depth)}}`;
314
+ }
315
+ throw new TypeError("a json pointer edit was given a value JSON cannot render");
316
+ }
317
+
318
+ function finish(body: string, style: JsonStyle): string {
319
+ if (style.finalNewline) return body.endsWith("\n") ? body : `${body}${style.newline}`;
320
+ return body.replace(/\r?\n$/u, "");
321
+ }
322
+
323
+ function isIndex(token: string): boolean {
324
+ return /^(?:0|[1-9]\d*)$/u.test(token);
325
+ }
326
+
327
+ function containerFor(next: string): [] | Record<string, unknown> {
328
+ return next === "-" || isIndex(next) ? [] : {};
329
+ }
330
+
331
+ function childOf(parent: unknown, token: string, next: string): unknown {
332
+ if (Array.isArray(parent)) {
333
+ if (!isIndex(token)) throw new TypeError("a json pointer does not address an array index");
334
+ const index = Number(token);
335
+ const current = parent[index];
336
+ if (current === undefined) {
337
+ const created = containerFor(next);
338
+ parent[index] = created;
339
+ return created;
340
+ }
341
+ return current;
342
+ }
343
+ if (parent === null || typeof parent !== "object") throw new TypeError("a json pointer does not address an object");
344
+ const record = parent as Record<string, unknown>;
345
+ if (!Object.hasOwn(record, token) || record[token] === undefined) {
346
+ const created = containerFor(next);
347
+ record[token] = created;
348
+ return created;
349
+ }
350
+ return record[token];
351
+ }
352
+
353
+ function assign(parent: unknown, token: string, edit: JsonPointerEdit): void {
354
+ if (Array.isArray(parent)) {
355
+ if (token === "-") {
356
+ if (edit.remove === true) throw new TypeError("a json pointer cannot remove an append");
357
+ parent.push(edit.value);
358
+ return;
359
+ }
360
+ if (!isIndex(token)) throw new TypeError("a json pointer does not address an array index");
361
+ const index = Number(token);
362
+ if (!Object.hasOwn(parent, index)) throw new TypeError("a json pointer does not address an array index");
363
+ if (edit.remove === true) parent.splice(index, 1);
364
+ else parent[index] = edit.value;
365
+ return;
366
+ }
367
+ if (parent === null || typeof parent !== "object") throw new TypeError("a json pointer does not address an object");
368
+ const record = parent as Record<string, unknown>;
369
+ if (edit.remove === true) delete record[token];
370
+ else record[token] = edit.value;
371
+ }
372
+
373
+ function applyOne(root: unknown, edit: JsonPointerEdit): unknown {
374
+ const tokens = tokensOf(edit.pointer);
375
+ if (tokens.length === 0) {
376
+ if (edit.remove === true) throw new TypeError("a json pointer edit cannot remove the whole document");
377
+ return edit.value;
378
+ }
379
+ if (root === null || typeof root !== "object") throw new TypeError("a json pointer does not address an object");
380
+ let parent: unknown = root;
381
+ for (let index = 0; index < tokens.length - 1; index += 1) {
382
+ parent = childOf(parent, tokens[index]!, tokens[index + 1] ?? "");
383
+ }
384
+ assign(parent, tokens[tokens.length - 1]!, edit);
385
+ return root;
386
+ }
387
+
388
+ /**
389
+ * The value a JSON pointer addresses in `text`, or `{ found: false }` when
390
+ * a token along the pointer is absent. The text must be JSON; a parse
391
+ * failure throws a TypeError that does not quote the text.
392
+ */
393
+ export function valueAtJsonPointer(text: string, pointer: string): JsonPointerValue {
394
+ let root: unknown;
395
+ try {
396
+ root = JSON.parse(text);
397
+ } catch {
398
+ throw new TypeError("a json pointer was given text that is not JSON");
399
+ }
400
+ const tokens = tokensOf(pointer);
401
+ let current = root;
402
+ for (const token of tokens) {
403
+ if (Array.isArray(current)) {
404
+ if (!isIndex(token) || !Object.hasOwn(current, Number(token))) return { found: false, value: undefined };
405
+ current = current[Number(token)];
406
+ continue;
407
+ }
408
+ if (current === null || typeof current !== "object" || !Object.hasOwn(current, token)) return { found: false, value: undefined };
409
+ current = (current as Record<string, unknown>)[token];
410
+ }
411
+ return { found: true, value: current };
412
+ }
413
+
414
+ /**
415
+ * Applies `edits` to `text` and returns the edited document. An empty edit
416
+ * list returns `text` unchanged. Indent, the order of existing keys, and
417
+ * whether the text ended in a newline are taken from `text` and used for
418
+ * the render. The render is parsed and rendered a second time, and the two
419
+ * results must be the same bytes.
420
+ */
421
+ export function editJsonPointer(text: string, edits: readonly JsonPointerEdit[]): string {
422
+ if (edits.length === 0) return text;
423
+ let indexed: { value: unknown; spans: Map<string, ValueSpan>; objectEntries: Map<string, readonly ObjectEntry[]> };
424
+ try {
425
+ indexed = indexJsonSpans(text);
426
+ } catch (cause) {
427
+ if (cause instanceof JsonEditUnstableError) throw cause;
428
+ throw new TypeError("a json pointer edit was given text that is not JSON");
429
+ }
430
+ let root = indexed.value;
431
+ for (const edit of edits) root = applyOne(root, edit);
432
+ const modified = editTargets(edits);
433
+ const style = detectStyle(text);
434
+ const rendered = finish(renderPreserving(text, root, "", style, 0, indexed.spans, modified, indexed.objectEntries), style);
435
+ if (!unchangedBytesPreserved(text, rendered, indexed.spans, modified)) throw new JsonEditUnstableError();
436
+ let again: string;
437
+ try {
438
+ const reread = indexJsonSpans(rendered);
439
+ again = finish(renderPreserving(rendered, reread.value, "", style, 0, reread.spans, new Set(), reread.objectEntries), style);
440
+ } catch (cause) {
441
+ if (cause instanceof JsonEditUnstableError) throw cause;
442
+ throw new JsonEditUnstableError();
443
+ }
444
+ if (rendered !== again) throw new JsonEditUnstableError();
445
+ return rendered;
446
+ }