@mnstry/atelier 0.2.0-alpha.12 → 0.2.0-alpha.14

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 (531) hide show
  1. package/CHANGELOG.md +894 -0
  2. package/README.md +159 -6
  3. package/contracts/atelier-adoption-policy.v2.schema.json +55 -0
  4. package/contracts/atelier-architecture.v1.schema.json +258 -0
  5. package/contracts/atelier-boundary-delegations.v1.schema.json +161 -0
  6. package/contracts/atelier-boundary-owner-keys.v1.schema.json +98 -0
  7. package/contracts/atelier-build.v1.schema.json +1076 -0
  8. package/contracts/atelier-capability.v1.schema.json +1252 -0
  9. package/contracts/atelier-coordination.v1.schema.json +1050 -0
  10. package/contracts/atelier-evidence-navigation.v1.schema.json +603 -0
  11. package/contracts/atelier-harness.v1.schema.json +234 -0
  12. package/contracts/atelier-ingestion.v1.schema.json +806 -0
  13. package/contracts/atelier-inquiry.v1.schema.json +1772 -0
  14. package/contracts/atelier-instruction-adoption.v1.schema.json +490 -0
  15. package/contracts/atelier-interaction.v1.schema.json +553 -0
  16. package/contracts/atelier-judgment.v1.schema.json +690 -0
  17. package/contracts/atelier-knowledge.v1.schema.json +1512 -0
  18. package/contracts/atelier-learning.v1.schema.json +1095 -0
  19. package/contracts/atelier-migration.v3.schema.json +64 -0
  20. package/contracts/atelier-personal-workspace-manifest.v1.schema.json +87 -0
  21. package/contracts/atelier-personal-workspace-overlay.v1.schema.json +299 -0
  22. package/contracts/atelier-template-adoption.v1.schema.json +223 -0
  23. package/contracts/atelier-template-profile.v1.schema.json +1168 -0
  24. package/contracts/atelier-trackable.v1.schema.json +781 -0
  25. package/contracts/atelier-upgrade-plan.v3.schema.json +532 -0
  26. package/docs/architecture.md +89 -0
  27. package/docs/assurance-controls.md +2 -2
  28. package/docs/blocks/capability-stewardship.md +17 -0
  29. package/docs/blocks/claims.md +16 -3
  30. package/docs/blocks/inquiry-harnesses.md +19 -0
  31. package/docs/blocks/learning-harnesses.md +17 -0
  32. package/docs/capability-stewardship.md +267 -0
  33. package/docs/coordination.md +51 -0
  34. package/docs/decision-practices.md +111 -0
  35. package/docs/decisions.md +29 -0
  36. package/docs/evidence-navigation.md +237 -0
  37. package/docs/exact-upgrades.md +3 -2
  38. package/docs/ingestion.md +181 -0
  39. package/docs/inquiry-harnesses.md +210 -0
  40. package/docs/install.md +80 -3
  41. package/docs/intake-and-guides.md +5 -0
  42. package/docs/integration-contract-decisions.md +24 -2
  43. package/docs/interaction-and-reflection.md +72 -0
  44. package/docs/knowledge-graph.md +20 -0
  45. package/docs/knowledge-health/api.md +30 -0
  46. package/docs/knowledge-health/assessment/api.md +96 -0
  47. package/docs/knowledge-health/assessment/capabilities.md +40 -0
  48. package/docs/knowledge-health/assessment/evaluation.md +27 -0
  49. package/docs/knowledge-health/assessment/facilitator.md +30 -0
  50. package/docs/knowledge-health/assessment/quickstart.md +61 -0
  51. package/docs/knowledge-health/assessment/troubleshooting.md +23 -0
  52. package/docs/knowledge-health/devday/facilitator.md +16 -0
  53. package/docs/knowledge-health/devday/participant-response.md +22 -0
  54. package/docs/knowledge-health/devday/public-default.md +107 -0
  55. package/docs/knowledge-health/devday/quickstart.md +18 -0
  56. package/docs/knowledge-health/devday/support.md +22 -0
  57. package/docs/knowledge-health/exercise.md +17 -0
  58. package/docs/knowledge-health/facilitator.md +20 -0
  59. package/docs/knowledge-health/quickstart.md +94 -0
  60. package/docs/knowledge-health/readme.md +15 -0
  61. package/docs/knowledge-health/support.md +34 -0
  62. package/docs/knowledge-setup.md +248 -0
  63. package/docs/knowledge-workspace.md +264 -0
  64. package/docs/learning-harnesses.md +237 -0
  65. package/docs/learning.md +215 -0
  66. package/docs/local-services.md +223 -19
  67. package/docs/obsidian-contract.md +246 -15
  68. package/docs/obsidian-plugin.md +252 -7
  69. package/docs/obsidian.md +874 -52
  70. package/docs/optional-ingestion.md +83 -0
  71. package/docs/personal-workspace.md +266 -0
  72. package/docs/practical-judgment.md +92 -0
  73. package/docs/progressive-ingestion-acceptance.md +89 -0
  74. package/docs/progressive-ingestion.md +235 -0
  75. package/docs/project-command-inventory.md +1 -0
  76. package/docs/release-engineering.md +50 -0
  77. package/docs/repo-boundary-guard.md +120 -3
  78. package/docs/research-starter-quickstart.md +66 -0
  79. package/docs/research-starter.md +114 -0
  80. package/docs/semantic-ingestion.md +198 -0
  81. package/docs/semantic-operation.md +318 -0
  82. package/docs/skill-steward.md +6 -0
  83. package/docs/template-profile.md +164 -0
  84. package/docs/template-upgrades.md +114 -0
  85. package/docs/trackables.md +111 -0
  86. package/docs/upgrade.md +219 -2
  87. package/fixtures/atelier-architecture/binding/invalid/document.json +19 -0
  88. package/fixtures/atelier-architecture/binding/valid/document.json +19 -0
  89. package/fixtures/atelier-architecture/catalog/invalid/document.json +290 -0
  90. package/fixtures/atelier-architecture/catalog/valid/document.json +343 -0
  91. package/fixtures/atelier-build-contract/attempt/invalid/document.json +20 -0
  92. package/fixtures/atelier-build-contract/attempt/valid/document.json +19 -0
  93. package/fixtures/atelier-build-contract/candidate/invalid/document.json +24 -0
  94. package/fixtures/atelier-build-contract/candidate/valid/document.json +23 -0
  95. package/fixtures/atelier-build-contract/decision/invalid/document.json +31 -0
  96. package/fixtures/atelier-build-contract/decision/valid/document.json +30 -0
  97. package/fixtures/atelier-build-contract/delivery/invalid/document.json +18 -0
  98. package/fixtures/atelier-build-contract/delivery/valid/document.json +17 -0
  99. package/fixtures/atelier-build-contract/gate/invalid/document.json +22 -0
  100. package/fixtures/atelier-build-contract/gate/valid/document.json +21 -0
  101. package/fixtures/atelier-build-contract/ledger/invalid/document.json +270 -0
  102. package/fixtures/atelier-build-contract/ledger/valid/document.json +269 -0
  103. package/fixtures/atelier-build-contract/objective/invalid/document.json +73 -0
  104. package/fixtures/atelier-build-contract/objective/valid/document.json +72 -0
  105. package/fixtures/atelier-build-contract/progress/invalid/document.json +18 -0
  106. package/fixtures/atelier-build-contract/progress/valid/document.json +17 -0
  107. package/fixtures/atelier-capability-contract/adoption/invalid/document.json +71 -0
  108. package/fixtures/atelier-capability-contract/adoption/valid/document.json +70 -0
  109. package/fixtures/atelier-capability-contract/event/invalid/document.json +18 -0
  110. package/fixtures/atelier-capability-contract/event/valid/document.json +17 -0
  111. package/fixtures/atelier-capability-contract/journal/invalid/document.json +382 -0
  112. package/fixtures/atelier-capability-contract/journal/valid/document.json +381 -0
  113. package/fixtures/atelier-capability-contract/notice/invalid/document.json +8 -0
  114. package/fixtures/atelier-capability-contract/notice/valid/document.json +7 -0
  115. package/fixtures/atelier-capability-contract/package/invalid/document.json +59 -0
  116. package/fixtures/atelier-capability-contract/package/valid/document.json +58 -0
  117. package/fixtures/atelier-capability-contract/plan/invalid/document.json +107 -0
  118. package/fixtures/atelier-capability-contract/plan/valid/document.json +106 -0
  119. package/fixtures/atelier-capability-contract/release/invalid/document.json +85 -0
  120. package/fixtures/atelier-capability-contract/release/valid/document.json +84 -0
  121. package/fixtures/atelier-capability-contract/state/invalid/document.json +316 -0
  122. package/fixtures/atelier-capability-contract/state/valid/document.json +315 -0
  123. package/fixtures/atelier-coordination/dependency/invalid/document.json +37 -0
  124. package/fixtures/atelier-coordination/dependency/valid/document.json +36 -0
  125. package/fixtures/atelier-coordination/directive/invalid/document.json +25 -0
  126. package/fixtures/atelier-coordination/directive/valid/document.json +24 -0
  127. package/fixtures/atelier-coordination/disposition/invalid/document.json +34 -0
  128. package/fixtures/atelier-coordination/disposition/valid/document.json +33 -0
  129. package/fixtures/atelier-coordination/outcome/invalid/document.json +39 -0
  130. package/fixtures/atelier-coordination/outcome/valid/document.json +38 -0
  131. package/fixtures/atelier-coordination/recovery/invalid/document.json +34 -0
  132. package/fixtures/atelier-coordination/recovery/valid/document.json +33 -0
  133. package/fixtures/atelier-harness-contract/handoff/invalid/document.json +25 -0
  134. package/fixtures/atelier-harness-contract/handoff/valid/document.json +24 -0
  135. package/fixtures/atelier-ingestion/invalid/authority.json +19 -0
  136. package/fixtures/atelier-ingestion/semantic-baseline.json +26 -0
  137. package/fixtures/atelier-ingestion/semantic-operation.json +75 -0
  138. package/fixtures/atelier-ingestion/valid/plan-input.json +18 -0
  139. package/fixtures/atelier-inquiry-contract/assessment/invalid/document.json +41 -0
  140. package/fixtures/atelier-inquiry-contract/assessment/valid/document.json +40 -0
  141. package/fixtures/atelier-inquiry-contract/bundle/invalid/document.json +51 -0
  142. package/fixtures/atelier-inquiry-contract/bundle/valid/document.json +50 -0
  143. package/fixtures/atelier-inquiry-contract/campaign/invalid/document.json +17 -0
  144. package/fixtures/atelier-inquiry-contract/campaign/valid/document.json +16 -0
  145. package/fixtures/atelier-inquiry-contract/decision/invalid/document.json +22 -0
  146. package/fixtures/atelier-inquiry-contract/decision/valid/document.json +21 -0
  147. package/fixtures/atelier-inquiry-contract/feedback/invalid/document.json +19 -0
  148. package/fixtures/atelier-inquiry-contract/feedback/valid/document.json +18 -0
  149. package/fixtures/atelier-inquiry-contract/handoff/invalid/document.json +92 -0
  150. package/fixtures/atelier-inquiry-contract/handoff/valid/document.json +91 -0
  151. package/fixtures/atelier-inquiry-contract/hypothesis/invalid/document.json +20 -0
  152. package/fixtures/atelier-inquiry-contract/hypothesis/valid/document.json +19 -0
  153. package/fixtures/atelier-inquiry-contract/ledger/invalid/document.json +680 -0
  154. package/fixtures/atelier-inquiry-contract/ledger/valid/document.json +679 -0
  155. package/fixtures/atelier-inquiry-contract/legacy-assessment/invalid/document.json +19 -0
  156. package/fixtures/atelier-inquiry-contract/legacy-assessment/valid/document.json +18 -0
  157. package/fixtures/atelier-inquiry-contract/request/invalid/document.json +50 -0
  158. package/fixtures/atelier-inquiry-contract/request/valid/document.json +49 -0
  159. package/fixtures/atelier-inquiry-contract/source/invalid/document.json +18 -0
  160. package/fixtures/atelier-inquiry-contract/source/valid/document.json +17 -0
  161. package/fixtures/atelier-inquiry-contract/withdrawal/invalid/document.json +16 -0
  162. package/fixtures/atelier-inquiry-contract/withdrawal/valid/document.json +15 -0
  163. package/fixtures/atelier-instruction-adoption/context-receipt/invalid/document.json +18 -0
  164. package/fixtures/atelier-instruction-adoption/context-receipt/valid/document.json +17 -0
  165. package/fixtures/atelier-instruction-adoption/plan/invalid/document.json +63 -0
  166. package/fixtures/atelier-instruction-adoption/plan/valid/document.json +62 -0
  167. package/fixtures/atelier-instruction-adoption/receipt/invalid/document.json +22 -0
  168. package/fixtures/atelier-instruction-adoption/receipt/valid/document.json +21 -0
  169. package/fixtures/atelier-instruction-adoption/state/invalid/document.json +30 -0
  170. package/fixtures/atelier-instruction-adoption/state/valid/document.json +29 -0
  171. package/fixtures/atelier-interaction/act/invalid/document.json +25 -0
  172. package/fixtures/atelier-interaction/act/valid/document.json +24 -0
  173. package/fixtures/atelier-interaction/assessment/invalid/document.json +32 -0
  174. package/fixtures/atelier-interaction/assessment/valid/document.json +31 -0
  175. package/fixtures/atelier-interaction/observation/invalid/document.json +22 -0
  176. package/fixtures/atelier-interaction/observation/valid/document.json +21 -0
  177. package/fixtures/atelier-interaction/policy/invalid/document.json +20 -0
  178. package/fixtures/atelier-interaction/policy/valid/document.json +19 -0
  179. package/fixtures/atelier-judgment/case/invalid/document.json +76 -0
  180. package/fixtures/atelier-judgment/case/valid/document.json +75 -0
  181. package/fixtures/atelier-judgment/decision/invalid/document.json +38 -0
  182. package/fixtures/atelier-judgment/decision/valid/document.json +37 -0
  183. package/fixtures/atelier-judgment/native-decision.md +3 -0
  184. package/fixtures/atelier-judgment/practice-reconsideration/definition.json +92 -0
  185. package/fixtures/atelier-judgment/practice-reconsideration/scenario.json +435 -0
  186. package/fixtures/atelier-knowledge-contract/activation/invalid/document.json +26 -0
  187. package/fixtures/atelier-knowledge-contract/activation/valid/document.json +25 -0
  188. package/fixtures/atelier-knowledge-contract/contribution/invalid/document.json +34 -0
  189. package/fixtures/atelier-knowledge-contract/contribution/valid/document.json +33 -0
  190. package/fixtures/atelier-knowledge-contract/domain/invalid/document.json +41 -0
  191. package/fixtures/atelier-knowledge-contract/domain/valid/document.json +40 -0
  192. package/fixtures/atelier-knowledge-contract/domain-revision/invalid/document.json +46 -0
  193. package/fixtures/atelier-knowledge-contract/domain-revision/valid/document.json +45 -0
  194. package/fixtures/atelier-knowledge-contract/evaluation/invalid/document.json +21 -0
  195. package/fixtures/atelier-knowledge-contract/evaluation/valid/document.json +20 -0
  196. package/fixtures/atelier-knowledge-contract/ledger/invalid/document.json +318 -0
  197. package/fixtures/atelier-knowledge-contract/ledger/valid/document.json +317 -0
  198. package/fixtures/atelier-knowledge-contract/relation/invalid/document.json +25 -0
  199. package/fixtures/atelier-knowledge-contract/relation/valid/document.json +24 -0
  200. package/fixtures/atelier-knowledge-contract/review/invalid/document.json +18 -0
  201. package/fixtures/atelier-knowledge-contract/review/valid/document.json +17 -0
  202. package/fixtures/atelier-knowledge-contract/withdrawal/invalid/document.json +16 -0
  203. package/fixtures/atelier-knowledge-contract/withdrawal/valid/document.json +15 -0
  204. package/fixtures/atelier-learning/invalid/authority.json +14 -0
  205. package/fixtures/atelier-learning/valid/capture.json +13 -0
  206. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/authority-key.json +17 -0
  207. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/nonempty-ext.json +25 -0
  208. package/fixtures/atelier-personal-workspace-contract/manifest/invalid/wrong-schema.json +22 -0
  209. package/fixtures/atelier-personal-workspace-contract/manifest/valid/manifest.json +22 -0
  210. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/authority-key.json +25 -0
  211. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/unknown-top-level-field.json +27 -0
  212. package/fixtures/atelier-personal-workspace-contract/overlay/invalid/wrong-schema.json +24 -0
  213. package/fixtures/atelier-trackable/definition/invalid/document.json +15 -0
  214. package/fixtures/atelier-trackable/definition/valid/document.json +14 -0
  215. package/fixtures/atelier-trackable/request/invalid/document.json +23 -0
  216. package/fixtures/atelier-trackable/request/valid/document.json +22 -0
  217. package/fixtures/boundary-delegations/invalid/authority-key.v1.json +30 -0
  218. package/fixtures/boundary-delegations/invalid/push-operation.v1.json +28 -0
  219. package/fixtures/boundary-delegations/invalid/unsigned.v1.json +25 -0
  220. package/fixtures/boundary-delegations/valid/empty.v1.json +4 -0
  221. package/fixtures/boundary-delegations/valid/signed-operator.v1.json +30 -0
  222. package/fixtures/boundary-owner-keys/invalid/wrong-algorithm.v1.json +15 -0
  223. package/fixtures/boundary-owner-keys/invalid/wrong-curve.v1.json +15 -0
  224. package/fixtures/boundary-owner-keys/valid/one-owner.v1.json +17 -0
  225. package/fixtures/capability-packages/adoption.example.json +70 -0
  226. package/fixtures/capability-packages/evidence-review/capability-package.json +58 -0
  227. package/fixtures/capability-packages/evidence-review/capability-release.json +84 -0
  228. package/fixtures/capability-packages/evidence-review/evaluation.md +15 -0
  229. package/fixtures/capability-packages/evidence-review/skills/evidence-review/SKILL.md +22 -0
  230. package/fixtures/capability-packages/evidence-review/skills/evidence-review/references/ledger.md +7 -0
  231. package/fixtures/capability-packages/research/capability-package.json +88 -0
  232. package/fixtures/capability-packages/research/capability-release.json +134 -0
  233. package/fixtures/capability-packages/research/evaluation.md +15 -0
  234. package/fixtures/capability-packages/research/skills/research-integrate/SKILL.md +29 -0
  235. package/fixtures/capability-packages/research/skills/research-integrate/references/integration.md +10 -0
  236. package/fixtures/capability-packages/research/skills/research-plan/SKILL.md +27 -0
  237. package/fixtures/capability-packages/research/skills/research-plan/references/inquiries.md +26 -0
  238. package/fixtures/capability-packages/research/skills/research-synthesize/SKILL.md +23 -0
  239. package/fixtures/capability-packages/research/skills/research-synthesize/references/synthesis.md +12 -0
  240. package/fixtures/evidence-navigation/invalid/duplicate-operation.json +16 -0
  241. package/fixtures/evidence-navigation/invalid/instruction-kind.json +36 -0
  242. package/fixtures/evidence-navigation/invalid/invalid-digest.json +36 -0
  243. package/fixtures/evidence-navigation/invalid/invalid-time.json +36 -0
  244. package/fixtures/evidence-navigation/invalid/missing-attribution.json +35 -0
  245. package/fixtures/evidence-navigation/invalid/reserved-extension.json +19 -0
  246. package/fixtures/evidence-navigation/invalid/unattributed-acceptance.json +36 -0
  247. package/fixtures/evidence-navigation/invalid/uncoerced-version.json +16 -0
  248. package/fixtures/evidence-navigation/invalid/unknown-currency.json +17 -0
  249. package/fixtures/evidence-navigation/invalid/unknown-selector-field.json +37 -0
  250. package/fixtures/evidence-navigation/invalid/unsupported-operation.json +15 -0
  251. package/fixtures/evidence-navigation/valid/claim.json +36 -0
  252. package/fixtures/evidence-navigation/valid/host.json +16 -0
  253. package/fixtures/evidence-navigation/valid/profile.json +16 -0
  254. package/fixtures/evidence-navigation/valid/snapshot.json +17 -0
  255. package/fixtures/harness-packages/build-harness/capability-package.json +61 -0
  256. package/fixtures/harness-packages/build-harness/capability-release.json +82 -0
  257. package/fixtures/harness-packages/build-harness/evaluation.md +3 -0
  258. package/fixtures/harness-packages/build-harness/skills/atelier-build-harness/SKILL.md +59 -0
  259. package/fixtures/harness-packages/knowledge-harness/capability-package.json +61 -0
  260. package/fixtures/harness-packages/knowledge-harness/capability-release.json +82 -0
  261. package/fixtures/harness-packages/knowledge-harness/evaluation.md +3 -0
  262. package/fixtures/harness-packages/knowledge-harness/skills/atelier-knowledge-harness/SKILL.md +60 -0
  263. package/fixtures/harnesses/learning-cycle.json +780 -0
  264. package/fixtures/inquiry/workshop.json +675 -0
  265. package/fixtures/inquiry-packages/discovery-harness/capability-package.json +61 -0
  266. package/fixtures/inquiry-packages/discovery-harness/capability-release.json +82 -0
  267. package/fixtures/inquiry-packages/discovery-harness/evaluation.md +3 -0
  268. package/fixtures/inquiry-packages/discovery-harness/skills/atelier-discovery-harness/SKILL.md +48 -0
  269. package/fixtures/inquiry-packages/research-harness/capability-package.json +61 -0
  270. package/fixtures/inquiry-packages/research-harness/capability-release.json +82 -0
  271. package/fixtures/inquiry-packages/research-harness/evaluation.md +3 -0
  272. package/fixtures/inquiry-packages/research-harness/skills/atelier-research-harness/SKILL.md +43 -0
  273. package/fixtures/knowledge-health/calibration-suite.json +26 -0
  274. package/fixtures/knowledge-health/calibration-trial.json +37 -0
  275. package/fixtures/knowledge-health/corrected/checklist.md +18 -0
  276. package/fixtures/knowledge-health/devday/participant-record.json +19 -0
  277. package/fixtures/knowledge-health/expected-summary.json +27 -0
  278. package/fixtures/knowledge-health/workshop/README.md +20 -0
  279. package/fixtures/knowledge-health/workshop/atelier.project.json +7 -0
  280. package/fixtures/knowledge-health/workshop/knowledge-plan.json +65 -0
  281. package/fixtures/knowledge-health/workshop/records/checklist.md +18 -0
  282. package/fixtures/knowledge-health/workshop/records/workshop.md +18 -0
  283. package/fixtures/knowledge-health/workshop/repo-access.v1.json +5 -0
  284. package/fixtures/knowledge-health/workspace/atelier.project.json +18 -0
  285. package/fixtures/knowledge-health/workspace/knowledge-plan.json +74 -0
  286. package/fixtures/knowledge-health/workspace/records/checklist.md +18 -0
  287. package/fixtures/knowledge-health/workspace/records/pilot.md +16 -0
  288. package/fixtures/knowledge-health/workspace/repo-access.v1.json +9 -0
  289. package/fixtures/personal-workspace/overlay.json +24 -0
  290. package/fixtures/templates/local-library.v1.json +76 -0
  291. package/fixtures/templates/upgrade-contracts/adoption-policy.v2/invalid.json +16 -0
  292. package/fixtures/templates/upgrade-contracts/adoption-policy.v2/valid.json +13 -0
  293. package/fixtures/templates/upgrade-contracts/migration.v3/invalid.json +24 -0
  294. package/fixtures/templates/upgrade-contracts/migration.v3/valid.json +21 -0
  295. package/fixtures/templates/upgrade-contracts/template-adoption.v1/invalid.json +60 -0
  296. package/fixtures/templates/upgrade-contracts/template-adoption.v1/valid.json +57 -0
  297. package/fixtures/templates/upgrade-contracts/upgrade-plan.v3/invalid.json +85 -0
  298. package/fixtures/templates/upgrade-contracts/upgrade-plan.v3/valid.json +82 -0
  299. package/package.json +60 -5
  300. package/plugins/obsidian/main.js +57 -8
  301. package/plugins/obsidian/manifest.json +1 -1
  302. package/skills/claude/atelier-build-harness/SKILL.md +59 -0
  303. package/skills/claude/atelier-coordination/SKILL.md +32 -0
  304. package/skills/claude/atelier-discovery-harness/SKILL.md +48 -0
  305. package/skills/claude/atelier-guided-upgrade/SKILL.md +29 -0
  306. package/skills/claude/atelier-interaction/SKILL.md +34 -0
  307. package/skills/claude/atelier-knowledge-harness/SKILL.md +60 -0
  308. package/skills/claude/atelier-knowledge-setup/SKILL.md +72 -0
  309. package/skills/claude/atelier-practical-judgment/SKILL.md +64 -0
  310. package/skills/claude/atelier-research-harness/SKILL.md +43 -0
  311. package/skills/claude/atelier-skill-steward/SKILL.md +53 -0
  312. package/skills/claude/atelier-trackables/SKILL.md +40 -0
  313. package/skills/codex/atelier-build-harness/SKILL.md +59 -0
  314. package/skills/codex/atelier-coordination/SKILL.md +32 -0
  315. package/skills/codex/atelier-discovery-harness/SKILL.md +48 -0
  316. package/skills/codex/atelier-guided-upgrade/SKILL.md +29 -0
  317. package/skills/codex/atelier-interaction/SKILL.md +34 -0
  318. package/skills/codex/atelier-knowledge-harness/SKILL.md +60 -0
  319. package/skills/codex/atelier-knowledge-setup/SKILL.md +72 -0
  320. package/skills/codex/atelier-practical-judgment/SKILL.md +64 -0
  321. package/skills/codex/atelier-research-harness/SKILL.md +43 -0
  322. package/skills/codex/atelier-skill-steward/SKILL.md +53 -0
  323. package/skills/codex/atelier-trackables/SKILL.md +40 -0
  324. package/src/architecture/catalog.mjs +355 -0
  325. package/src/architecture/index.mjs +93 -0
  326. package/src/boundary/content-rules.mjs +536 -69
  327. package/src/boundary/delegation-command.mjs +158 -0
  328. package/src/boundary/delegation.mjs +396 -0
  329. package/src/boundary/policy.mjs +185 -64
  330. package/src/build/git.mjs +18 -0
  331. package/src/build/index.mjs +3 -0
  332. package/src/build/ledger.mjs +88 -0
  333. package/src/capabilities/adoption.mjs +381 -0
  334. package/src/capabilities/evidence.mjs +140 -0
  335. package/src/capabilities/files.mjs +124 -0
  336. package/src/capabilities/index.mjs +4 -0
  337. package/src/capabilities/instructions.mjs +250 -0
  338. package/src/capabilities/package.mjs +136 -0
  339. package/src/cli/command-failure.mjs +36 -0
  340. package/src/cli/execute-command.mjs +16 -2
  341. package/src/cli/run.mjs +66 -9
  342. package/src/coauthor/store.mjs +43 -2
  343. package/src/collaboration/proposals.mjs +189 -0
  344. package/src/commands/architecture.mjs +27 -0
  345. package/src/commands/boundary.mjs +3 -0
  346. package/src/commands/capability.mjs +73 -0
  347. package/src/commands/enroll.mjs +3 -0
  348. package/src/commands/harness.mjs +79 -0
  349. package/src/commands/ingest.mjs +39 -0
  350. package/src/commands/init.mjs +4 -1
  351. package/src/commands/inquiry.mjs +66 -0
  352. package/src/commands/knowledge-semantic.mjs +128 -0
  353. package/src/commands/knowledge.mjs +119 -0
  354. package/src/commands/learn.mjs +61 -0
  355. package/src/commands/obsidian.mjs +772 -65
  356. package/src/commands/practice.mjs +24 -0
  357. package/src/commands/setup.mjs +22 -2
  358. package/src/commands/trackable.mjs +19 -0
  359. package/src/contracts/corpus.mjs +30 -0
  360. package/src/coordination/index.mjs +110 -0
  361. package/src/evidence-navigation/contracts.mjs +60 -0
  362. package/src/evidence-navigation/evaluate.mjs +69 -0
  363. package/src/evidence-navigation/generation.mjs +22 -0
  364. package/src/evidence-navigation/index.mjs +2 -0
  365. package/src/evidence-navigation/local.mjs +167 -0
  366. package/src/evidence-navigation/schema.generated.mjs +604 -0
  367. package/src/evidence-navigation/types.mjs +12 -0
  368. package/src/export/atelier-export-contract.mjs +35 -8
  369. package/src/graph/enroll.mjs +248 -0
  370. package/src/graph/graph.mjs +30 -6
  371. package/src/graph/knowledge-graph.mjs +77 -9
  372. package/src/harnesses/contracts.mjs +41 -0
  373. package/src/harnesses/exchange.mjs +154 -0
  374. package/src/harnesses/feedback.mjs +16 -0
  375. package/src/harnesses/history.mjs +37 -0
  376. package/src/harnesses/index.mjs +4 -0
  377. package/src/harnesses/store.mjs +29 -0
  378. package/src/ingestion/contracts.mjs +41 -0
  379. package/src/ingestion/evaluation.mjs +119 -0
  380. package/src/ingestion/optional/composition/index.mjs +171 -0
  381. package/src/ingestion/optional/evaluation/index.mjs +219 -0
  382. package/src/ingestion/optional/graphify/composition-binding.mjs +60 -0
  383. package/src/ingestion/optional/graphify/composition_receiving.py +185 -0
  384. package/src/ingestion/optional/graphify/graphify_supplier.py +938 -0
  385. package/src/ingestion/optional/graphify/json_bridge.py +163 -0
  386. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  387. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  388. package/src/ingestion/optional/graphify/query_receiving.py +209 -0
  389. package/src/ingestion/optional/index.mjs +9 -0
  390. package/src/ingestion/optional/jev/bridge.mjs +166 -0
  391. package/src/ingestion/optional/jev/composition-binding.mjs +116 -0
  392. package/src/ingestion/optional/jev/judgments.mjs +174 -0
  393. package/src/ingestion/optional/jev/native-translation.mjs +32 -0
  394. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/__init__.py +7 -0
  395. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/adapter.py +966 -0
  396. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/artifacts.py +101 -0
  397. package/src/ingestion/optional/lightrag/atelier_lightrag_supplier/projection.py +146 -0
  398. package/src/ingestion/optional/lightrag/composition-binding.mjs +67 -0
  399. package/src/ingestion/optional/vanilla/composition-binding.mjs +102 -0
  400. package/src/ingestion/optional/vanilla/index.mjs +218 -0
  401. package/src/ingestion/optional/vanilla/native-normalization.mjs +187 -0
  402. package/src/ingestion/processors.mjs +218 -0
  403. package/src/ingestion/semantic.mjs +203 -0
  404. package/src/ingestion/store.mjs +372 -0
  405. package/src/inquiry/beliefs.mjs +32 -0
  406. package/src/inquiry/contracts.mjs +23 -0
  407. package/src/inquiry/index.mjs +5 -0
  408. package/src/inquiry/ledger.mjs +135 -0
  409. package/src/inquiry/lenses.mjs +15 -0
  410. package/src/inquiry/projection.mjs +77 -0
  411. package/src/inquiry/store.mjs +35 -0
  412. package/src/intake/read-scope.mjs +12 -0
  413. package/src/intake/store.mjs +69 -6
  414. package/src/interaction/index.mjs +91 -0
  415. package/src/judgment/index.mjs +82 -0
  416. package/src/judgment/practice-evaluation.mjs +70 -0
  417. package/src/judgment/practice.mjs +144 -0
  418. package/src/knowledge/context.mjs +217 -0
  419. package/src/knowledge/index.mjs +5 -0
  420. package/src/knowledge/ingestion.mjs +79 -0
  421. package/src/knowledge/intake.mjs +33 -0
  422. package/src/knowledge/ledger.mjs +110 -0
  423. package/src/knowledge/participatory/guidance.mjs +36 -0
  424. package/src/knowledge/participatory/guided-contribution.mjs +44 -0
  425. package/src/knowledge/participatory/index.mjs +2 -0
  426. package/src/knowledge/participatory/installed-workshop.mjs +108 -0
  427. package/src/knowledge/plan.mjs +67 -0
  428. package/src/knowledge/projection.mjs +43 -0
  429. package/src/knowledge/semantic-operation.mjs +567 -0
  430. package/src/knowledge/sessions.mjs +467 -0
  431. package/src/knowledge/workspace.mjs +295 -0
  432. package/src/knowledge-health/assessment.mjs +165 -0
  433. package/src/knowledge-health/capabilities.mjs +350 -0
  434. package/src/knowledge-health/context.mjs +62 -0
  435. package/src/knowledge-health/example.mjs +47 -0
  436. package/src/knowledge-health/index.mjs +10 -0
  437. package/src/knowledge-health/owner-join.mjs +88 -0
  438. package/src/knowledge-health/proof/common.mjs +24 -0
  439. package/src/knowledge-health/proof/installed-check.mjs +100 -0
  440. package/src/knowledge-health/proof/native-readback.mjs +37 -0
  441. package/src/knowledge-health/proof/rehearse.mjs +68 -0
  442. package/src/knowledge-health/proof/validate-participant.mjs +17 -0
  443. package/src/knowledge-health/proof/verify-native-capture.mjs +14 -0
  444. package/src/knowledge-health/proof/workshop-check.mjs +36 -0
  445. package/src/knowledge-health/rule-identity.mjs +49 -0
  446. package/src/knowledge-health/workshop-composition.mjs +19 -0
  447. package/src/knowledge-health/workshop-profile.mjs +176 -0
  448. package/src/knowledge-health/workshop.mjs +99 -0
  449. package/src/learning/adapters.mjs +68 -0
  450. package/src/learning/assistance.mjs +127 -0
  451. package/src/learning/contracts.mjs +65 -0
  452. package/src/learning/store.mjs +272 -0
  453. package/src/personal-workspace/index.mjs +391 -0
  454. package/src/personal-workspace/selection.mjs +401 -0
  455. package/src/project/config.mjs +8 -0
  456. package/src/project/durable-state.mjs +3 -1
  457. package/src/project/file-class.mjs +1 -1
  458. package/src/project/path-match.mjs +2 -1
  459. package/src/projection/obsidian/edits/apply.mjs +55 -16
  460. package/src/projection/obsidian/materialize/prepare-view.mjs +87 -5
  461. package/src/projection/obsidian/personal-workspace.mjs +519 -0
  462. package/src/projection/obsidian/proposals/adapter.mjs +79 -17
  463. package/src/projection/obsidian/proposals/observation.mjs +50 -4
  464. package/src/projection/obsidian/publication/publisher.mjs +214 -18
  465. package/src/projection/obsidian/publication/vault-list.mjs +4 -3
  466. package/src/projection/obsidian/recovery/index.mjs +4 -1
  467. package/src/projection/obsidian/recovery/store.mjs +160 -7
  468. package/src/projection/obsidian/selection-ui/contribution.mjs +6 -5
  469. package/src/projection/project.mjs +3 -2
  470. package/src/readiness/readiness.mjs +10 -3
  471. package/src/reflection/index.mjs +56 -0
  472. package/src/runtime/obsidian/app-production-seams.mjs +48 -16
  473. package/src/runtime/obsidian/app-registration.mjs +175 -28
  474. package/src/runtime/obsidian/app-restart.mjs +154 -0
  475. package/src/runtime/obsidian/engine.mjs +437 -56
  476. package/src/runtime/obsidian/extension-points.mjs +1 -1
  477. package/src/runtime/obsidian/index.mjs +10 -7
  478. package/src/runtime/obsidian/launch-plan.mjs +7 -4
  479. package/src/runtime/obsidian/lifecycle.mjs +108 -23
  480. package/src/runtime/obsidian/login-item.mjs +335 -0
  481. package/src/runtime/obsidian/machine-settings.mjs +119 -12
  482. package/src/runtime/obsidian/observation.mjs +36 -4
  483. package/src/runtime/obsidian/opening.mjs +173 -38
  484. package/src/runtime/obsidian/personal-selection.mjs +125 -0
  485. package/src/runtime/obsidian/pipeline.mjs +115 -6
  486. package/src/runtime/obsidian/plugin-channel.mjs +11 -3
  487. package/src/runtime/obsidian/plugin-choice.mjs +16 -4
  488. package/src/runtime/obsidian/plugin-drift.mjs +59 -11
  489. package/src/runtime/obsidian/plugin-presence.mjs +14 -1
  490. package/src/runtime/obsidian/private-lock.mjs +39 -4
  491. package/src/runtime/obsidian/process-identity.mjs +20 -10
  492. package/src/runtime/obsidian/project-views.mjs +282 -0
  493. package/src/runtime/obsidian/questions.mjs +48 -0
  494. package/src/runtime/obsidian/release-watch.mjs +56 -0
  495. package/src/runtime/obsidian/service-main.mjs +148 -19
  496. package/src/runtime/obsidian/service-manager-production.mjs +61 -0
  497. package/src/runtime/obsidian/service-managers.mjs +275 -0
  498. package/src/runtime/obsidian/service-record.mjs +87 -23
  499. package/src/runtime/obsidian/service.mjs +102 -9
  500. package/src/runtime/obsidian/startup-adapters.mjs +59 -21
  501. package/src/runtime/obsidian/unheld-evidence.mjs +84 -0
  502. package/src/runtime/obsidian/vault-location.mjs +300 -0
  503. package/src/runtime/obsidian/view-counts.mjs +34 -0
  504. package/src/runtime/obsidian/view-permission.mjs +334 -0
  505. package/src/server/local-sidecar.mjs +169 -2
  506. package/src/server/server.mjs +28 -2
  507. package/src/template-bindings/project.mjs +92 -0
  508. package/src/templates/profile.mjs +225 -0
  509. package/src/trackables/domain.mjs +175 -0
  510. package/src/trackables/store.mjs +114 -0
  511. package/src/ui/control-system.mjs +201 -0
  512. package/src/ui/html-primitives.mjs +3 -0
  513. package/src/ui/knowledge-page.mjs +1388 -0
  514. package/src/upgrade/closure-diagnostics.mjs +135 -0
  515. package/src/upgrade/closure.mjs +649 -0
  516. package/src/upgrade/explanation.mjs +1 -1
  517. package/src/upgrade/template-participant.mjs +171 -0
  518. package/src/upgrade/transaction.mjs +72 -25
  519. package/src/upgrade/upgrade.mjs +20 -7
  520. package/templates/external-project-workspace/pack-lifecycle.json +7 -1
  521. package/templates/knowledge-workspace/.gitattributes +2 -0
  522. package/templates/knowledge-workspace/AGENTS.md +27 -0
  523. package/templates/knowledge-workspace/README.md +44 -0
  524. package/templates/knowledge-workspace/atelier.project.json +22 -0
  525. package/templates/knowledge-workspace/gitignore +6 -0
  526. package/templates/knowledge-workspace/knowledge-plan.json +111 -0
  527. package/templates/knowledge-workspace/knowledge-plan.schema.json +281 -0
  528. package/templates/knowledge-workspace/records/forecast.md +22 -0
  529. package/templates/knowledge-workspace/records/inspection.md +25 -0
  530. package/templates/knowledge-workspace/records/telescope.md +24 -0
  531. package/templates/knowledge-workspace/repo-access.v1.json +9 -0
@@ -1,10 +1,14 @@
1
1
  import { spawnSync } from 'node:child_process'
2
+ import { createHash } from 'node:crypto'
2
3
  import fs from 'node:fs'
3
4
  import path from 'node:path'
4
5
  import { matchesPathPattern, normalizeRelPath } from '../project/path-match.mjs'
5
6
  import { sanitizedGitEnvironment } from '../runtime/git-adapter.mjs'
6
7
 
7
8
  export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
9
+ const EMPTY_TREES = Object.freeze({ sha1: EMPTY_TREE, sha256: '6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321' })
10
+ // Commits one push may carry before the per-commit scan refuses rather than run long.
11
+ export const PUSH_COMMIT_SCAN_MAX = 1000
8
12
  export const ZERO_SHA_RE = /^0{40,}$/
9
13
 
10
14
  export const RULE_KINDS = Object.freeze(['content', 'path'])
@@ -62,8 +66,9 @@ export function validateContentRules(rules, { label = 'contentRules' } = {}) {
62
66
  continue
63
67
  }
64
68
  for (const key of Object.keys(rule)) {
65
- if (!['id', 'kind', 'severity', 'pattern', 'paths', 'description'].includes(key)) errors.push(`${at} must not include additional property ${key}`)
69
+ if (!['id', 'kind', 'severity', 'pattern', 'paths', 'description', 'ext'].includes(key)) errors.push(`${at} must not include additional property ${key}`)
66
70
  }
71
+ if (rule.ext != null && !isObject(rule.ext)) errors.push(`${at}.ext must be an object`)
67
72
  const id = trimmed(rule.id)
68
73
  if (!id) errors.push(`${at}.id is required`)
69
74
  else if (seen.has(id)) errors.push(`${at}.id duplicates an earlier rule: ${id}`)
@@ -96,8 +101,9 @@ export function validateContentRuleExceptions(exceptions, rules = DEFAULT_CONTEN
96
101
  continue
97
102
  }
98
103
  for (const key of Object.keys(exception)) {
99
- if (!['rule', 'repo', 'paths', 'reason'].includes(key)) errors.push(`${at} must not include additional property ${key}`)
104
+ if (!['rule', 'repo', 'paths', 'reason', 'ext'].includes(key)) errors.push(`${at} must not include additional property ${key}`)
100
105
  }
106
+ if (exception.ext != null && !isObject(exception.ext)) errors.push(`${at}.ext must be an object`)
101
107
  const rule = trimmed(exception.rule)
102
108
  if (!rule) errors.push(`${at}.rule is required; an exception must name the rule it excepts`)
103
109
  else if (ruleIds.size && !ruleIds.has(rule)) errors.push(`${at}.rule "${rule}" is not a declared content rule`)
@@ -126,6 +132,9 @@ export function resolveContentRules(policy) {
126
132
  }
127
133
 
128
134
  export function findException({ exceptions = [], rule, repo, path: filePath }) {
135
+ // Exception paths are slash-normalised patterns, so they cannot name a file whose
136
+ // own name holds a backslash; such a file never inherits a sibling's exception.
137
+ if (String(filePath ?? '').includes('\\')) return null
129
138
  const rel = normalizeRelPath(filePath)
130
139
  return (
131
140
  exceptions.find(
@@ -139,6 +148,8 @@ export function findException({ exceptions = [], rule, repo, path: filePath }) {
139
148
 
140
149
  function rulePathMatches(rule, filePath) {
141
150
  if (!Array.isArray(rule.paths) || !rule.paths.length) return true
151
+ // Path scopes are slash-normalised too; a backslash name is treated as in scope.
152
+ if (String(filePath ?? '').includes('\\')) return true
142
153
  return rule.paths.some((pattern) => matchesPathPattern(pattern, filePath))
143
154
  }
144
155
 
@@ -152,13 +163,15 @@ function rulePathMatches(rule, filePath) {
152
163
  export function scanAddedContent({ files, rules = DEFAULT_CONTENT_RULES, exceptions = [], repo = null }) {
153
164
  const findings = []
154
165
  for (const file of files) {
155
- const filePath = normalizeRelPath(file.path)
166
+ // Paths from Git are exact repository paths and are reported as they are.
167
+ const filePath = String(file.path ?? '')
156
168
  for (const rule of rules) {
157
169
  const kind = rule.kind ?? 'content'
158
170
  const severity = rule.severity ?? 'error'
159
171
  const exception = findException({ exceptions, rule: rule.id, repo, path: filePath })
160
172
  if (kind === 'path') {
161
- if (!file.added || !new RegExp(rule.pattern, 'i').test(filePath)) continue
173
+ const pathRule = new RegExp(rule.pattern, 'i')
174
+ if (!file.added || !(pathRule.test(filePath) || pathRule.test(normalizeRelPath(filePath)))) continue
162
175
  if (exception) continue
163
176
  findings.push({ severity, code: 'content-rule-violation', rule: rule.id, repo, path: filePath, line: null, message: `${repo ?? ''}/${filePath}: ${rule.description ?? rule.id}`.replace(/^\//, '') })
164
177
  continue
@@ -168,7 +181,7 @@ export function scanAddedContent({ files, rules = DEFAULT_CONTENT_RULES, excepti
168
181
  for (const line of file.addedLines ?? []) {
169
182
  if (!re.test(line.text)) continue
170
183
  if (exception) continue
171
- findings.push({
184
+ const found = {
172
185
  severity,
173
186
  code: 'content-rule-violation',
174
187
  rule: rule.id,
@@ -176,45 +189,242 @@ export function scanAddedContent({ files, rules = DEFAULT_CONTENT_RULES, excepti
176
189
  path: filePath,
177
190
  line: line.number,
178
191
  message: `${repo ?? ''}/${filePath}:${line.number}: ${rule.description ?? rule.id}`.replace(/^\//, ''),
179
- })
192
+ }
193
+ // A digest of the line identifies the same content across diffs; it is
194
+ // not enumerable, so it never appears in a report.
195
+ Object.defineProperty(found, 'lineDigest', { value: createHash('sha256').update(line.text).digest('hex') })
196
+ findings.push(found)
180
197
  }
181
198
  }
182
199
  }
183
200
  return findings
184
201
  }
185
202
 
186
- /** Parse `git diff` output into added files and added lines with line numbers. */
187
- export function parseAddedContent(diff) {
188
- const files = []
203
+ // The diff commands below pin their own output format, so repository or user
204
+ // configuration (prefixes, colour, external diff drivers, textconv, relative
205
+ // paths, fused hunks, submodule summaries) cannot change what the parser reads.
206
+ // Anything it still cannot read is reported and blocks, so a key not pinned here
207
+ // can stop a commit but cannot hide content.
208
+ export const CANONICAL_DIFF_FLAGS = Object.freeze([
209
+ '--no-ext-diff',
210
+ '--no-textconv',
211
+ '--no-color',
212
+ '--src-prefix=a/',
213
+ '--dst-prefix=b/',
214
+ '--no-relative',
215
+ '--inter-hunk-context=0',
216
+ '--submodule=short',
217
+ '--full-index',
218
+ ])
219
+
220
+ const OCTAL_ESCAPE = /^[0-7]{3}$/
221
+ const SIMPLE_ESCAPES = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, '\\': 92 }
222
+
223
+ /**
224
+ * Read one C-quoted path token as Git writes it (core.quotePath), starting at the
225
+ * opening quote. Returns the decoded path and the index after the closing quote,
226
+ * or null when the token is malformed.
227
+ */
228
+ function readQuotedToken(text, start) {
229
+ if (text[start] !== '"') return null
230
+ const bytes = []
231
+ for (let index = start + 1; index < text.length; index += 1) {
232
+ const char = String.fromCodePoint(text.codePointAt(index))
233
+ if (char === '"') return { value: Buffer.from(bytes).toString('utf8'), end: index + 1 }
234
+ if (char !== '\\') {
235
+ bytes.push(...Buffer.from(char, 'utf8'))
236
+ index += char.length - 1
237
+ continue
238
+ }
239
+ const next = text[index + 1]
240
+ if (next === undefined) return null
241
+ const octal = text.slice(index + 1, index + 4)
242
+ if (OCTAL_ESCAPE.test(octal)) {
243
+ const byte = Number.parseInt(octal, 8)
244
+ if (byte > 255) return null
245
+ bytes.push(byte)
246
+ index += 3
247
+ } else if (Object.hasOwn(SIMPLE_ESCAPES, next)) {
248
+ bytes.push(SIMPLE_ESCAPES[next])
249
+ index += 1
250
+ } else {
251
+ return null
252
+ }
253
+ }
254
+ return null
255
+ }
256
+
257
+ /** A path as it appears after a fixed prefix in a header line, quoted or bare. */
258
+ function prefixedPath(token, prefix) {
259
+ if (token.startsWith('"')) {
260
+ const quoted = readQuotedToken(token, 0)
261
+ if (!quoted || quoted.end !== token.length || !quoted.value.startsWith(prefix)) return null
262
+ return quoted.value.slice(prefix.length)
263
+ }
264
+ return token.startsWith(prefix) ? token.slice(prefix.length) : null
265
+ }
266
+
267
+ /**
268
+ * The destination path of a `diff --git` header. A bare header is ambiguous when
269
+ * a path contains " b/", so it is resolved only when both sides name the same
270
+ * path, which is every case except a rename or copy; those are resolved from the
271
+ * `rename to` / `copy to` lines that follow.
272
+ */
273
+ export function diffHeaderPath(line) {
274
+ if (!line.startsWith('diff --git ')) return null
275
+ const rest = line.slice('diff --git '.length)
276
+ if (rest.startsWith('"')) {
277
+ const first = readQuotedToken(rest, 0)
278
+ if (!first || rest[first.end] !== ' ') return null
279
+ return prefixedPath(rest.slice(first.end + 1), 'b/')
280
+ }
281
+ if (rest.endsWith('"')) {
282
+ const open = rest.lastIndexOf(' "')
283
+ return open < 0 ? null : prefixedPath(rest.slice(open + 1), 'b/')
284
+ }
285
+ if (rest.length < 5 || (rest.length - 5) % 2 !== 0) return null
286
+ const half = (rest.length - 5) / 2
287
+ const candidate = rest.slice(2, 2 + half)
288
+ return half > 0 && rest === `a/${candidate} b/${candidate}` ? candidate : null
289
+ }
290
+
291
+ const HUNK_HEADER = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
292
+ const INDEX_LINE = /^index ([0-9a-f]{40,64})\.\.([0-9a-f]{40,64})(?: \d+)?$/
293
+
294
+ /**
295
+ * Split `git diff --unified=0` output into file sections. Lines are read as
296
+ * content only inside a hunk, for exactly as many lines as its header counts, so
297
+ * content can never be read as a header and a header can never hide content. A
298
+ * section whose path or hunks cannot be read is returned with `incomplete` set,
299
+ * so the caller fails closed instead of skipping it. Paths are the exact
300
+ * repository paths Git reports; `newBlob` is the object id of the new content.
301
+ * With `detail`, each section also keeps its hunks (added and removed text) and
302
+ * the raw lines after its header.
303
+ */
304
+ export function parseDiffSections(diff, { detail = false } = {}) {
305
+ const sections = []
189
306
  let current = null
307
+ let pendingAdded = 0
308
+ let pendingRemoved = 0
190
309
  let lineNumber = 0
191
310
  for (const raw of String(diff ?? '').split('\n')) {
311
+ if (detail && current && !raw.startsWith('diff --git ') && !raw.startsWith('* Unmerged path ')) current.lines.push(raw)
312
+ if (pendingAdded > 0 || pendingRemoved > 0) {
313
+ if (raw.startsWith('+') && pendingAdded > 0) {
314
+ current.addedLines.push({ number: lineNumber, text: raw.slice(1) })
315
+ if (detail) current.hunks.at(-1).added.push(raw.slice(1))
316
+ lineNumber += 1
317
+ pendingAdded -= 1
318
+ continue
319
+ }
320
+ if (raw.startsWith('-') && pendingRemoved > 0) {
321
+ if (detail) current.hunks.at(-1).removed.push(raw.slice(1))
322
+ pendingRemoved -= 1
323
+ continue
324
+ }
325
+ if (raw.startsWith(' ') && pendingAdded > 0 && pendingRemoved > 0) {
326
+ // A context line counts on both sides; none is expected, but one is harmless.
327
+ lineNumber += 1
328
+ pendingAdded -= 1
329
+ pendingRemoved -= 1
330
+ continue
331
+ }
332
+ if (raw.startsWith('\\')) continue // ""
333
+ current.incomplete = 'hunk-shorter-than-its-header'
334
+ pendingAdded = 0
335
+ pendingRemoved = 0
336
+ }
192
337
  if (raw.startsWith('diff --git ')) {
193
- const header = raw.match(/^diff --git a\/(.*) b\/(.*)$/)
194
- current = { path: header ? normalizeRelPath(header[2]) : null, added: false, addedLines: [] }
195
- files.push(current)
338
+ const headerPath = diffHeaderPath(raw)
339
+ current = {
340
+ path: headerPath === null || headerPath === '' ? null : headerPath,
341
+ added: false,
342
+ deleted: false,
343
+ binary: false,
344
+ newBlob: null,
345
+ oldPath: null,
346
+ addedLines: [],
347
+ incomplete: null,
348
+ inHeader: true,
349
+ ...(detail ? { hunks: [], lines: [] } : {}),
350
+ }
351
+ sections.push(current)
196
352
  continue
197
353
  }
198
- if (!current) continue
199
- if (raw.startsWith('new file mode ')) {
200
- current.added = true
354
+ if (raw.startsWith('* Unmerged path ')) {
355
+ // An unmerged entry has no patch to read; it stands as its own unreadable
356
+ // section instead of being taken for a line of the file before it. Git
357
+ // prints this name as it is, never quoted.
358
+ const listed = raw.slice('* Unmerged path '.length)
359
+ current = {
360
+ path: listed !== '' ? listed : null,
361
+ added: false,
362
+ deleted: false,
363
+ binary: false,
364
+ newBlob: null,
365
+ oldPath: null,
366
+ addedLines: [],
367
+ incomplete: 'unmerged-path',
368
+ inHeader: false,
369
+ ...(detail ? { hunks: [], lines: [] } : {}),
370
+ }
371
+ sections.push(current)
201
372
  continue
202
373
  }
203
- const hunk = raw.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
374
+ if (!current) continue
375
+ if (raw.startsWith('\\')) continue
376
+ const hunk = raw.match(HUNK_HEADER)
204
377
  if (hunk) {
205
- lineNumber = Number(hunk[1])
378
+ current.inHeader = false
379
+ if (detail) current.hunks.push({ added: [], removed: [] })
380
+ pendingRemoved = hunk[1] === undefined ? 1 : Number(hunk[1])
381
+ lineNumber = Number(hunk[2])
382
+ pendingAdded = hunk[3] === undefined ? 1 : Number(hunk[3])
383
+ continue
384
+ }
385
+ if (raw.startsWith('Binary files ') && raw.endsWith(' differ')) {
386
+ current.inHeader = false
387
+ current.binary = true
206
388
  continue
207
389
  }
208
- if (/^\+(?!\+)/.test(raw)) {
209
- current.addedLines.push({ number: lineNumber, text: raw.slice(1) })
210
- lineNumber += 1
211
- } else if (/^-(?!-)/.test(raw)) {
212
- // removed lines do not advance the new-file line counter
213
- } else if (raw.startsWith(' ')) {
214
- lineNumber += 1
390
+ if (!current.inHeader) {
391
+ if (raw !== '') current.incomplete ??= 'unexpected-line-outside-a-hunk'
392
+ continue
393
+ }
394
+ const index = raw.match(INDEX_LINE)
395
+ if (index) {
396
+ current.newBlob = ZERO_SHA_RE.test(index[2]) ? null : index[2]
397
+ } else if (raw.startsWith('new file mode ')) current.added = true
398
+ else if (raw.startsWith('deleted file mode ')) current.deleted = true
399
+ else if (raw.startsWith('rename from ') || raw.startsWith('copy from ')) {
400
+ const source = raw.slice(raw.indexOf(' from ') + 6)
401
+ const named = source.startsWith('"') ? readQuotedToken(source, 0) : { value: source, end: source.length }
402
+ if (named && named.end === source.length && named.value !== '') current.oldPath = named.value
403
+ else current.incomplete ??= 'unreadable-rename-source'
404
+ } else if (raw.startsWith('rename to ') || raw.startsWith('copy to ')) {
405
+ const target = raw.slice(raw.indexOf(' to ') + 4)
406
+ const named = target.startsWith('"') ? readQuotedToken(target, 0) : { value: target, end: target.length }
407
+ if (named && named.end === target.length && named.value !== '') current.path = named.value
408
+ else current.incomplete ??= 'unreadable-rename-target'
409
+ } else if (raw.startsWith('+++ ')) {
410
+ const target = raw.slice(4)
411
+ if (target !== '/dev/null' && current.path === null) {
412
+ const named = prefixedPath(target, 'b/')
413
+ if (named !== null && named !== '') current.path = named
414
+ }
215
415
  }
216
416
  }
217
- return files.filter((file) => file.path)
417
+ if (pendingAdded > 0 || pendingRemoved > 0) current.incomplete = 'hunk-shorter-than-its-header'
418
+ for (const section of sections) {
419
+ delete section.inHeader
420
+ if (section.path === null) section.incomplete ??= 'unreadable-file-path'
421
+ }
422
+ return sections
423
+ }
424
+
425
+ /** Parse `git diff` output into added files and added lines with line numbers. */
426
+ export function parseAddedContent(diff) {
427
+ return addedFilesFrom(parseDiffSections(diff))
218
428
  }
219
429
 
220
430
  /** Ref updates arrive on the pre-push hook's stdin as `<localRef> <localSha> <remoteRef> <remoteSha>`. */
@@ -254,12 +464,16 @@ export function parsePushRefInput(text) {
254
464
  export function gitOutputResult(repoRoot, args, {
255
465
  encoding = 'utf8',
256
466
  gitExecutable = 'git',
467
+ indexFile = null,
257
468
  maxBuffer = DIFF_RESULT_MAX_BYTES,
258
469
  runner = spawnSync,
259
470
  } = {}) {
260
471
  let result
472
+ // A hook names the index being committed (`git commit -a` writes a temporary
473
+ // one); every other variable from the caller's Git environment is dropped.
474
+ const env = indexFile ? { ...sanitizedGitEnvironment(), GIT_INDEX_FILE: indexFile } : sanitizedGitEnvironment()
261
475
  try {
262
- result = runner(gitExecutable, ['-C', repoRoot, ...args], { encoding, maxBuffer, env: sanitizedGitEnvironment() })
476
+ result = runner(gitExecutable, ['-C', repoRoot, ...args], { encoding, maxBuffer, env })
263
477
  } catch (error) {
264
478
  return { ok: false, code: 'git-command-failed', error: error instanceof Error ? error.message : String(error), stdout: null }
265
479
  }
@@ -277,25 +491,34 @@ export function gitOutputResult(repoRoot, args, {
277
491
  return { ok: true, stdout, bytes }
278
492
  }
279
493
 
280
- export function pushRangeBase(repoRoot, remoteSha) {
494
+ /** The empty tree in this repository's object format (SHA-1 or SHA-256). */
495
+ export function emptyTreeFor(repoRoot, { runner = spawnSync } = {}) {
496
+ const result = gitOutputResult(repoRoot, ['rev-parse', '--show-object-format'], { maxBuffer: 1024, runner })
497
+ return (result.ok && EMPTY_TREES[result.stdout.trim()]) || EMPTY_TREE
498
+ }
499
+
500
+ export function pushRangeBase(repoRoot, remoteSha, { runner = spawnSync } = {}) {
281
501
  // A new branch has no remote counterpart, so everything it carries is new:
282
502
  // diff against the empty tree rather than silently scanning nothing.
283
- if (!remoteSha || ZERO_SHA_RE.test(remoteSha)) return EMPTY_TREE
284
- const result = spawnSync('git', ['-C', repoRoot, 'rev-parse', '--verify', '--quiet', `${remoteSha}^{commit}`], {
285
- encoding: 'utf8',
286
- maxBuffer: 1024 * 1024,
287
- })
288
- return result.status === 0 ? remoteSha : EMPTY_TREE
503
+ if (!remoteSha || ZERO_SHA_RE.test(remoteSha)) return emptyTreeFor(repoRoot, { runner })
504
+ const result = gitOutputResult(repoRoot, ['rev-parse', '--verify', '--quiet', `${remoteSha}^{commit}`], { maxBuffer: 1024 * 1024, runner })
505
+ return result.ok ? remoteSha : emptyTreeFor(repoRoot, { runner })
289
506
  }
290
507
 
508
+ export const canonicalDiffArgs = (revisions, extra = []) => ['diff', ...CANONICAL_DIFF_FLAGS, '--unified=0', ...extra, ...revisions]
509
+ // The content scan judges added lines only, so a deleted file's removed lines
510
+ // would just spend the output limit. The semantic review reads removed lines
511
+ // and keeps the plain form above.
512
+ const contentDiffArgs = (revisions, extra = []) => canonicalDiffArgs(revisions, ['--irreversible-delete', ...extra])
513
+
291
514
  export function diffForPushRange(repoRoot, { localSha, remoteSha }, options = {}) {
292
- const base = pushRangeBase(repoRoot, remoteSha)
293
- const result = gitOutputResult(repoRoot, ['diff', '--unified=0', base, localSha], options)
515
+ const base = pushRangeBase(repoRoot, remoteSha, { runner: options.runner })
516
+ const result = gitOutputResult(repoRoot, contentDiffArgs([base, localSha]), options)
294
517
  return result.ok ? { ...result, base, diff: result.stdout } : { ...result, base, diff: null }
295
518
  }
296
519
 
297
520
  export function stagedDiff(repoRoot, options = {}) {
298
- const result = gitOutputResult(repoRoot, ['diff', '--cached', '--unified=0'], options)
521
+ const result = gitOutputResult(repoRoot, contentDiffArgs(['--cached']), options)
299
522
  return result.ok ? { ...result, diff: result.stdout } : { ...result, diff: null }
300
523
  }
301
524
 
@@ -311,20 +534,21 @@ function incompleteDiagnostic({ repo, path: filePath = null, message, details =
311
534
  }
312
535
  }
313
536
 
314
- function binaryPathsFromDiff(diff) {
315
- const paths = new Set()
316
- let current = null
317
- for (const line of String(diff ?? '').split('\n')) {
318
- if (line.startsWith('diff --git ')) {
319
- const header = line.match(/^diff --git a\/(.*) b\/(.*)$/)
320
- current = header ? normalizeRelPath(header[2]) : null
321
- continue
322
- }
323
- if (current && (line.startsWith('Binary files ') || line === 'GIT binary patch')) paths.add(current)
324
- }
325
- return [...paths]
537
+ function unreadableSectionDiagnostics(sections, repo) {
538
+ return sections
539
+ .filter((section) => section.incomplete)
540
+ .map((section) => incompleteDiagnostic({
541
+ repo,
542
+ path: section.path,
543
+ message: 'a changed file could not be read from the diff, so its content was not scanned',
544
+ details: { reason: section.incomplete },
545
+ }))
326
546
  }
327
547
 
548
+ const addedFilesFrom = (sections) => sections
549
+ .filter((section) => section.path !== null)
550
+ .map(({ path: filePath, added, addedLines }) => ({ path: filePath, added, addedLines }))
551
+
328
552
  function isBinaryBuffer(buffer) {
329
553
  if (buffer.subarray(0, Math.min(buffer.length, 8192)).includes(0)) return true
330
554
  try {
@@ -336,7 +560,6 @@ function isBinaryBuffer(buffer) {
336
560
  }
337
561
 
338
562
  function scanBinaryBuffer({ buffer, filePath, rules, exceptions, repo }) {
339
- if (!isBinaryBuffer(buffer)) return []
340
563
  const text = buffer.toString('latin1')
341
564
  const findings = []
342
565
  for (const rule of rules) {
@@ -357,13 +580,100 @@ function scanBinaryBuffer({ buffer, filePath, rules, exceptions, repo }) {
357
580
  return findings
358
581
  }
359
582
 
360
- function readBinaryChanges({ repoRoot, revision, diff, rules, exceptions, repo, gitExecutable, gitRunner }) {
583
+ const DECLARED_TEXT_BATCH = 200
584
+ // A new file's diff can be twice its size (a "+" before every short line), so a
585
+ // batch holds at most half the diff limit in blob bytes.
586
+ const DECLARED_TEXT_BATCH_BYTES = DIFF_RESULT_MAX_BYTES / 2
587
+
588
+ /**
589
+ * Git reports a file as binary when its attributes say so, whatever it holds
590
+ * (for example `*.cfg binary` in .gitattributes, which can arrive with the tree),
591
+ * or when the content it replaces was binary.
592
+ * Such text is judged like any other text: by its added lines, taken from a
593
+ * `--text` diff of just those paths and their rename sources. Narrowing the
594
+ * paths can change how Git pairs renames and copies, so a file's lines are used
595
+ * only when it is paired exactly as in the main diff; otherwise, or when a batch
596
+ * cannot be read, the file is read again on its own. Pathspecs are taken
597
+ * literally: core.precomposeUnicode is off for this call, because it would
598
+ * rewrite a decomposed name on the command line and match nothing. Returns, per
599
+ * path, `{ addedLines }` or `{ cause }` naming why the text was not read.
600
+ */
601
+ function declaredBinaryTextLines({ repoRoot, revisions, entries, changedPaths = [], gitExecutable, gitRunner, indexFile = null }) {
602
+ const outcomes = new Map()
603
+ const read = (group) => {
604
+ const paths = [...new Set(group.flatMap(({ section }) => [section.path, section.oldPath].filter((value) => value && value !== '')))]
605
+ // A literal pathspec also matches a directory of that name, so a file that
606
+ // replaced a directory would bring the directory's changed files in as
607
+ // rename candidates and as output. Only changed entries can appear in a
608
+ // diff. Under a read path with nothing else read inside, one subtree
609
+ // exclusion covers them all; under one that holds another read path, the
610
+ // changed entries not read are excluded one by one. Usually there are none.
611
+ const reading = new Set(paths)
612
+ const excluded = new Set()
613
+ for (const read of paths) {
614
+ const under = changedPaths.filter((value) => value.startsWith(`${read}/`))
615
+ if (under.length === 0) continue
616
+ if (!paths.some((other) => other.startsWith(`${read}/`))) excluded.add(`${read}/`)
617
+ else for (const value of under) if (!reading.has(value)) excluded.add(value)
618
+ }
619
+ const result = gitOutputResult(repoRoot, [
620
+ '-c', 'core.precomposeUnicode=false',
621
+ ...contentDiffArgs(revisions, ['--text']),
622
+ '--',
623
+ ...paths.map((value) => `:(literal)${value}`),
624
+ ...[...excluded].map((value) => `:(exclude,literal)${value}`),
625
+ ], { gitExecutable, indexFile, runner: gitRunner })
626
+ const parsed = result.ok ? parseDiffSections(result.stdout) : []
627
+ for (const { section } of group) {
628
+ const matches = parsed.filter((candidate) => candidate.path === section.path && !candidate.deleted)
629
+ let cause = null
630
+ if (!result.ok) cause = result.code
631
+ else if (matches.length === 0) cause = 'missing-from-text-diff'
632
+ else if (matches.some((candidate) => candidate.oldPath !== section.oldPath)) cause = 'paired-differently-from-main-diff'
633
+ else cause = matches.find((candidate) => candidate.incomplete)?.incomplete ?? (matches.some((candidate) => candidate.binary) ? 'binary-in-text-diff' : null)
634
+ outcomes.set(section.path, cause ? { cause } : { addedLines: matches.flatMap((candidate) => candidate.addedLines) })
635
+ }
636
+ }
637
+ const groups = []
638
+ let group = []
639
+ let bytes = 0
640
+ for (const entry of entries) {
641
+ if (group.length > 0 && (group.length >= DECLARED_TEXT_BATCH || bytes + entry.bytes > DECLARED_TEXT_BATCH_BYTES)) {
642
+ groups.push(group)
643
+ group = []
644
+ bytes = 0
645
+ }
646
+ group.push(entry)
647
+ bytes += entry.bytes
648
+ }
649
+ if (group.length > 0) groups.push(group)
650
+ for (const batch of groups) {
651
+ read(batch)
652
+ if (batch.length === 1) continue
653
+ for (const entry of batch) if (outcomes.get(entry.section.path).cause) read([entry])
654
+ }
655
+ return outcomes
656
+ }
657
+
658
+ function readBinaryChanges({ repoRoot, revisions, sections, rules, exceptions, repo, gitExecutable, gitRunner, indexFile = null }) {
361
659
  const findings = []
362
660
  const diagnostics = []
363
661
  let totalBytes = 0
364
- for (const filePath of binaryPathsFromDiff(diff)) {
365
- const spec = revision === ':' ? `:${filePath}` : `${revision}:${filePath}`
366
- const result = gitOutputResult(repoRoot, ['show', spec], {
662
+ const blobs = []
663
+ const seen = new Set()
664
+ const declaredText = []
665
+ // A deleted path is absent from the revision being scanned, so there is nothing
666
+ // to read: the blob it had is already in history and is not being introduced.
667
+ for (const section of sections) {
668
+ if (!section.binary || section.deleted || section.path === null || seen.has(section.path)) continue
669
+ seen.add(section.path)
670
+ const filePath = section.path
671
+ if (!section.newBlob) {
672
+ diagnostics.push(incompleteDiagnostic({ repo, path: filePath, message: 'binary content could not be identified in the diff', details: { reason: 'missing-blob-id' } }))
673
+ continue
674
+ }
675
+ // Read by object id: a path can be misread (":0:name" names an index stage).
676
+ const result = gitOutputResult(repoRoot, ['cat-file', 'blob', section.newBlob], {
367
677
  encoding: 'buffer',
368
678
  gitExecutable,
369
679
  maxBuffer: BINARY_FILE_MAX_BYTES + 1,
@@ -388,6 +698,7 @@ function readBinaryChanges({ repoRoot, revision, diff, rules, exceptions, repo,
388
698
  continue
389
699
  }
390
700
  totalBytes += result.bytes
701
+ blobs.push({ id: section.newBlob, bytes: result.bytes })
391
702
  if (totalBytes > BINARY_AGGREGATE_MAX_BYTES) {
392
703
  diagnostics.push(incompleteDiagnostic({
393
704
  repo,
@@ -397,9 +708,33 @@ function readBinaryChanges({ repoRoot, revision, diff, rules, exceptions, repo,
397
708
  }))
398
709
  break
399
710
  }
400
- findings.push(...scanBinaryBuffer({ buffer: result.stdout, filePath, rules, exceptions, repo }))
711
+ if (isBinaryBuffer(result.stdout)) {
712
+ findings.push(...scanBinaryBuffer({ buffer: result.stdout, filePath, rules, exceptions, repo }))
713
+ continue
714
+ }
715
+ declaredText.push({ section, bytes: result.bytes })
716
+ }
717
+ if (declaredText.length > 0) {
718
+ const changedPaths = [...new Set(sections.flatMap((section) => [section.path, section.oldPath]).filter((value) => value && value !== ''))]
719
+ const outcomes = declaredBinaryTextLines({ repoRoot, revisions, entries: declaredText, changedPaths, gitExecutable, gitRunner, indexFile })
720
+ const contentRules = rules.filter((rule) => (rule.kind ?? 'content') === 'content')
721
+ for (const { section } of declaredText) {
722
+ const { addedLines, cause } = outcomes.get(section.path)
723
+ if (cause) {
724
+ diagnostics.push(incompleteDiagnostic({
725
+ repo,
726
+ path: section.path,
727
+ message: cause === 'git-output-limit-exceeded'
728
+ ? `text that Git reports as binary has a diff over the ${DIFF_RESULT_MAX_BYTES}-byte limit, so its content was not scanned`
729
+ : 'text that Git reports as binary could not be read as text, so its content was not scanned',
730
+ details: { reason: 'declared-binary-text-unreadable', cause },
731
+ }))
732
+ continue
733
+ }
734
+ findings.push(...scanAddedContent({ files: [{ path: section.path, added: false, addedLines }], rules: contentRules, exceptions, repo }))
735
+ }
401
736
  }
402
- return { findings, diagnostics, bytes: totalBytes }
737
+ return { findings, diagnostics, bytes: totalBytes, blobs }
403
738
  }
404
739
 
405
740
  export function scanStagedRepository({
@@ -409,8 +744,9 @@ export function scanStagedRepository({
409
744
  repo = null,
410
745
  gitExecutable = 'git',
411
746
  gitRunner = spawnSync,
747
+ indexFile = null,
412
748
  } = {}) {
413
- const acquired = stagedDiff(repoRoot, { gitExecutable, runner: gitRunner })
749
+ const acquired = stagedDiff(repoRoot, { gitExecutable, indexFile, runner: gitRunner })
414
750
  if (!acquired.ok) {
415
751
  return {
416
752
  findings: [],
@@ -418,38 +754,169 @@ export function scanStagedRepository({
418
754
  bytes: 0,
419
755
  }
420
756
  }
421
- const files = parseAddedContent(acquired.diff)
422
- const binary = readBinaryChanges({ repoRoot, revision: ':', diff: acquired.diff, rules, exceptions, repo, gitExecutable, gitRunner })
757
+ const sections = parseDiffSections(acquired.diff)
758
+ const binary = readBinaryChanges({ repoRoot, revisions: ['--cached'], sections, rules, exceptions, repo, gitExecutable, gitRunner, indexFile })
423
759
  return {
424
- findings: [...scanAddedContent({ files, rules, exceptions, repo }), ...binary.findings],
425
- diagnostics: binary.diagnostics,
760
+ findings: [...scanAddedContent({ files: addedFilesFrom(sections), rules, exceptions, repo }), ...binary.findings],
761
+ diagnostics: [...unreadableSectionDiagnostics(sections, repo), ...binary.diagnostics],
426
762
  bytes: acquired.bytes + binary.bytes,
427
763
  }
428
764
  }
429
765
 
766
+ function scanRevisionRange({ repoRoot, base, tip, rules, exceptions, repo, gitRunner }) {
767
+ const acquired = gitOutputResult(repoRoot, contentDiffArgs([base, tip]), { runner: gitRunner })
768
+ if (!acquired.ok) return { acquired, keyed: [], diagnostics: [], bytes: 0, blobs: [] }
769
+ const sections = parseDiffSections(acquired.stdout)
770
+ const binary = readBinaryChanges({ repoRoot, revisions: [base, tip], sections, rules, exceptions, repo, gitRunner })
771
+ const findings = [...scanAddedContent({ files: addedFilesFrom(sections), rules, exceptions, repo }), ...binary.findings]
772
+ return {
773
+ acquired,
774
+ keyed: findings.map((item) => [findingKey(item), item]),
775
+ diagnostics: [...unreadableSectionDiagnostics(sections, repo), ...binary.diagnostics],
776
+ bytes: acquired.bytes + binary.bytes,
777
+ blobs: binary.blobs,
778
+ }
779
+ }
780
+
781
+ // What a finding reports: its rule, path and the digest of its line (binary
782
+ // content has no line), so the same content is recognised in any diff.
783
+ function findingKey(finding) {
784
+ return [finding.rule, finding.path, finding.lineDigest ?? 'binary'].join('\0')
785
+ }
786
+
787
+ /**
788
+ * What one commit adds. A merge adds only what is new against every parent:
789
+ * a side parent's own commits are judged where they were made, and content the
790
+ * remote already holds is not judged again. A finding is dropped only when a
791
+ * fully read diff against another parent shows that parent already had it, and
792
+ * one fully read parent diff covers everything new against all parents.
793
+ */
794
+ function scanCommit({ repoRoot, commit, parents, empty, rules, exceptions, repo, gitRunner }) {
795
+ if (parents.length <= 1) return scanRevisionRange({ repoRoot, base: parents[0] ?? empty, tip: commit, rules, exceptions, repo, gitRunner })
796
+ const read = parents
797
+ .map((parent) => scanRevisionRange({ repoRoot, base: parent, tip: commit, rules, exceptions, repo, gitRunner }))
798
+ .filter((scan) => scan.acquired.ok)
799
+ if (read.length === 0) return { acquired: { ok: false, code: 'merge-unreadable' }, keyed: [], diagnostics: [], bytes: 0, blobs: [] }
800
+ const complete = read.filter((scan) => scan.diagnostics.length === 0)
801
+ // Any complete parent diff yields the same findings; the smallest is the one
802
+ // that does not re-read what the other parent already published.
803
+ const reference = complete.length > 0 ? complete.reduce((small, scan) => (scan.bytes < small.bytes ? scan : small)) : read[0]
804
+ const keyed = reference.keyed.filter(([key]) => complete.every((scan) => scan === reference || scan.keyed.some(([other]) => other === key)))
805
+ const diagnostics = complete.length > 0 ? [] : read.flatMap((scan) => scan.diagnostics)
806
+ // Evidence counts once, as the diff the commit is judged by.
807
+ return { acquired: reference.acquired, keyed, diagnostics, bytes: reference.bytes, blobs: reference.blobs }
808
+ }
809
+
810
+ const PLAIN_REMOTE_NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/
811
+
812
+ /**
813
+ * Which tracking refs already hold what this push carries. The installed hook
814
+ * passes the remote Git names, which is a configured remote or a URL; a URL is
815
+ * matched to the remote configured with it. Only that remote's tracking refs
816
+ * exclude commits, so history held only by another remote is judged on its way
817
+ * here. A URL no remote is configured with excludes nothing but the remote's
818
+ * own commit. With no remote named (a hook installed before this), every
819
+ * remote's tracking refs exclude, as before.
820
+ */
821
+ function remoteScope(repoRoot, remote, runner) {
822
+ if (typeof remote !== 'string' || remote === '') return { args: ['--remotes'], label: 'no remote holds' }
823
+ const configured = gitOutputResult(repoRoot, ['config', '--get-regexp', '^remote\\..*\\.url$'], { maxBuffer: 1024 * 1024, runner })
824
+ const remotes = configured.ok
825
+ ? configured.stdout.split('\n').filter(Boolean).map((line) => {
826
+ const space = line.indexOf(' ')
827
+ return { name: line.slice('remote.'.length, space).replace(/\.url$/, ''), url: line.slice(space + 1) }
828
+ })
829
+ : []
830
+ const named = remotes.find((item) => item.name === remote) ?? remotes.find((item) => item.url === remote)
831
+ if (named && PLAIN_REMOTE_NAME.test(named.name)) {
832
+ // `--remotes=origin` would also take a remote named `origin/x`; leave those out.
833
+ const nested = remotes.filter((item) => item.name.startsWith(`${named.name}/`)).map((item) => `--exclude=${item.name}/*`)
834
+ return { args: [...nested, `--remotes=${named.name}`], label: `${named.name} does not hold` }
835
+ }
836
+ return { args: [], label: `${remote} does not hold` }
837
+ }
838
+
430
839
  export function scanPushUpdate({
431
840
  repoRoot,
432
841
  update,
433
842
  rules = DEFAULT_CONTENT_RULES,
434
843
  exceptions = [],
435
844
  repo = null,
845
+ remote = null,
436
846
  gitRunner = spawnSync,
847
+ maxCommits = PUSH_COMMIT_SCAN_MAX,
848
+ maxBytes = CHECK_AGGREGATE_MAX_BYTES,
437
849
  } = {}) {
438
- const acquired = diffForPushRange(repoRoot, update, { runner: gitRunner })
439
- if (!acquired.ok) {
850
+ const base = pushRangeBase(repoRoot, update.remoteSha, { runner: gitRunner })
851
+ const net = scanRevisionRange({ repoRoot, base, tip: update.localSha, rules, exceptions, repo, gitRunner })
852
+ if (!net.acquired.ok) {
440
853
  return {
441
854
  findings: [],
442
- diagnostics: [incompleteDiagnostic({ repo, message: 'push evidence could not be acquired', details: { reason: acquired.code } })],
855
+ diagnostics: [incompleteDiagnostic({ repo, message: 'push evidence could not be acquired', details: { reason: net.acquired.code } })],
443
856
  bytes: 0,
444
857
  }
445
858
  }
446
- const files = parseAddedContent(acquired.diff)
447
- const binary = readBinaryChanges({ repoRoot, revision: update.localSha, diff: acquired.diff, rules, exceptions, repo })
448
- return {
449
- findings: [...scanAddedContent({ files, rules, exceptions, repo }), ...binary.findings],
450
- diagnostics: binary.diagnostics,
451
- bytes: acquired.bytes + binary.bytes,
859
+ const reported = new Map(net.keyed)
860
+ const findings = net.keyed.map(([, item]) => item)
861
+ const diagnostics = [...net.diagnostics]
862
+ // Every path is judged wherever it appears, but each blob counts once toward
863
+ // the evidence budget, however many diffs read it.
864
+ const counted = new Set()
865
+ const evidence = (scan) => scan.bytes - scan.blobs.reduce((repeat, blob) => {
866
+ if (counted.has(blob.id)) return repeat + blob.bytes
867
+ counted.add(blob.id)
868
+ return repeat
869
+ }, 0)
870
+ let bytes = evidence(net)
871
+ const scope = remoteScope(repoRoot, remote, gitRunner)
872
+ // Parents before children, so a commit is judged before a merge that brings it in.
873
+ const listed = gitOutputResult(repoRoot, ['rev-list', '--topo-order', '--reverse', '--parents', `--max-count=${maxCommits + 1}`, update.localSha, '--not', ...(base === update.remoteSha ? [base] : []), ...scope.args], { runner: gitRunner })
874
+ if (!listed.ok) {
875
+ diagnostics.push(incompleteDiagnostic({ repo, message: 'the commits in this push could not be listed', details: { reason: listed.code } }))
876
+ return { findings, diagnostics, bytes }
877
+ }
878
+ const commits = listed.stdout.split('\n').filter(Boolean).map((line) => line.split(' '))
879
+ if (commits.length > maxCommits) {
880
+ diagnostics.push(incompleteDiagnostic({
881
+ repo,
882
+ message: `this push carries more than ${maxCommits} commits that ${scope.label}, more than are judged one by one; push it in smaller parts, an earlier commit first (git push <remote> <commit>:<ref>)`,
883
+ details: { reason: 'push-range-too-long', limit: maxCommits },
884
+ }))
885
+ return { findings, diagnostics, bytes }
886
+ }
887
+ // One commit straight on the remote's tip is the endpoint change already judged.
888
+ if (commits.length === 1 && commits[0].length === 2 && commits[0][1] === base) return { findings, diagnostics, bytes }
889
+ const empty = emptyTreeFor(repoRoot, { runner: gitRunner })
890
+ for (const [commit, ...parents] of commits) {
891
+ const step = scanCommit({ repoRoot, commit, parents, empty, rules, exceptions, repo, gitRunner })
892
+ const short = commit.slice(0, 12)
893
+ if (!step.acquired.ok) {
894
+ diagnostics.push(incompleteDiagnostic({ repo, message: `commit ${short} could not be read`, details: { reason: step.acquired.code, commit } }))
895
+ continue
896
+ }
897
+ bytes += evidence(step)
898
+ for (const [key, item] of step.keyed) {
899
+ const retained = reported.get(key)
900
+ if (retained) {
901
+ // Already reported: record where else it was published.
902
+ if (!retained.commit) retained.commits = [...(retained.commits ?? []), commit]
903
+ continue
904
+ }
905
+ const placed = { ...item, commit, message: `${item.message} (in commit ${short})` }
906
+ reported.set(key, placed)
907
+ findings.push(placed)
908
+ }
909
+ diagnostics.push(...step.diagnostics.map((item) => ({ ...item, message: `${item.message} (in commit ${short})`, details: { ...item.details, commit } })))
910
+ if (bytes > maxBytes) {
911
+ diagnostics.push(incompleteDiagnostic({
912
+ repo,
913
+ message: `push evidence exceeds the ${maxBytes}-byte aggregate limit; push it in smaller parts`,
914
+ details: { reason: 'push-evidence-too-large', limitBytes: maxBytes, commit },
915
+ }))
916
+ break
917
+ }
452
918
  }
919
+ return { findings, diagnostics, bytes }
453
920
  }
454
921
 
455
922
  /**