@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,738 @@
1
+ // The repository change set and the apply bundle (issue #1178), validated
2
+ // against the shared contracts docs/contracts/repository-change-set.json and
3
+ // apply-bundle.json -- in the public repository, not shipped in this
4
+ // package. This package's build packs both into src/generated/ beside the
5
+ // plan and brief contracts, and validates them with the same generated copy
6
+ // of the one contract checker. The TypeScript types below describe the same
7
+ // shapes for callers; they validate nothing.
8
+ import { createHash } from "node:crypto";
9
+ import { formatContractViolation, validateAgainstContract } from "./generated/contract-schema.generated.js";
10
+ import { PACKAGE_SCOPE } from "./generated/package-scope.generated.js";
11
+ import { bundleDigest, changeSetDigest } from "./change-set-digest.js";
12
+ import { AGENTS_GUIDE_PATH } from "./agents-guide.js";
13
+ import { loadContract } from "./plan-contract.js";
14
+ /** The path of the installed-state ledger every change set writes. */
15
+ export const LEDGER_PATH = "clossys/.state/installed.json";
16
+ /** Where the engagement brief is written. */
17
+ export const BRIEF_PATH = "clossys/brief.json";
18
+ /** Every file name a lockfile can have. */
19
+ export const LOCKFILE_NAMES = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
20
+ const DEFAULT_LOCKFILE = {
21
+ npm: "package-lock.json",
22
+ pnpm: "pnpm-lock.yaml",
23
+ yarn: "yarn.lock",
24
+ };
25
+ /**
26
+ * The repository's lockfile path, as the change-set contract defines it: the
27
+ * observed lockfile, or, when there is none, the one its package manager
28
+ * writes; null when the repository has no package manager.
29
+ */
30
+ export function lockfilePath(observed) {
31
+ if (observed.lockfile !== "none")
32
+ return observed.lockfile;
33
+ return observed.packageManager === "none" ? null : DEFAULT_LOCKFILE[observed.packageManager];
34
+ }
35
+ /** Where a composed skill is written for a role. */
36
+ export function skillPath(role) {
37
+ return `.agents/skills/clossys-${role}/SKILL.md`;
38
+ }
39
+ /** Where the composed-skill manifest is written: one per compose-skills item (code rule C9). */
40
+ export const SKILLS_MANIFEST_PATH = "clossys/.state/skills.json";
41
+ /** Controller's limit on a root entry name, in UTF-16 code units. */
42
+ export const MAX_ROOT_NAME_UNITS = 255;
43
+ /** A lowercase id token, such as a role: the contracts' idToken. */
44
+ export const ID_TOKEN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
45
+ /**
46
+ * The root names a change set can introduce: the first segment of each
47
+ * owned pattern other than `**`, read from the packed contract. A fixed list,
48
+ * so no plan or brief text can become a root entry (code rules C13 and L9).
49
+ */
50
+ export const INTRODUCIBLE_ROOTS = (() => {
51
+ const definitions = loadContract("repository-change-set.json").definitions;
52
+ const patterns = definitions?.ownedPattern?.allOf?.[1]?.enum;
53
+ if (!Array.isArray(patterns) || !patterns.every((entry) => typeof entry === "string"))
54
+ throw new Error("the packed change-set contract has no ownedPattern list");
55
+ return new Set(patterns.map((pattern) => pattern.split("/")[0]).filter((root) => root !== "**"));
56
+ })();
57
+ /** The planItem a package act for this repository and package has: `${repositoryId}:${name}`, exactly (code rules C16 and L10). */
58
+ export function derivedPlanItem(repositoryId, name) {
59
+ return `${repositoryId}:${name}`;
60
+ }
61
+ /** The discovery roots, in canonical order. */
62
+ export const DISCOVERY_ROOTS = [".claude/skills", ".cursor/skills"];
63
+ /** Where a role's discovery link is written under a discovery root. */
64
+ export function discoveryLinkPath(root, role) {
65
+ return `${root}/clossys-${role}`;
66
+ }
67
+ /** A discovery link's target: its exact bytes, with no line feed. */
68
+ export function discoveryLinkTarget(role) {
69
+ return `../../.agents/skills/clossys-${role}`;
70
+ }
71
+ const DISCOVERY_LINK = /^\.(?:claude|cursor)\/skills\/clossys-([^/]+)$/u;
72
+ /** The role a discovery link path names, or null when the path is not a discovery link. */
73
+ export function discoveryLinkRole(path) {
74
+ if (!isSafeRelativePath(path))
75
+ return null;
76
+ return DISCOVERY_LINK.exec(path)?.[1] ?? null;
77
+ }
78
+ /** The file each write-record source writes (code rule C9). */
79
+ export const WRITE_RECORD_PATHS = {
80
+ "engagement-brief": BRIEF_PATH,
81
+ "agents-pointer": "AGENTS.md",
82
+ "claude-loader": "CLAUDE.md",
83
+ "agents-guide": AGENTS_GUIDE_PATH,
84
+ };
85
+ export const TEMPLATE_PATHS = {
86
+ "add-caller-workflow": [
87
+ ".github/workflows/clossys-adoption-evidence.yml",
88
+ ".github/workflows/clossys-adoption-decision.yml",
89
+ ".github/scripts/clossys-collect-adoption-snapshot.mjs",
90
+ ],
91
+ "write-starter-request": [".starter/request.json"],
92
+ "add-ci-template": [".github/workflows/clossys-ci.yml"],
93
+ "add-path-scope-job": [".github/workflows/clossys-path-scope.yml"],
94
+ };
95
+ /** For each surface an exempt-release-age item writes: its file, the list key in it, and the package manager that reads it (code rule C12). */
96
+ export const EXEMPTION_SURFACES = {
97
+ "pnpm-workspace": { path: "pnpm-workspace.yaml", key: "minimumReleaseAgeExclude", packageManager: "pnpm" },
98
+ yarnrc: { path: ".yarnrc.yml", key: "npmPreapprovedPackages", packageManager: "yarn" },
99
+ };
100
+ /** The write kind of each act's whole files; null for an act that writes no whole file. A compose-skills discovery link is a link. */
101
+ export const WRITE_KINDS = {
102
+ "write-record": "write",
103
+ "compose-skills": "write",
104
+ "add-caller-workflow": "write",
105
+ "write-starter-request": "write",
106
+ "add-ci-template": "write",
107
+ "add-path-scope-job": "write",
108
+ "exempt-release-age": "create-or-edit",
109
+ "declare-root-entry": "edit",
110
+ install: null,
111
+ "pin-starter": null,
112
+ "write-ledger": null,
113
+ };
114
+ /** The write kind a whole file at `path` has when `item` names it. */
115
+ export function writeKindOf(item, path) {
116
+ if (item.act === "compose-skills" && discoveryLinkRole(path) !== null)
117
+ return "link";
118
+ return WRITE_KINDS[item.act];
119
+ }
120
+ /** `sha256:` and the hex SHA-256 of a text's UTF-8 bytes: a file's content digest. */
121
+ export function contentDigest(text) {
122
+ return `sha256:${createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex")}`;
123
+ }
124
+ /** The JSON pointer of one dependency entry in package.json. */
125
+ export function dependencyPointer(placement, name) {
126
+ return `/${placement}/${name.replace(/~/g, "~0").replace(/\//g, "~1")}`;
127
+ }
128
+ const SAFE_PATH = /^(?:(?!\.\.?\/)[^/\\\u0000-\u001f]+\/)*(?!\.\.?$)[^/\\\u0000-\u001f]+$/u;
129
+ /** A relative path with `/` between segments and no empty, `.` or `..` segment (the contracts' safePath). */
130
+ export function isSafeRelativePath(path) {
131
+ return SAFE_PATH.test(path);
132
+ }
133
+ /** A path pattern as the contract allows one: a safe path whose segments may use `*`, where a segment containing `**` is exactly `**`, and which is not `**` alone. */
134
+ export function isPathPattern(pattern) {
135
+ if (pattern === "**" || !isSafeRelativePath(pattern))
136
+ return false;
137
+ return pattern.split("/").every((segment) => segment === "**" || !segment.includes("**"));
138
+ }
139
+ function segmentMatches(segment, glob) {
140
+ const pattern = glob.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*");
141
+ return new RegExp(`^${pattern}$`, "u").test(segment);
142
+ }
143
+ function segmentsMatch(path, pattern) {
144
+ if (pattern.length === 0)
145
+ return path.length === 0;
146
+ const [head, ...rest] = pattern;
147
+ if (head === "**") {
148
+ for (let skip = 0; skip <= path.length; skip += 1)
149
+ if (segmentsMatch(path.slice(skip), rest))
150
+ return true;
151
+ return false;
152
+ }
153
+ return path.length > 0 && segmentMatches(path[0], head) && segmentsMatch(path.slice(1), rest);
154
+ }
155
+ /**
156
+ * Whether a safe relative path is matched by a path pattern: `*` matches
157
+ * any characters within one segment, and a segment that is exactly `**`
158
+ * matches any number of whole segments, including none. An unsafe path, or
159
+ * a pattern the contract does not allow, matches nothing.
160
+ */
161
+ export function matchesPathPattern(path, pattern) {
162
+ if (!isSafeRelativePath(path) || !isPathPattern(pattern))
163
+ return false;
164
+ return segmentsMatch(path.split("/"), pattern.split("/"));
165
+ }
166
+ /** Orders two strings by their UTF-16 code units. */
167
+ export function compareCodeUnits(left, right) {
168
+ if (left === right)
169
+ return 0;
170
+ return left < right ? -1 : 1;
171
+ }
172
+ /** Orders two tuples of strings member by member, by UTF-16 code units. */
173
+ export function compareTuples(left, right) {
174
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
175
+ const difference = compareCodeUnits(left[index] ?? "", right[index] ?? "");
176
+ if (difference !== 0)
177
+ return difference;
178
+ }
179
+ return 0;
180
+ }
181
+ /** The sort keys of the change-set contract's canonical order (code rule C8), shared by the planner that writes it and the rule that checks it. */
182
+ export const CANONICAL_KEYS = {
183
+ item: (item) => [item.id],
184
+ file: (file) => [file.path],
185
+ key: (key) => [key.file, key.pointer],
186
+ refusal: (refusal) => ("path" in refusal ? [refusal.path, ""] : [refusal.file, refusal.pointer]),
187
+ deferral: (deferral) => [deferral.planItem],
188
+ invariant: (invariant) => ("name" in invariant ? [invariant.name] : [""]),
189
+ pattern: (pattern) => [pattern],
190
+ surface: (surface) => [surface.surface, surface.path],
191
+ root: (root) => [root],
192
+ name: (name) => [name],
193
+ tool: (tool) => [tool.tool],
194
+ };
195
+ /** A copy of `values` in canonical order by `key`. */
196
+ export function canonicalOrder(values, key) {
197
+ return [...values].sort((left, right) => compareTuples(key(left), key(right)));
198
+ }
199
+ const VERDICT_RANK = { satisfied: 0, indeterminate: 1, violated: 2 };
200
+ /** The worst of some verdicts: violated, then indeterminate, then satisfied; satisfied for none. */
201
+ export function worstVerdict(verdicts) {
202
+ return verdicts.reduce((current, verdict) => (VERDICT_RANK[verdict] > VERDICT_RANK[current] ? verdict : current), "satisfied");
203
+ }
204
+ /** The rule a bundle check carries when its authorization is for another plan (code rule A4). */
205
+ export const AUTHORIZATION_PLAN_MISMATCH = "authorization-plan-mismatch";
206
+ /** The rule a bundle check carries when the plan has package acts and no authorization permits them (code rule A4). */
207
+ export const AUTHORIZATION_ABSENT = "authorization-absent";
208
+ function repeats(values, key) {
209
+ const seen = new Map();
210
+ const out = [];
211
+ values.forEach((value, index) => {
212
+ const k = key(value);
213
+ const first = seen.get(k);
214
+ if (first === undefined)
215
+ seen.set(k, index);
216
+ else
217
+ out.push({ index, first });
218
+ });
219
+ return out;
220
+ }
221
+ const isPackageItem = (item) => item.act === "install" || item.act === "pin-starter";
222
+ const isDerived = (file) => "derived" in file;
223
+ const isPackageInvariant = (invariant) => "name" in invariant;
224
+ const lower = (text) => text.toLowerCase();
225
+ /** C8: the index of the first entry out of canonical order (or repeated, when `strict`), if any. */
226
+ function firstOutOfOrder(values, key, strict) {
227
+ for (let index = 1; index < values.length; index += 1) {
228
+ const difference = compareTuples(key(values[index - 1]), key(values[index]));
229
+ if (difference > 0 || (strict && difference === 0))
230
+ return index;
231
+ }
232
+ return undefined;
233
+ }
234
+ /** Code rules C1-C16 of repository-change-set.json, over a set whose schema already passes. Messages name positions, never values. */
235
+ export function changeSetRuleViolations(set) {
236
+ const out = [];
237
+ const push = (rule, path, message) => out.push({ rule, path, message });
238
+ // C1
239
+ for (const { index, first } of repeats(set.items, (item) => item.id))
240
+ push("C1", `items[${index}].id`, `repeats items[${first}].id`);
241
+ // C2
242
+ const itemsById = new Map(set.items.map((item) => [item.id, item]));
243
+ const unknown = (path, item) => {
244
+ if (!itemsById.has(item))
245
+ push("C2", path, "is not the id of an item");
246
+ };
247
+ set.files.forEach((file, index) => {
248
+ unknown(`files[${index}].item`, file.item);
249
+ if (isDerived(file))
250
+ file.invariants.forEach((invariant, at) => {
251
+ if (isPackageInvariant(invariant))
252
+ unknown(`files[${index}].invariants[${at}].item`, invariant.item);
253
+ });
254
+ });
255
+ set.keys.forEach((key, index) => unknown(`keys[${index}].item`, key.item));
256
+ set.refused.forEach((refusal, index) => unknown(`refused[${index}].item`, refusal.item));
257
+ // C3
258
+ for (const { index, first } of repeats(set.files, (file) => lower(file.path)))
259
+ push("C3", `files[${index}].path`, `repeats files[${first}].path, compared case-insensitively`);
260
+ for (const { index, first } of repeats(set.keys, (key) => `${key.file}\u0000${key.pointer}`))
261
+ push("C3", `keys[${index}].pointer`, `repeats keys[${first}].pointer`);
262
+ const written = new Set(set.files.map((file) => lower(file.path)));
263
+ const writtenKeys = new Set(set.keys.map((key) => `${key.file}\u0000${key.pointer}`));
264
+ set.refused.forEach((refusal, index) => {
265
+ if ("path" in refusal && written.has(lower(refusal.path)))
266
+ push("C3", `refused[${index}].path`, "is also written in files");
267
+ if ("pointer" in refusal && writtenKeys.has(`${refusal.file}\u0000${refusal.pointer}`))
268
+ push("C3", `refused[${index}].pointer`, "is also written in keys");
269
+ });
270
+ const allowed = (path) => set.pathAllowList.some((pattern) => matchesPathPattern(path, pattern));
271
+ set.files.forEach((file, index) => {
272
+ if (!allowed(file.path))
273
+ push("C3", `files[${index}].path`, "is not matched by any pathAllowList entry");
274
+ });
275
+ set.keys.forEach((key, index) => {
276
+ if (!allowed(key.file))
277
+ push("C3", `keys[${index}].file`, "is not matched by any pathAllowList entry");
278
+ });
279
+ set.items.forEach((item, index) => {
280
+ if (item.act === "exempt-release-age" && !allowed(item.path))
281
+ push("C3", `items[${index}].path`, "is not matched by any pathAllowList entry");
282
+ });
283
+ // C4
284
+ if (set.ledger.generation < 0)
285
+ push("C4", "ledger.generation", "must be 0 or more");
286
+ const ledgerItems = set.items.map((item, index) => ({ item, index })).filter(({ item }) => item.act === "write-ledger");
287
+ if (ledgerItems.length !== 1) {
288
+ push("C4", "items", `must hold exactly one write-ledger item, and holds ${ledgerItems.length}`);
289
+ }
290
+ else {
291
+ const ledgerId = ledgerItems[0].item.id;
292
+ const ledgerFiles = set.files.map((file, index) => ({ file, index })).filter(({ file }) => file.item === ledgerId);
293
+ if (ledgerFiles.length !== 1) {
294
+ push("C4", `items[${ledgerItems[0].index}]`, `must be named by exactly one file, and is named by ${ledgerFiles.length}`);
295
+ }
296
+ else {
297
+ const { file, index } = ledgerFiles[0];
298
+ const expected = set.ledger.generation + 1;
299
+ const ok = file.path === LEDGER_PATH && isDerived(file) && file.invariants.length === 1 && !isPackageInvariant(file.invariants[0]) && file.invariants[0].ledgerGeneration === expected;
300
+ if (!ok)
301
+ push("C4", `files[${index}]`, `must be the derived file ${LEDGER_PATH} with one invariant, ledgerGeneration equal to ledger.generation plus 1`);
302
+ }
303
+ }
304
+ // C5
305
+ const digest = changeSetDigest(set);
306
+ if (set.changeSetDigest !== digest)
307
+ push("C5", "changeSetDigest", "is not this change set's digest");
308
+ const short = digest.slice("sha256:".length, "sha256:".length + 12);
309
+ if (set.branch !== `clossys/apply-${short}`)
310
+ push("C5", "branch", "is not clossys/apply- and the first 12 digits of this change set's digest");
311
+ if (!set.pullRequest.title.endsWith(short))
312
+ push("C5", "pullRequest.title", "does not end with the first 12 digits of this change set's digest");
313
+ // C6
314
+ const planItems = [
315
+ ...set.items.flatMap((item, index) => (isPackageItem(item) ? [{ planItem: item.planItem, path: `items[${index}].planItem` }] : [])),
316
+ ...set.deferred.map((deferral, index) => ({ planItem: deferral.planItem, path: `deferred[${index}].planItem` })),
317
+ ];
318
+ for (const { index, first } of repeats(planItems, (entry) => entry.planItem))
319
+ push("C6", planItems[index].path, `repeats ${planItems[first].path}`);
320
+ // C7
321
+ const lockPath = lockfilePath(set.observed);
322
+ const lockfiles = [];
323
+ set.files.forEach((file, index) => {
324
+ if (!isDerived(file)) {
325
+ if (file.path === LEDGER_PATH || file.path === "package.json" || LOCKFILE_NAMES.includes(file.path))
326
+ push("C7", `files[${index}].path`, "may not be written as a whole file");
327
+ return;
328
+ }
329
+ if (file.path === LEDGER_PATH) {
330
+ if (file.invariants.some(isPackageInvariant))
331
+ push("C7", `files[${index}].invariants`, "of the ledger may hold only a ledgerGeneration invariant");
332
+ return;
333
+ }
334
+ if (file.path !== lockPath) {
335
+ push("C7", `files[${index}].path`, "is derived but is neither the ledger nor this repository's lockfile path");
336
+ return;
337
+ }
338
+ lockfiles.push(index);
339
+ file.invariants.forEach((invariant, at) => {
340
+ if (!isPackageInvariant(invariant))
341
+ push("C7", `files[${index}].invariants[${at}]`, "of the lockfile must be a package invariant");
342
+ });
343
+ });
344
+ if (lockfiles.length > 1)
345
+ push("C7", `files[${lockfiles[1]}]`, "is a second derived lockfile");
346
+ // C8
347
+ const order = (path, values, key, strict = false) => {
348
+ const index = firstOutOfOrder(values, key, strict);
349
+ if (index !== undefined)
350
+ push("C8", `${path}[${index}]`, strict ? "is out of canonical order, or repeats the entry before it" : "is out of canonical order");
351
+ };
352
+ order("items", set.items, CANONICAL_KEYS.item);
353
+ order("files", set.files, CANONICAL_KEYS.file);
354
+ order("keys", set.keys, CANONICAL_KEYS.key);
355
+ order("refused", set.refused, CANONICAL_KEYS.refusal, true);
356
+ order("deferred", set.deferred, CANONICAL_KEYS.deferral);
357
+ set.files.forEach((file, index) => {
358
+ if (isDerived(file))
359
+ order(`files[${index}].invariants`, file.invariants, CANONICAL_KEYS.invariant);
360
+ });
361
+ order("pathAllowList", set.pathAllowList, CANONICAL_KEYS.pattern, true);
362
+ order("observed.releaseAgeSurfaces", set.observed.releaseAgeSurfaces, CANONICAL_KEYS.surface, true);
363
+ order("observed.symlinkedSkillRoots", set.observed.symlinkedSkillRoots, CANONICAL_KEYS.root, true);
364
+ order("observed.linkedAgentsPaths", set.observed.linkedAgentsPaths, CANONICAL_KEYS.name, true);
365
+ if (set.observed.repositoryProfile !== null) {
366
+ order("observed.repositoryProfile.undeclaredRoots", set.observed.repositoryProfile.undeclaredRoots, CANONICAL_KEYS.name, true);
367
+ order("observed.repositoryProfile.prohibitedRoots", set.observed.repositoryProfile.prohibitedRoots, CANONICAL_KEYS.name, true);
368
+ }
369
+ if (set.tooling !== undefined)
370
+ order("tooling", set.tooling, CANONICAL_KEYS.tool, true);
371
+ if (set.texts !== undefined)
372
+ order("texts", set.texts, (row) => [row.path], true);
373
+ // C9
374
+ set.files.forEach((file, index) => {
375
+ if (isDerived(file))
376
+ return;
377
+ const role = discoveryLinkRole(file.path);
378
+ if ((file.mode === "120000") !== (role !== null))
379
+ push("C9", `files[${index}].mode`, role === null ? "is 120000, which only a discovery link has" : "is not 120000, and this path is a discovery link");
380
+ else if (role !== null && file.after !== null && file.after !== contentDigest(discoveryLinkTarget(role)))
381
+ push("C9", `files[${index}].after`, "is not the content digest of this discovery link's target");
382
+ });
383
+ const linkedRoots = DISCOVERY_ROOTS.filter((root) => !set.observed.symlinkedSkillRoots.includes(root));
384
+ set.items.forEach((item, index) => {
385
+ const files = set.files.map((file, at) => ({ file, at })).filter(({ file }) => file.item === item.id);
386
+ const refusals = set.refused.map((refusal, at) => ({ refusal, at })).filter(({ refusal }) => refusal.item === item.id);
387
+ const keys = set.keys.filter((key) => key.item === item.id);
388
+ const invariants = set.files.flatMap((file) => (isDerived(file) ? file.invariants.filter((invariant) => isPackageInvariant(invariant) && invariant.item === item.id) : []));
389
+ const at = `items[${index}]`;
390
+ // Every path an item names: a whole file's or a path refusal's; a derived file or a key refusal counts as a name at no path.
391
+ const named = [...files.map(({ file }) => (isDerived(file) ? "" : file.path)), ...refusals.map(({ refusal }) => ("path" in refusal ? refusal.path : ""))];
392
+ const namedExactly = (expected) => {
393
+ const counts = new Map();
394
+ for (const path of named)
395
+ counts.set(path, (counts.get(path) ?? 0) + 1);
396
+ return keys.length === 0 && named.length === expected.length && new Set(expected).size === expected.length && expected.every((path) => counts.get(path) === 1);
397
+ };
398
+ if (item.act === "write-record") {
399
+ const path = WRITE_RECORD_PATHS[item.source];
400
+ if (!namedExactly([path]))
401
+ push("C9", at, `must be named by exactly one whole file or path refusal, at ${path}, and by nothing else`);
402
+ }
403
+ else if (item.act === "compose-skills") {
404
+ for (const { index: repeat, first } of repeats(item.roles, (role) => role))
405
+ push("C9", `${at}.roles[${repeat}]`, `repeats roles[${first}]`);
406
+ const roles = [...new Set(item.roles)];
407
+ // A role whose SKILL.md is refused gets no discovery link: a link would expose a skill the flow does not own.
408
+ const written = (role) => files.some(({ file }) => !isDerived(file) && file.path === skillPath(role));
409
+ const expected = [...roles.flatMap((role) => [skillPath(role), ...(written(role) ? linkedRoots.map((root) => discoveryLinkPath(root, role)) : [])]), SKILLS_MANIFEST_PATH];
410
+ if (!namedExactly(expected)) {
411
+ push("C9", at, `must be named, for each role, by exactly one whole file or path refusal at that role's skill path, by one whole file or path refusal at its discovery link under each root not observed as a symbolic link when its skill is a whole file and by none when it is refused, by exactly one at ${SKILLS_MANIFEST_PATH}, and by nothing else`);
412
+ }
413
+ }
414
+ else if (item.act === "add-caller-workflow" || item.act === "write-starter-request" || item.act === "add-ci-template" || item.act === "add-path-scope-job") {
415
+ if (!namedExactly(TEMPLATE_PATHS[item.act]))
416
+ push("C9", at, `must be named by exactly one whole file or path refusal at each file an ${item.act} item writes, and by nothing else`);
417
+ }
418
+ else if (item.act === "exempt-release-age") {
419
+ if (!(keys.length === 0 && named.length <= 1 && named.every((path) => path === item.path)))
420
+ push("C9", at, "must be named by at most one whole file or path refusal, at its own path, and by nothing else");
421
+ }
422
+ else if (item.act === "declare-root-entry") {
423
+ if (!namedExactly([item.path]))
424
+ push("C9", at, "must be named by exactly one whole file or path refusal, at its own path, and by nothing else");
425
+ }
426
+ else if (item.act === "write-ledger") {
427
+ if (refusals.length > 0)
428
+ push("C9", at, "is refused, but the ledger is always written");
429
+ }
430
+ else if (isPackageItem(item)) {
431
+ const pointer = dependencyPointer(item.placement, item.package.name);
432
+ if (item.act === "pin-starter" && item.placement !== "devDependencies")
433
+ push("C9", `${at}.placement`, "of a pin-starter item must be devDependencies");
434
+ if (files.some(({ file }) => !isDerived(file)))
435
+ push("C9", at, "is named by a whole file");
436
+ if (refusals.some(({ refusal }) => "path" in refusal))
437
+ push("C9", at, "is named by a path refusal");
438
+ const ownPointers = [dependencyPointer("dependencies", item.package.name), dependencyPointer("devDependencies", item.package.name)];
439
+ if (refusals.some(({ refusal }) => "pointer" in refusal && !ownPointers.includes(refusal.pointer)))
440
+ push("C9", at, "is named by a key refusal for another package");
441
+ if (keys.some((key) => key.pointer !== pointer || key.after !== item.package.version))
442
+ push("C9", at, "is named by a key whose pointer or value is not this item's");
443
+ const pinned = (invariant) => isPackageInvariant(invariant) && invariant.name === item.package.name && invariant.version === item.package.version && invariant.integrity === item.package.integrity;
444
+ if (!invariants.every(pinned))
445
+ push("C9", at, "is named by an invariant whose package is not this item's");
446
+ const keyRefusals = refusals.filter(({ refusal }) => "pointer" in refusal).length;
447
+ if (item.satisfiedInBase) {
448
+ if (keys.length + invariants.length + refusals.length > 0)
449
+ push("C9", at, "is satisfied in the base, so nothing may be written or refused for it");
450
+ }
451
+ else if (!((keys.length === 1 && invariants.length === 1 && refusals.length === 0) || (keys.length === 0 && invariants.length === 0 && keyRefusals >= 1))) {
452
+ push("C9", at, "is not satisfied in the base, so it must write exactly one key and one invariant, or be refused");
453
+ }
454
+ }
455
+ });
456
+ const kinds = new Map(set.items.map((item) => [item.id, item]));
457
+ set.keys.forEach((key, index) => {
458
+ const item = kinds.get(key.item);
459
+ if (item !== undefined && !isPackageItem(item))
460
+ push("C9", `keys[${index}].item`, "is not a package item");
461
+ });
462
+ set.refused.forEach((refusal, index) => {
463
+ const item = kinds.get(refusal.item);
464
+ if ("pointer" in refusal && item !== undefined && !isPackageItem(item))
465
+ push("C9", `refused[${index}].item`, "is a key refusal for an item that is not a package item");
466
+ });
467
+ set.files.forEach((file, index) => {
468
+ if (!isDerived(file) || file.path === LEDGER_PATH)
469
+ return;
470
+ file.invariants.forEach((invariant, at) => {
471
+ const item = isPackageInvariant(invariant) ? kinds.get(invariant.item) : undefined;
472
+ if (item !== undefined && !isPackageItem(item))
473
+ push("C9", `files[${index}].invariants[${at}].item`, "is not a package item");
474
+ });
475
+ const first = file.invariants[0];
476
+ if (first !== undefined && isPackageInvariant(first) && file.item !== first.item)
477
+ push("C9", `files[${index}].item`, "is not the item of its first invariant");
478
+ });
479
+ // C10
480
+ const starters = set.items.map((item, index) => ({ item, index })).filter(({ item }) => item.act === "pin-starter");
481
+ if (starters.length > 1)
482
+ push("C10", `items[${starters[1].index}]`, "is a second pin-starter item; a set pins Starter at most once");
483
+ set.items.forEach((item, index) => {
484
+ if (set.phase === "setup" && item.act === "install")
485
+ push("C10", `items[${index}]`, "is an install in a setup set, where installs are deferred");
486
+ });
487
+ if (set.phase === "apply")
488
+ set.deferred.forEach((_, index) => push("C10", `deferred[${index}]`, "is deferred in an apply set, which defers nothing"));
489
+ // C11
490
+ if (set.phase === "setup") {
491
+ const count = (act) => set.items.filter((item) => item.act === act).length;
492
+ for (const act of ["add-caller-workflow", "write-starter-request", "add-ci-template", "add-path-scope-job", "pin-starter"]) {
493
+ const held = count(act);
494
+ if (held !== 1)
495
+ push("C11", "items", `must hold exactly one ${act} item in a setup set, and holds ${held}`);
496
+ }
497
+ const exempts = count("exempt-release-age");
498
+ const wanted = set.observed.packageManager === "pnpm" || set.observed.packageManager === "yarn" ? 1 : 0;
499
+ if (exempts !== wanted)
500
+ push("C11", "items", `must hold ${wanted} exempt-release-age item(s) in a setup set for this package manager, and holds ${exempts}`);
501
+ }
502
+ // C12
503
+ set.items.forEach((item, index) => {
504
+ if (item.act !== "exempt-release-age")
505
+ return;
506
+ const surface = EXEMPTION_SURFACES[item.surface];
507
+ if (item.path !== surface.path)
508
+ push("C12", `items[${index}].path`, "is not the file of the item's surface");
509
+ if (set.observed.packageManager !== surface.packageManager)
510
+ push("C12", `items[${index}].surface`, "is not a surface this repository's package manager reads");
511
+ if (item.scope !== PACKAGE_SCOPE.scope)
512
+ push("C12", `items[${index}].scope`, "is not the publishing scope this package packs");
513
+ });
514
+ // C13
515
+ const profile = set.observed.repositoryProfile;
516
+ const declarers = set.items.map((item, index) => ({ item, index })).filter(({ item }) => item.act === "declare-root-entry");
517
+ if (declarers.length > 1)
518
+ push("C13", `items[${declarers[1].index}]`, "is a second declare-root-entry item");
519
+ const needed = profile !== null && (profile.rootVocabulary === "unparseable" || (profile.rootVocabulary === "checked" && (profile.undeclaredRoots.length > 0 || profile.prohibitedRoots.length > 0)));
520
+ if (needed && declarers.length === 0)
521
+ push("C13", "items", "must hold a declare-root-entry item for the observed repository profile");
522
+ if (!needed)
523
+ for (const { index } of declarers)
524
+ push("C13", `items[${index}]`, "is a declare-root-entry item the observed repository profile does not need");
525
+ if (profile !== null) {
526
+ if (profile.rootVocabulary !== "checked" && profile.undeclaredRoots.length + profile.prohibitedRoots.length > 0) {
527
+ push("C13", "observed.repositoryProfile", "lists root names, but its root vocabulary is not checked");
528
+ }
529
+ if (profile.undeclaredRoots.some((name) => profile.prohibitedRoots.includes(name)))
530
+ push("C13", "observed.repositoryProfile", "lists one root name as both undeclared and prohibited");
531
+ // Only a path the set creates introduces a root name: a refused path is not written, and a key's file or an edited file already exists.
532
+ const roots = new Set(set.files.filter((file) => isDerived(file) || file.before === null).map((file) => file.path.split("/")[0]));
533
+ for (const [name, list] of [["undeclaredRoots", profile.undeclaredRoots], ["prohibitedRoots", profile.prohibitedRoots]]) {
534
+ list.forEach((root, at) => {
535
+ if (!roots.has(root))
536
+ push("C13", `observed.repositoryProfile.${name}[${at}]`, "is not the first segment of any path the set creates");
537
+ if (!INTRODUCIBLE_ROOTS.has(root))
538
+ push("C13", `observed.repositoryProfile.${name}[${at}]`, "is not a root name an owned pattern can introduce");
539
+ });
540
+ }
541
+ }
542
+ if (profile !== null && needed && declarers.length === 1) {
543
+ const { item, index } = declarers[0];
544
+ const at = `items[${index}]`;
545
+ if (item.path !== profile.path)
546
+ push("C13", `${at}.path`, "is not the observed repository profile's path");
547
+ item.entries.forEach((entry, position) => {
548
+ if (!INTRODUCIBLE_ROOTS.has(entry.name))
549
+ push("C13", `${at}.entries[${position}].name`, "is not a root name an owned pattern can introduce");
550
+ });
551
+ if (item.entries.length !== profile.undeclaredRoots.length || item.entries.some((entry, position) => entry.name !== profile.undeclaredRoots[position])) {
552
+ push("C13", `${at}.entries`, "do not name exactly the observed undeclared root names, in their order");
553
+ }
554
+ const whole = set.files.find((file) => file.item === item.id && !isDerived(file));
555
+ const refusal = set.refused.find((entry) => entry.item === item.id && "path" in entry);
556
+ const expected = profile.rootVocabulary === "unparseable" ? "root-vocabulary-unknown" : profile.prohibitedRoots.length > 0 ? "root-entry-prohibited" : null;
557
+ if (expected === null) {
558
+ if (whole === undefined)
559
+ push("C13", at, "must be named by a whole file that edits the profile");
560
+ }
561
+ else if (refusal === undefined || refusal.reason !== expected) {
562
+ push("C13", at, `must be named by a path refusal with reason ${expected}`);
563
+ }
564
+ }
565
+ // C14
566
+ const linked = set.observed.linkedAgentsPaths;
567
+ const skillRoles = new Set(set.items.flatMap((item) => (item.act === "compose-skills" ? item.roles : [])));
568
+ linked.forEach((path, at) => {
569
+ const role = /^\.agents\/skills\/clossys-(.+)$/u.exec(path)?.[1];
570
+ if (role !== undefined && !skillRoles.has(role))
571
+ push("C14", `observed.linkedAgentsPaths[${at}]`, "is a skill directory of no role this set composes");
572
+ });
573
+ const underLink = (path) => linked.some((link) => path === link || path.startsWith(`${link}/`));
574
+ const skillPaths = new Set([...skillRoles].map(skillPath));
575
+ set.files.forEach((file, at) => {
576
+ if (skillPaths.has(file.path) && underLink(file.path))
577
+ push("C14", `files[${at}].path`, "is written through a symbolic link on the default branch");
578
+ });
579
+ set.refused.forEach((refusal, at) => {
580
+ if (!("path" in refusal)) {
581
+ if (refusal.reason === "skills-root-is-link")
582
+ push("C14", `refused[${at}].reason`, "is skills-root-is-link on a key, which names no path");
583
+ return;
584
+ }
585
+ const shouldBe = skillPaths.has(refusal.path) && underLink(refusal.path);
586
+ if (shouldBe !== (refusal.reason === "skills-root-is-link")) {
587
+ push("C14", `refused[${at}].reason`, shouldBe ? "is not skills-root-is-link, and this skill lies under a symbolic link" : "is skills-root-is-link, but this path is no skill under a symbolic link");
588
+ }
589
+ });
590
+ // C15: every whole file obeys its item's write kind, checked here and nowhere else.
591
+ set.files.forEach((file, index) => {
592
+ const item = itemsById.get(file.item);
593
+ if (isDerived(file) || item === undefined)
594
+ return;
595
+ const kind = writeKindOf(item, file.path);
596
+ if (kind === null)
597
+ return; // an act that writes no whole file: code rule C9 refuses the file
598
+ const at = `files[${index}]`;
599
+ if (file.after === null) {
600
+ push("C15", `${at}.after`, `is null, but a ${kind} never deletes a file`);
601
+ return;
602
+ }
603
+ if (kind === "edit" && file.before === null)
604
+ push("C15", `${at}.before`, "is null, but an edit changes a file the default branch has");
605
+ if ((kind === "edit" || kind === "create-or-edit") && file.after === file.before)
606
+ push("C15", `${at}.after`, `equals before, but a ${kind} writes only a change`);
607
+ if (kind === "link" && file.before !== null && file.before !== file.after)
608
+ push("C15", `${at}.before`, "is another target, but a discovery link is only created or kept");
609
+ });
610
+ // C16: a planItem reaches the public ledger, so it is derived, never free text.
611
+ set.items.forEach((item, index) => {
612
+ if (isPackageItem(item) && item.planItem !== derivedPlanItem(set.repository.id, item.package.name))
613
+ push("C16", `items[${index}].planItem`, "is not the repository id, a colon and the package name");
614
+ });
615
+ const prefix = `${set.repository.id}:`;
616
+ set.deferred.forEach((deferral, index) => {
617
+ const name = deferral.planItem.startsWith(prefix) ? deferral.planItem.slice(prefix.length) : "";
618
+ if (!PACKAGE_NAME.test(name))
619
+ push("C16", `deferred[${index}].planItem`, "is not the repository id, a colon and a package name");
620
+ });
621
+ if (set.texts !== undefined) {
622
+ const wholeByPath = new Map(set.files.filter((file) => !("derived" in file)).map((file) => [file.path, file]));
623
+ set.texts.forEach((row, index) => {
624
+ const file = wholeByPath.get(row.path);
625
+ if (file === undefined)
626
+ push("C17", `texts[${index}].path`, "names no whole file in files");
627
+ else if (file.after !== null && file.after !== contentDigest(row.text))
628
+ push("C17", `texts[${index}].text`, "is not the content digest of that whole file's after bytes");
629
+ });
630
+ }
631
+ return out;
632
+ }
633
+ const PACKAGE_NAME = /^(?=.{1,214}$)@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/u;
634
+ const PRE_APPLY_CHECKS = ["V1", "V2", "V3", "V4", "V5", "V6", "V7", "V8", "V9"];
635
+ /** Code rules A1-A7 of apply-bundle.json, over a bundle whose schema already passes. */
636
+ export function applyBundleRuleViolations(bundle) {
637
+ const out = [];
638
+ for (const { index, first } of repeats(bundle.repositories, (entry) => entry.id.toLowerCase()))
639
+ out.push({ rule: "A1", path: `repositories[${index}].id`, message: `repeats repositories[${first}].id` });
640
+ const computed = bundle.repositories.flatMap((entry) => ("changeSet" in entry ? [{ id: entry.id, changeSetDigest: entry.changeSet }] : []));
641
+ if (bundle.bundleDigest !== bundleDigest(bundle.plan.digest, computed))
642
+ out.push({ rule: "A2", path: "bundleDigest", message: "is not the digest of plan.digest and the repositories that have a change set" });
643
+ const mismatch = bundle.authorization !== null && bundle.authorization.planDigest !== bundle.plan.digest;
644
+ // A snapshot is recorded exactly when the plan has package acts (the plan contract's R6), and package acts need an authorization.
645
+ const absent = bundle.snapshot !== null && bundle.authorization === null;
646
+ bundle.repositories.forEach((entry, index) => {
647
+ const expected = entry.checks.length === 0 && !("changeSet" in entry) ? entry.verdict : worstVerdict(entry.checks.map((check) => check.verdict));
648
+ if (entry.verdict !== expected)
649
+ out.push({ rule: "A3", path: `repositories[${index}].verdict`, message: "is not the worst of its checks' verdicts" });
650
+ const flagged = entry.checks.some((check) => check.check === "V3" && check.verdict === "violated" && check.rule === AUTHORIZATION_PLAN_MISMATCH);
651
+ const carries = entry.checks.some((check) => check.rule === AUTHORIZATION_PLAN_MISMATCH);
652
+ if ("changeSet" in entry && mismatch && !flagged)
653
+ out.push({ rule: "A4", path: `repositories[${index}].checks`, message: "lacks the violated V3 check for an authorization issued for another plan" });
654
+ if (!mismatch && carries)
655
+ out.push({ rule: "A4", path: `repositories[${index}].checks`, message: "reports an authorization mismatch the bundle does not have" });
656
+ const flaggedAbsent = entry.checks.some((check) => check.check === "V3" && check.verdict === "violated" && check.rule === AUTHORIZATION_ABSENT);
657
+ const carriesAbsent = entry.checks.some((check) => check.rule === AUTHORIZATION_ABSENT);
658
+ if ("changeSet" in entry && absent && !flaggedAbsent)
659
+ out.push({ rule: "A4", path: `repositories[${index}].checks`, message: "lacks the violated V3 check for package acts no authorization permits" });
660
+ if (!absent && carriesAbsent)
661
+ out.push({ rule: "A4", path: `repositories[${index}].checks`, message: "reports a missing authorization the bundle does not lack" });
662
+ if (!("changeSet" in entry))
663
+ return;
664
+ const hasState = entry.state !== undefined;
665
+ const hasBinding = entry.binding !== undefined;
666
+ if (bundle.mode === "report") {
667
+ if (hasState)
668
+ out.push({ rule: "A5", path: `repositories[${index}].state`, message: "is a repository state, which a report bundle never claims" });
669
+ if (hasBinding)
670
+ out.push({ rule: "A5", path: `repositories[${index}].binding`, message: "is a binding, which a report bundle never records" });
671
+ return;
672
+ }
673
+ const planned = entry.verdict === "satisfied" && hasBinding;
674
+ if (hasState && !planned)
675
+ out.push({ rule: "A6", path: `repositories[${index}].state`, message: "is planned, but the verdict is not satisfied or no approval binds the change set" });
676
+ if (!hasState && planned)
677
+ out.push({ rule: "A6", path: `repositories[${index}]`, message: "is satisfied and bound by an approval, so it must be planned" });
678
+ if (hasState && !PRE_APPLY_CHECKS.every((id) => entry.checks.some((check) => check.check === id && check.verdict === "satisfied"))) {
679
+ out.push({ rule: "A6", path: `repositories[${index}].checks`, message: "lacks a satisfied check for each of V1 to V9, which a planned repository needs" });
680
+ }
681
+ const v3 = entry.checks.filter((check) => check.check === "V3");
682
+ const authorityHolds = v3.length > 0 && v3.every((check) => check.verdict === "satisfied");
683
+ if (hasBinding !== authorityHolds) {
684
+ out.push({ rule: "A7", path: `repositories[${index}]${hasBinding ? ".binding" : ""}`, message: hasBinding ? "is recorded, but V3 is missing or not satisfied" : "has V3 satisfied, so it must record the binding V3 found" });
685
+ }
686
+ if (entry.binding?.kind === "admitted") {
687
+ if (entry.phase !== "apply")
688
+ out.push({ rule: "A7", path: `repositories[${index}].binding`, message: "is admitted, which only an apply set can be" });
689
+ if (entry.binding.setupChangeSet === entry.changeSet)
690
+ out.push({ rule: "A7", path: `repositories[${index}].binding.setupChangeSet`, message: "is this repository's own change set, not the setup set it follows" });
691
+ if (entry.binding.subjectDigest === bundle.bundleDigest)
692
+ out.push({ rule: "A7", path: `repositories[${index}].binding.subjectDigest`, message: "is this bundle's own digest, but the approved bundle held the setup set, not this apply set" });
693
+ }
694
+ });
695
+ if (bundle.mode === "planned" && !bundle.plan.committed)
696
+ out.push({ rule: "A6", path: "plan.committed", message: "must be true in a planned bundle" });
697
+ if (bundle.mode === "planned") {
698
+ const bound = bundle.repositories.flatMap((entry, index) => ("changeSet" in entry && entry.binding !== undefined ? [{ subject: entry.binding.subjectDigest, index }] : []));
699
+ const first = bound[0];
700
+ for (const { subject, index } of bound) {
701
+ if (first !== undefined && subject !== first.subject)
702
+ out.push({ rule: "A7", path: `repositories[${index}].binding.subjectDigest`, message: `is not the approval repositories[${first.index}] is bound by, and one bundle has one approval` });
703
+ }
704
+ // A plan with package acts has a snapshot; V3 passes for package acts only with a current execution authorization (a plan with none is bound by its decision alone).
705
+ if (first !== undefined && bundle.snapshot !== null && bundle.authorization === null) {
706
+ out.push({ rule: "A7", path: "authorization", message: "is null, but V3 passed for a bound change set of a plan with package acts, which needs a current execution authorization" });
707
+ }
708
+ }
709
+ return out;
710
+ }
711
+ function violationsOf(contractName, label, value, rules) {
712
+ const schema = validateAgainstContract(loadContract(contractName), value, loadContract);
713
+ if (schema.length > 0)
714
+ return schema.map((violation) => ({ rule: "schema", path: violation.path, message: formatContractViolation(label, violation) }));
715
+ return rules(value).map((violation) => ({ rule: violation.rule, path: violation.path, message: `${label}.${violation.path} ${violation.message} (rule ${violation.rule})` }));
716
+ }
717
+ function result(violations) {
718
+ if (violations.length === 0)
719
+ return { valid: true };
720
+ return { valid: false, reason: violations.map((violation) => violation.message).join("; ") };
721
+ }
722
+ /** Every reason a change set is refused: the change-set contract's schema, then, once that passes, its code rules C1-C16. */
723
+ export function repositoryChangeSetViolations(value) {
724
+ return violationsOf("repository-change-set.json", "changeSet", value, changeSetRuleViolations);
725
+ }
726
+ /** Every reason a bundle is refused: the bundle contract's schema, then, once that passes, its code rules A1-A7. */
727
+ export function applyBundleViolations(value) {
728
+ return violationsOf("apply-bundle.json", "bundle", value, applyBundleRuleViolations);
729
+ }
730
+ /** Validates a change set against repository-change-set.json and its code rules C1-C16. No reason echoes a value. */
731
+ export function validateRepositoryChangeSet(value) {
732
+ return result(repositoryChangeSetViolations(value));
733
+ }
734
+ /** Validates a bundle against apply-bundle.json and its code rules A1-A7. No reason echoes a value. */
735
+ export function validateApplyBundle(value) {
736
+ return result(applyBundleViolations(value));
737
+ }
738
+ //# sourceMappingURL=change-set-contract.js.map