gitnexus 1.6.9 → 1.6.10-aptos.1

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 (540) hide show
  1. package/README.md +236 -53
  2. package/dist/_shared/graph/types.d.ts +67 -3
  3. package/dist/_shared/graph/types.d.ts.map +1 -1
  4. package/dist/_shared/index.d.ts +1 -0
  5. package/dist/_shared/index.d.ts.map +1 -1
  6. package/dist/_shared/index.js.map +1 -1
  7. package/dist/_shared/integrations/resilient-fetch.d.ts +3 -1
  8. package/dist/_shared/integrations/resilient-fetch.d.ts.map +1 -1
  9. package/dist/_shared/integrations/resilient-fetch.js +4 -3
  10. package/dist/_shared/integrations/resilient-fetch.js.map +1 -1
  11. package/dist/_shared/language-detection.d.ts.map +1 -1
  12. package/dist/_shared/language-detection.js +2 -0
  13. package/dist/_shared/language-detection.js.map +1 -1
  14. package/dist/_shared/languages.d.ts +2 -0
  15. package/dist/_shared/languages.d.ts.map +1 -1
  16. package/dist/_shared/languages.js +2 -0
  17. package/dist/_shared/languages.js.map +1 -1
  18. package/dist/_shared/lbug/schema-constants.d.ts +2 -2
  19. package/dist/_shared/lbug/schema-constants.d.ts.map +1 -1
  20. package/dist/_shared/lbug/schema-constants.js +8 -0
  21. package/dist/_shared/lbug/schema-constants.js.map +1 -1
  22. package/dist/_shared/pipeline.d.ts +1 -1
  23. package/dist/_shared/pipeline.d.ts.map +1 -1
  24. package/dist/_shared/scope-resolution/callable-flow-site.d.ts +110 -0
  25. package/dist/_shared/scope-resolution/callable-flow-site.d.ts.map +1 -0
  26. package/dist/_shared/scope-resolution/callable-flow-site.js +11 -0
  27. package/dist/_shared/scope-resolution/callable-flow-site.js.map +1 -0
  28. package/dist/_shared/scope-resolution/finalize-algorithm.d.ts +1 -1
  29. package/dist/_shared/scope-resolution/finalize-algorithm.d.ts.map +1 -1
  30. package/dist/_shared/scope-resolution/finalize-algorithm.js +1 -1
  31. package/dist/_shared/scope-resolution/finalize-algorithm.js.map +1 -1
  32. package/dist/_shared/scope-resolution/language-classification.d.ts.map +1 -1
  33. package/dist/_shared/scope-resolution/language-classification.js +1 -0
  34. package/dist/_shared/scope-resolution/language-classification.js.map +1 -1
  35. package/dist/_shared/scope-resolution/parsed-file.d.ts +7 -0
  36. package/dist/_shared/scope-resolution/parsed-file.d.ts.map +1 -1
  37. package/dist/_shared/scope-resolution/reference-site.d.ts +9 -1
  38. package/dist/_shared/scope-resolution/reference-site.d.ts.map +1 -1
  39. package/dist/_shared/scope-resolution/types.d.ts +20 -3
  40. package/dist/_shared/scope-resolution/types.d.ts.map +1 -1
  41. package/dist/cli/ai-context.d.ts +1 -1
  42. package/dist/cli/ai-context.js +25 -44
  43. package/dist/cli/analyze.d.ts +5 -2
  44. package/dist/cli/analyze.js +205 -11
  45. package/dist/cli/clean.js +17 -3
  46. package/dist/cli/cli-message.d.ts +1 -1
  47. package/dist/cli/doctor.d.ts +18 -0
  48. package/dist/cli/doctor.js +96 -5
  49. package/dist/cli/editor-targets.d.ts +17 -2
  50. package/dist/cli/editor-targets.js +44 -0
  51. package/dist/cli/embeddings.d.ts +12 -0
  52. package/dist/cli/embeddings.js +49 -0
  53. package/dist/cli/eval-server.d.ts +23 -1
  54. package/dist/cli/eval-server.js +126 -8
  55. package/dist/cli/help-i18n.js +6 -0
  56. package/dist/cli/i18n/en.d.ts +18 -8
  57. package/dist/cli/i18n/en.js +18 -8
  58. package/dist/cli/i18n/resources.d.ts +28 -8
  59. package/dist/cli/i18n/zh-CN.d.ts +10 -0
  60. package/dist/cli/i18n/zh-CN.js +18 -8
  61. package/dist/cli/index.js +21 -7
  62. package/dist/cli/lazy-action.d.ts +7 -0
  63. package/dist/cli/lazy-action.js +27 -0
  64. package/dist/cli/mcp.js +6 -3
  65. package/dist/cli/move-availability.d.ts +5 -0
  66. package/dist/cli/move-availability.js +11 -0
  67. package/dist/cli/setup.d.ts +8 -0
  68. package/dist/cli/setup.js +230 -32
  69. package/dist/cli/skill-gen.js +38 -18
  70. package/dist/cli/standard-skills.d.ts +76 -0
  71. package/dist/cli/standard-skills.js +45 -0
  72. package/dist/cli/status.d.ts +4 -1
  73. package/dist/cli/status.js +57 -4
  74. package/dist/cli/tool.js +11 -0
  75. package/dist/cli/uninstall.js +77 -18
  76. package/dist/cli/wiki.d.ts +1 -0
  77. package/dist/cli/wiki.js +7 -1
  78. package/dist/config/ignore-service.d.ts +2 -0
  79. package/dist/config/ignore-service.js +27 -0
  80. package/dist/core/analysis-features.d.ts +21 -0
  81. package/dist/core/analysis-features.js +59 -0
  82. package/dist/core/analyzer-identity.d.ts +74 -0
  83. package/dist/core/analyzer-identity.js +1820 -0
  84. package/dist/core/augmentation/engine.js +13 -8
  85. package/dist/core/embeddings/embedder.d.ts +3 -2
  86. package/dist/core/embeddings/embedder.js +24 -7
  87. package/dist/core/embeddings/embedding-identity.d.ts +11 -0
  88. package/dist/core/embeddings/embedding-identity.js +24 -0
  89. package/dist/core/embeddings/embedding-pipeline.d.ts +36 -1
  90. package/dist/core/embeddings/embedding-pipeline.js +100 -9
  91. package/dist/core/embeddings/http-client.d.ts +33 -3
  92. package/dist/core/embeddings/http-client.js +237 -25
  93. package/dist/core/embeddings/node-module-compat.d.ts +25 -0
  94. package/dist/core/embeddings/node-module-compat.js +25 -0
  95. package/dist/core/embeddings/onnxruntime-common-resolver.js +8 -5
  96. package/dist/core/embeddings/onnxruntime-node-resolver.js +33 -7
  97. package/dist/core/embeddings/runtime-install.d.ts +119 -0
  98. package/dist/core/embeddings/runtime-install.js +372 -0
  99. package/dist/core/embeddings/runtime-support.d.ts +48 -17
  100. package/dist/core/embeddings/runtime-support.js +96 -0
  101. package/dist/core/git-staleness.js +1 -1
  102. package/dist/core/group/bridge-db.js +2 -0
  103. package/dist/core/group/extractors/http-patterns/python.js +246 -108
  104. package/dist/core/group/extractors/manifest-extractor.js +4 -0
  105. package/dist/core/incremental/escalation-gate.d.ts +38 -0
  106. package/dist/core/incremental/escalation-gate.js +48 -0
  107. package/dist/core/incremental/shadow-candidates.d.ts +3 -3
  108. package/dist/core/incremental/shadow-candidates.js +3 -3
  109. package/dist/core/incremental/subgraph-extract.d.ts +2 -2
  110. package/dist/core/incremental/subgraph-extract.js +2 -2
  111. package/dist/core/index-freshness.d.ts +5 -0
  112. package/dist/core/index-freshness.js +13 -0
  113. package/dist/core/ingestion/class-extractors/configs/jvm.js +29 -0
  114. package/dist/core/ingestion/cobol/cobol-preprocessor.js +1 -1
  115. package/dist/core/ingestion/cobol/jcl-processor.js +9 -8
  116. package/dist/core/ingestion/cobol-processor.js +27 -26
  117. package/dist/core/ingestion/community-processor.d.ts +51 -1
  118. package/dist/core/ingestion/community-processor.js +323 -64
  119. package/dist/core/ingestion/emit-references.js +7 -2
  120. package/dist/core/ingestion/filesystem-walker.js +4 -0
  121. package/dist/core/ingestion/frameworks/spring/analysis-features.d.ts +3 -0
  122. package/dist/core/ingestion/frameworks/spring/analysis-features.js +7 -0
  123. package/dist/core/ingestion/frameworks/spring/bean-candidates.d.ts +30 -0
  124. package/dist/core/ingestion/frameworks/spring/bean-candidates.js +182 -0
  125. package/dist/core/ingestion/frameworks/spring/bean-catalog.d.ts +12 -0
  126. package/dist/core/ingestion/frameworks/spring/bean-catalog.js +26 -0
  127. package/dist/core/ingestion/frameworks/spring/config-bindings.d.ts +24 -0
  128. package/dist/core/ingestion/frameworks/spring/config-bindings.js +115 -0
  129. package/dist/core/ingestion/languages/c/captures.js +56 -0
  130. package/dist/core/ingestion/languages/c/query.js +17 -0
  131. package/dist/core/ingestion/languages/c/scope-resolver.js +6 -0
  132. package/dist/core/ingestion/languages/cobol/captures.js +93 -0
  133. package/dist/core/ingestion/languages/cobol/scope-resolver.js +7 -0
  134. package/dist/core/ingestion/languages/cpp/captures.js +365 -75
  135. package/dist/core/ingestion/languages/cpp/scope-resolver.js +14 -0
  136. package/dist/core/ingestion/languages/csharp/captures.js +25 -0
  137. package/dist/core/ingestion/languages/dart/captures.js +90 -2
  138. package/dist/core/ingestion/languages/dart/extension-type-preprocess.d.ts +7 -0
  139. package/dist/core/ingestion/languages/dart/extension-type-preprocess.js +30 -0
  140. package/dist/core/ingestion/languages/dart.js +2 -0
  141. package/dist/core/ingestion/languages/go/captures.js +34 -0
  142. package/dist/core/ingestion/languages/index.d.ts +1 -0
  143. package/dist/core/ingestion/languages/index.js +2 -0
  144. package/dist/core/ingestion/languages/java/analysis-features.d.ts +3 -0
  145. package/dist/core/ingestion/languages/java/analysis-features.js +13 -0
  146. package/dist/core/ingestion/languages/java/capture-side-channel.d.ts +22 -0
  147. package/dist/core/ingestion/languages/java/capture-side-channel.js +58 -0
  148. package/dist/core/ingestion/languages/java/captures.d.ts +4 -3
  149. package/dist/core/ingestion/languages/java/captures.js +193 -17
  150. package/dist/core/ingestion/languages/java/package-facts.d.ts +5 -0
  151. package/dist/core/ingestion/languages/java/package-facts.js +9 -0
  152. package/dist/core/ingestion/languages/java/package-siblings.d.ts +3 -16
  153. package/dist/core/ingestion/languages/java/package-siblings.js +8 -147
  154. package/dist/core/ingestion/languages/java/query.js +23 -0
  155. package/dist/core/ingestion/languages/java/scope-resolver.js +23 -0
  156. package/dist/core/ingestion/languages/java/spring-bean-metadata.d.ts +2 -0
  157. package/dist/core/ingestion/languages/java/spring-bean-metadata.js +8 -0
  158. package/dist/core/ingestion/languages/java/spring-config-bindings.d.ts +23 -0
  159. package/dist/core/ingestion/languages/java/spring-config-bindings.js +215 -0
  160. package/dist/core/ingestion/languages/java.js +3 -0
  161. package/dist/core/ingestion/languages/javascript/captures.js +16 -0
  162. package/dist/core/ingestion/languages/javascript/query.js +21 -0
  163. package/dist/core/ingestion/languages/jvm/package-facts.d.ts +35 -0
  164. package/dist/core/ingestion/languages/jvm/package-facts.js +40 -0
  165. package/dist/core/ingestion/languages/jvm/package-siblings.d.ts +14 -0
  166. package/dist/core/ingestion/languages/jvm/package-siblings.js +129 -0
  167. package/dist/core/ingestion/languages/kotlin/capture-side-channel.d.ts +17 -3
  168. package/dist/core/ingestion/languages/kotlin/capture-side-channel.js +42 -8
  169. package/dist/core/ingestion/languages/kotlin/captures.js +59 -0
  170. package/dist/core/ingestion/languages/kotlin/package-facts.d.ts +5 -0
  171. package/dist/core/ingestion/languages/kotlin/package-facts.js +9 -0
  172. package/dist/core/ingestion/languages/kotlin/package-siblings.d.ts +4 -0
  173. package/dist/core/ingestion/languages/kotlin/package-siblings.js +8 -0
  174. package/dist/core/ingestion/languages/kotlin/query.js +19 -0
  175. package/dist/core/ingestion/languages/kotlin/scope-resolver.js +9 -1
  176. package/dist/core/ingestion/languages/kotlin/spring-bean-metadata.d.ts +2 -0
  177. package/dist/core/ingestion/languages/kotlin/spring-bean-metadata.js +8 -0
  178. package/dist/core/ingestion/languages/kotlin.js +5 -6
  179. package/dist/core/ingestion/languages/move.d.ts +1 -0
  180. package/dist/core/ingestion/languages/move.js +27 -0
  181. package/dist/core/ingestion/languages/php/captures.js +40 -0
  182. package/dist/core/ingestion/languages/php/import-decomposer.js +12 -0
  183. package/dist/core/ingestion/languages/php/import-target.d.ts +2 -1
  184. package/dist/core/ingestion/languages/php/import-target.js +110 -2
  185. package/dist/core/ingestion/languages/php/interpret.js +10 -0
  186. package/dist/core/ingestion/languages/php/scope-resolver.js +1 -1
  187. package/dist/core/ingestion/languages/python/captures.d.ts +3 -4
  188. package/dist/core/ingestion/languages/python/captures.js +24 -5
  189. package/dist/core/ingestion/languages/python/interpret.js +4 -0
  190. package/dist/core/ingestion/languages/python/receiver-binding.d.ts +15 -2
  191. package/dist/core/ingestion/languages/python/receiver-binding.js +96 -2
  192. package/dist/core/ingestion/languages/python/simple-hooks.d.ts +5 -5
  193. package/dist/core/ingestion/languages/python/simple-hooks.js +15 -5
  194. package/dist/core/ingestion/languages/ruby/captures.js +42 -0
  195. package/dist/core/ingestion/languages/rust/captures.js +13 -0
  196. package/dist/core/ingestion/languages/rust/interpret.js +18 -0
  197. package/dist/core/ingestion/languages/rust/query.js +9 -0
  198. package/dist/core/ingestion/languages/rust/range-binding.js +6 -2
  199. package/dist/core/ingestion/languages/swift/captures.js +12 -0
  200. package/dist/core/ingestion/languages/typescript/captures.js +21 -0
  201. package/dist/core/ingestion/languages/typescript/nuxt-auto-imports.js +2 -1
  202. package/dist/core/ingestion/languages/typescript/query.js +37 -0
  203. package/dist/core/ingestion/markdown-processor.js +3 -2
  204. package/dist/core/ingestion/model/registration-table.js +1 -0
  205. package/dist/core/ingestion/parsing-processor.d.ts +3 -1
  206. package/dist/core/ingestion/parsing-processor.js +36 -1
  207. package/dist/core/ingestion/pipeline-phases/index.d.ts +2 -0
  208. package/dist/core/ingestion/pipeline-phases/index.js +2 -0
  209. package/dist/core/ingestion/pipeline-phases/parse-impl.js +56 -2
  210. package/dist/core/ingestion/pipeline-phases/parse.d.ts +0 -2
  211. package/dist/core/ingestion/pipeline-phases/parse.js +12 -3
  212. package/dist/core/ingestion/pipeline-phases/processes.d.ts +8 -0
  213. package/dist/core/ingestion/pipeline-phases/processes.js +11 -1
  214. package/dist/core/ingestion/pipeline-phases/spring-config.d.ts +37 -0
  215. package/dist/core/ingestion/pipeline-phases/spring-config.js +433 -0
  216. package/dist/core/ingestion/pipeline-phases/standalone-ingest.d.ts +16 -0
  217. package/dist/core/ingestion/pipeline-phases/standalone-ingest.js +6 -0
  218. package/dist/core/ingestion/pipeline.d.ts +4 -2
  219. package/dist/core/ingestion/pipeline.js +14 -2
  220. package/dist/core/ingestion/resolve-references.js +12 -0
  221. package/dist/core/ingestion/route-extractors/constant-resolver.d.ts +83 -0
  222. package/dist/core/ingestion/route-extractors/constant-resolver.js +131 -0
  223. package/dist/core/ingestion/route-extractors/python-const-resolver.d.ts +75 -0
  224. package/dist/core/ingestion/route-extractors/python-const-resolver.js +299 -0
  225. package/dist/core/ingestion/scope-extractor.js +306 -43
  226. package/dist/core/ingestion/scope-resolution/contract/scope-resolver.d.ts +71 -2
  227. package/dist/core/ingestion/scope-resolution/graph-bridge/callee-id-sink.d.ts +7 -7
  228. package/dist/core/ingestion/scope-resolution/graph-bridge/callee-id-sink.js +8 -7
  229. package/dist/core/ingestion/scope-resolution/graph-bridge/edges.d.ts +3 -1
  230. package/dist/core/ingestion/scope-resolution/graph-bridge/edges.js +6 -0
  231. package/dist/core/ingestion/scope-resolution/graph-bridge/ids.js +1 -0
  232. package/dist/core/ingestion/scope-resolution/graph-bridge/node-lookup.js +4 -1
  233. package/dist/core/ingestion/scope-resolution/graph-bridge/references-to-edges.js +7 -2
  234. package/dist/core/ingestion/scope-resolution/passes/callable-value-flow.d.ts +47 -0
  235. package/dist/core/ingestion/scope-resolution/passes/callable-value-flow.js +1138 -0
  236. package/dist/core/ingestion/scope-resolution/passes/free-call-fallback.d.ts +16 -4
  237. package/dist/core/ingestion/scope-resolution/passes/free-call-fallback.js +97 -1
  238. package/dist/core/ingestion/scope-resolution/passes/property-dispatch.d.ts +60 -0
  239. package/dist/core/ingestion/scope-resolution/passes/property-dispatch.js +111 -0
  240. package/dist/core/ingestion/scope-resolution/passes/receiver-bound-calls.d.ts +4 -3
  241. package/dist/core/ingestion/scope-resolution/pipeline/phase.d.ts +5 -0
  242. package/dist/core/ingestion/scope-resolution/pipeline/phase.js +25 -38
  243. package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +6 -0
  244. package/dist/core/ingestion/scope-resolution/pipeline/run.js +147 -35
  245. package/dist/core/ingestion/scope-resolution/resolution-outcome.d.ts +1 -1
  246. package/dist/core/ingestion/scope-resolution/scope/walkers.js +73 -44
  247. package/dist/core/ingestion/taint/typescript-model.js +34 -2
  248. package/dist/core/ingestion/tree-sitter-queries.d.ts +2 -2
  249. package/dist/core/ingestion/tree-sitter-queries.js +23 -2
  250. package/dist/core/ingestion/type-extractors/jvm.js +17 -2
  251. package/dist/core/ingestion/utils/ast-helpers.d.ts +1 -0
  252. package/dist/core/ingestion/utils/ast-helpers.js +153 -2
  253. package/dist/core/ingestion/utils/callable-flow-captures.d.ts +94 -0
  254. package/dist/core/ingestion/utils/callable-flow-captures.js +878 -0
  255. package/dist/core/ingestion/utils/line-base.d.ts +20 -0
  256. package/dist/core/ingestion/utils/line-base.js +20 -0
  257. package/dist/core/ingestion/utils/symbol-labels.d.ts +21 -0
  258. package/dist/core/ingestion/utils/symbol-labels.js +45 -0
  259. package/dist/core/ingestion/workers/parse-worker.d.ts +34 -0
  260. package/dist/core/ingestion/workers/parse-worker.js +40 -5
  261. package/dist/core/ingestion/workers/result-merge.js +4 -0
  262. package/dist/core/ingestion/workers/worker-pool.d.ts +28 -0
  263. package/dist/core/ingestion/workers/worker-pool.js +128 -28
  264. package/dist/core/lbug/csv-generator.d.ts +15 -0
  265. package/dist/core/lbug/csv-generator.js +124 -27
  266. package/dist/core/lbug/cypher-escape.d.ts +20 -0
  267. package/dist/core/lbug/cypher-escape.js +20 -0
  268. package/dist/core/lbug/extension-load-error.d.ts +67 -0
  269. package/dist/core/lbug/extension-load-error.js +326 -0
  270. package/dist/core/lbug/extension-loader.d.ts +17 -2
  271. package/dist/core/lbug/extension-loader.js +38 -13
  272. package/dist/core/lbug/lbug-adapter.d.ts +203 -2
  273. package/dist/core/lbug/lbug-adapter.js +527 -86
  274. package/dist/core/lbug/lbug-config.d.ts +56 -2
  275. package/dist/core/lbug/lbug-config.js +267 -7
  276. package/dist/core/lbug/move-columns.d.ts +7 -0
  277. package/dist/core/lbug/move-columns.js +86 -0
  278. package/dist/core/lbug/native-check.d.ts +40 -0
  279. package/dist/core/lbug/native-check.js +90 -0
  280. package/dist/core/lbug/node-table-layout.d.ts +29 -0
  281. package/dist/core/lbug/node-table-layout.js +119 -0
  282. package/dist/core/lbug/pool-adapter.d.ts +23 -1
  283. package/dist/core/lbug/pool-adapter.js +115 -8
  284. package/dist/core/lbug/schema.d.ts +4 -3
  285. package/dist/core/lbug/schema.js +16 -16
  286. package/dist/core/lbug/sidecar-recovery.d.ts +172 -0
  287. package/dist/core/lbug/sidecar-recovery.js +418 -16
  288. package/dist/core/lbug/wal-checkpoint-driver.js +3 -0
  289. package/dist/core/move/artifact-download.d.ts +19 -0
  290. package/dist/core/move/artifact-download.js +179 -0
  291. package/dist/core/move/compiler-facts.d.ts +120 -0
  292. package/dist/core/move/compiler-facts.js +9 -0
  293. package/dist/core/move/consistency.d.ts +74 -0
  294. package/dist/core/move/consistency.js +182 -0
  295. package/dist/core/move/constants.d.ts +98 -0
  296. package/dist/core/move/constants.js +136 -0
  297. package/dist/core/move/discovery.d.ts +10 -0
  298. package/dist/core/move/discovery.js +22 -0
  299. package/dist/core/move/entry-points.d.ts +14 -0
  300. package/dist/core/move/entry-points.js +40 -0
  301. package/dist/core/move/facts-mapper.d.ts +75 -0
  302. package/dist/core/move/facts-mapper.js +534 -0
  303. package/dist/core/move/install-lock.d.ts +14 -0
  304. package/dist/core/move/install-lock.js +102 -0
  305. package/dist/core/move/install.d.ts +39 -0
  306. package/dist/core/move/install.js +391 -0
  307. package/dist/core/move/mcp-client.d.ts +115 -0
  308. package/dist/core/move/mcp-client.js +436 -0
  309. package/dist/core/move/move-ingest.d.ts +52 -0
  310. package/dist/core/move/move-ingest.js +403 -0
  311. package/dist/core/move/provision.d.ts +19 -0
  312. package/dist/core/move/provision.js +91 -0
  313. package/dist/core/move/release.d.ts +6 -0
  314. package/dist/core/move/release.js +10 -0
  315. package/dist/core/move/symbol-id.d.ts +35 -0
  316. package/dist/core/move/symbol-id.js +65 -0
  317. package/dist/core/move/type-parser.d.ts +11 -0
  318. package/dist/core/move/type-parser.js +38 -0
  319. package/dist/core/platform/capabilities.d.ts +0 -1
  320. package/dist/core/platform/capabilities.js +39 -8
  321. package/dist/core/run-analyze.d.ts +9 -3
  322. package/dist/core/run-analyze.js +1002 -184
  323. package/dist/core/search/fts-indexes.d.ts +31 -0
  324. package/dist/core/search/fts-indexes.js +73 -0
  325. package/dist/core/search/fts-schema.js +2 -0
  326. package/dist/core/tree-sitter/safe-parse.d.ts +8 -3
  327. package/dist/core/tree-sitter/safe-parse.js +21 -7
  328. package/dist/core/wiki/graph-queries.js +6 -5
  329. package/dist/core/wiki/llm-client.d.ts +8 -2
  330. package/dist/core/wiki/llm-client.js +41 -5
  331. package/dist/mcp/core/embedder.js +16 -2
  332. package/dist/mcp/http-transport.d.ts +5 -0
  333. package/dist/mcp/http-transport.js +19 -5
  334. package/dist/mcp/local/bean-metadata.d.ts +2 -0
  335. package/dist/mcp/local/bean-metadata.js +25 -0
  336. package/dist/mcp/local/line-display.d.ts +22 -0
  337. package/dist/mcp/local/line-display.js +3 -0
  338. package/dist/mcp/local/local-backend.d.ts +19 -1
  339. package/dist/mcp/local/local-backend.js +402 -155
  340. package/dist/mcp/local/pdg-impact.d.ts +5 -3
  341. package/dist/mcp/local/pdg-impact.js +5 -2
  342. package/dist/mcp/output-budget.d.ts +5 -0
  343. package/dist/mcp/output-budget.js +52 -0
  344. package/dist/mcp/read-only-policy.d.ts +12 -0
  345. package/dist/mcp/read-only-policy.js +102 -0
  346. package/dist/mcp/repository-policy.d.ts +34 -0
  347. package/dist/mcp/repository-policy.js +328 -0
  348. package/dist/mcp/resources.js +32 -5
  349. package/dist/mcp/server.d.ts +5 -2
  350. package/dist/mcp/server.js +29 -9
  351. package/dist/mcp/tools.d.ts +0 -8
  352. package/dist/mcp/tools.js +102 -5
  353. package/dist/server/analyze-job.d.ts +3 -0
  354. package/dist/server/analyze-job.js +17 -0
  355. package/dist/server/analyze-launch.d.ts +6 -0
  356. package/dist/server/analyze-launch.js +82 -6
  357. package/dist/server/analyze-worker-core.d.ts +2 -1
  358. package/dist/server/analyze-worker-core.js +5 -2
  359. package/dist/server/analyze-worker.js +29 -6
  360. package/dist/server/api.d.ts +30 -0
  361. package/dist/server/api.js +120 -34
  362. package/dist/server/mcp-http.d.ts +1 -1
  363. package/dist/server/mcp-http.js +4 -2
  364. package/dist/server/upload-ingest.js +2 -1
  365. package/dist/server/validation.js +2 -1
  366. package/dist/storage/git.d.ts +31 -0
  367. package/dist/storage/git.js +111 -1
  368. package/dist/storage/parse-cache.js +7 -1
  369. package/dist/storage/parsedfile-store.d.ts +8 -0
  370. package/dist/storage/parsedfile-store.js +165 -1
  371. package/dist/storage/repo-manager.d.ts +194 -2
  372. package/dist/storage/repo-manager.js +56 -1
  373. package/dist/types/pipeline.d.ts +7 -0
  374. package/hooks/antigravity/gitnexus-antigravity-hook.cjs +50 -3
  375. package/hooks/claude/gitnexus-hook.cjs +59 -9
  376. package/hooks/claude/pre-tool-use.sh +0 -0
  377. package/hooks/claude/session-start.sh +0 -0
  378. package/package.json +21 -15
  379. package/scripts/cross-platform-tests.ts +56 -0
  380. package/scripts/ensure-fts.ts +43 -0
  381. package/scripts/install-duckdb-extension.mjs +85 -24
  382. package/scripts/run-cross-platform.ts +61 -6
  383. package/scripts/shard-arg.ts +30 -0
  384. package/scripts/sync-plugin-manifests.mjs +192 -0
  385. package/skills/gitnexus-lfg/README.md +55 -0
  386. package/skills/gitnexus-lfg/SKILL.md +86 -0
  387. package/skills/gitnexus-plan/README.md +142 -0
  388. package/skills/gitnexus-plan/SKILL.md +348 -0
  389. package/skills/gitnexus-plan/references/context-ledger.md +137 -0
  390. package/skills/gitnexus-plan/references/context-pack.md +126 -0
  391. package/skills/gitnexus-plan/references/evidence-provenance.md +272 -0
  392. package/skills/gitnexus-plan/references/pdg-slice.md +109 -0
  393. package/skills/gitnexus-plan/references/plan-template.md +201 -0
  394. package/skills/gitnexus-plan/scripts/evidence-provenance.mjs +2084 -0
  395. package/skills/gitnexus-review/SKILL.md +273 -0
  396. package/skills/gitnexus-review/ci-personas/ci-adversarial-lens.md +42 -0
  397. package/skills/gitnexus-review/ci-personas/ci-blast-radius-lens.md +39 -0
  398. package/skills/gitnexus-review/ci-personas/ci-correctness-lens.md +37 -0
  399. package/skills/gitnexus-review/ci-personas/ci-coverage-lens.md +40 -0
  400. package/skills/gitnexus-review/ci-personas/ci-critic-lens.md +42 -0
  401. package/skills/gitnexus-review/ci-personas/ci-security-lens.md +39 -0
  402. package/skills/gitnexus-work/README.md +71 -0
  403. package/skills/gitnexus-work/SKILL.md +269 -0
  404. package/skills/gitnexus-work/references/evidence-provenance.md +272 -0
  405. package/skills/gitnexus-work/scripts/evidence-provenance.mjs +2084 -0
  406. package/web/assets/ProcessFlowModal-B_bfBySy.js +22 -0
  407. package/web/assets/__vite-browser-external-bMKyYQkO.js +1 -0
  408. package/web/assets/agent-Bqxn-abU.js +626 -0
  409. package/web/assets/architecture-7EHR7CIX-6QZW5X65-BVWFvYz2.js +1 -0
  410. package/web/assets/architectureDiagram-UL44E2DR-CmG3uPWf.js +36 -0
  411. package/web/assets/{blockDiagram-7IZFK4PR-BkWhpzWB.js → blockDiagram-7IZFK4PR-DBefcjof.js} +7 -7
  412. package/web/assets/{c4Diagram-Y2BXMSZH-DtBvpzWD.js → c4Diagram-Y2BXMSZH-EjWnjEJl.js} +6 -6
  413. package/web/assets/chunk-2T2R6R2M-Bb6Sy3_j.js +4 -0
  414. package/web/assets/chunk-2UTLFMKG-CXlhF3Pt.js +1 -0
  415. package/web/assets/chunk-3SSMPTDK-rPsRM1x0.js +321 -0
  416. package/web/assets/chunk-4R4BOZG6-Be_F0JoX.js +159 -0
  417. package/web/assets/{chunk-6764PJDD-DpIS7hxD.js → chunk-6764PJDD-C7xxk063.js} +1 -1
  418. package/web/assets/chunk-67TQ5CYL-CA7OhIEf.js +124 -0
  419. package/web/assets/chunk-7FYTHRHK-CsmYIswB.js +32 -0
  420. package/web/assets/chunk-7J6CGLKN-BlKwgXKs.js +10 -0
  421. package/web/assets/chunk-7W6UQGC5-pyKGssV3.js +1 -0
  422. package/web/assets/chunk-C62D2QBJ-3gdL8EKc.js +1 -0
  423. package/web/assets/chunk-CEXFNPSA-CjwtMDkc.js +1 -0
  424. package/web/assets/{chunk-INKRHTLW-Nu4ri9P-.js → chunk-INKRHTLW-Ckl3_KkO.js} +13 -13
  425. package/web/assets/chunk-J5EP6P6S-L19oz55R.js +1 -0
  426. package/web/assets/{chunk-KGFNY3KK-JGMJvKCK.js → chunk-KGFNY3KK-BZeP-55u.js} +17 -17
  427. package/web/assets/chunk-KGYTTC2M-zm6n242I.js +1 -0
  428. package/web/assets/{chunk-KRXBNO2N-BW5o0KUN.js → chunk-KRXBNO2N-C-UNuKKf.js} +1 -1
  429. package/web/assets/{chunk-LCXTWHL2-CfLLCywY.js → chunk-LCXTWHL2-C162wE5z.js} +5 -5
  430. package/web/assets/chunk-LII3EMHJ-BL6lKJ_P.js +1 -0
  431. package/web/assets/chunk-LRIF4GLE-n59lhprL.js +1 -0
  432. package/web/assets/chunk-QA3QBVWF-DVXPOIxe.js +2 -0
  433. package/web/assets/chunk-RERM46MO-B5dAdWpP.js +1 -0
  434. package/web/assets/chunk-RG4AUYOV-ZNgyLFq6.js +206 -0
  435. package/web/assets/chunk-RKZBBQEN-MyIQbGgD.js +1 -0
  436. package/web/assets/chunk-RLI5ZMPA-CkXZ0y0U.js +1 -0
  437. package/web/assets/chunk-T2UQINTJ-BEyXhZWe.js +1 -0
  438. package/web/assets/chunk-UP6H54XL-BG-n7qDp.js +1 -0
  439. package/web/assets/chunk-UXSXWOXI-CW1_r5cS.js +1 -0
  440. package/web/assets/chunk-UY5QBCOK-ByqTVLvh.js +1 -0
  441. package/web/assets/chunk-VU6ZFW4Y-BLeasdkU.js +2 -0
  442. package/web/assets/{chunk-W44A43WB-JqMLIpR0.js → chunk-W44A43WB-BN8EQc_u.js} +1 -1
  443. package/web/assets/chunk-ZXARS5L4-C3v-4Fsw.js +1 -0
  444. package/web/assets/classDiagram-KGZ6W3CR-olpO3fMe.js +1 -0
  445. package/web/assets/classDiagram-v2-72OJOZXJ-olpO3fMe.js +1 -0
  446. package/web/assets/cose-bilkent-UX7MHV2Q-BmnZ7Mvj.js +1 -0
  447. package/web/assets/dagre-ND4H6XIP-CY4iIF7v.js +4 -0
  448. package/web/assets/diagram-3NCE3AQN-CO6QpzEE.js +43 -0
  449. package/web/assets/diagram-GF46GFSD-B2atbzmU.js +24 -0
  450. package/web/assets/diagram-HNR7UZ2L-DVRltWbL.js +3 -0
  451. package/web/assets/diagram-QXG6HAR7-DR6AHD2h.js +24 -0
  452. package/web/assets/diagram-WEQXMOUZ-DkpoMwTL.js +10 -0
  453. package/web/assets/erDiagram-L5TCEMPS-n8g_90bc.js +85 -0
  454. package/web/assets/eventmodeling-FCH6USID-MREXMVOE-CgFBM7e-.js +1 -0
  455. package/web/assets/flowDiagram-H6V6AXG4-SGMDiCkB.js +162 -0
  456. package/web/assets/ganttDiagram-JCBTUEKG-Cb3UBqRS.js +292 -0
  457. package/web/assets/gitGraph-WXDBUCRP-R675I2BI-DCJv7SII.js +1 -0
  458. package/web/assets/gitGraphDiagram-S2ZK5IYY-Bs9CR7tq.js +106 -0
  459. package/web/assets/index-CX_fADmQ.css +2 -0
  460. package/web/assets/index-D1Z3dGGz.js +575 -0
  461. package/web/assets/info-J43DQDTF-KCYPFFUO-BfNy-9Nd.js +1 -0
  462. package/web/assets/infoDiagram-3YFTVSEB-T368on4D.js +2 -0
  463. package/web/assets/{ishikawaDiagram-BNXS4ZKH-DfYpxnE5.js → ishikawaDiagram-BNXS4ZKH-XpjJRU9h.js} +6 -6
  464. package/web/assets/journeyDiagram-M6C3CM3L-C6wjooas.js +139 -0
  465. package/web/assets/kanban-definition-75IXJCU3-5_UVY79g.js +89 -0
  466. package/web/assets/{katex-K3KEBU37-CTc5BslQ.js → katex-K3KEBU37-qRQBy-EZ.js} +2 -2
  467. package/web/assets/{mindmap-definition-2TDM6QVE-DypvbOMQ.js → mindmap-definition-2TDM6QVE-8xDLzk2e.js} +29 -29
  468. package/web/assets/node-Ct_zna8U.js +8 -0
  469. package/web/assets/packet-YPE3B663-LP52Z2RK-D4qkeG9k.js +1 -0
  470. package/web/assets/pie-LRSECV5Y-TCRJHUBD-B_yMKyQa.js +1 -0
  471. package/web/assets/pieDiagram-CU6KROY3-Bv4LpAbr.js +30 -0
  472. package/web/assets/preload-helper-Czpn1I53.js +1 -0
  473. package/web/assets/{quadrantDiagram-VICAPDV7-yXSZ2lLz.js → quadrantDiagram-VICAPDV7-DR54jOeR.js} +6 -6
  474. package/web/assets/radar-GUYGQ44K-RDLRG3WG-DeD6yN_0.js +1 -0
  475. package/web/assets/requirementDiagram-JXO7QTGE-PEkg7sKC.js +84 -0
  476. package/web/assets/rolldown-runtime-QTnfLwEv.js +1 -0
  477. package/web/assets/sankeyDiagram-URQDO5SZ-DDWEtjcs.js +40 -0
  478. package/web/assets/sdk-CzHHH8tD.js +37 -0
  479. package/web/assets/sequenceDiagram-VS2MUI6T-Bv-uwANx.js +162 -0
  480. package/web/assets/src-JV30wyV8.js +1 -0
  481. package/web/assets/stateDiagram-7D4R322I-DVNjmkEq.js +1 -0
  482. package/web/assets/stateDiagram-v2-36443NZ5-CyN4Fh7_.js +1 -0
  483. package/web/assets/timeline-definition-O6YCAMPW-Qmsuo7qS.js +120 -0
  484. package/web/assets/treeView-BLDUP644-QA4HXRO3-CDuSYnd4.js +1 -0
  485. package/web/assets/treemap-LRROVOQU-LLAWBHMP-imazucyJ.js +1 -0
  486. package/web/assets/vennDiagram-MWXL3ELB-BD1iK1SX.js +34 -0
  487. package/web/assets/wardley-L42UT6IY-5TKZOOLJ-DqrT9tB8.js +1 -0
  488. package/web/assets/wardleyDiagram-CUQ6CDDI-D6TzLVob.js +78 -0
  489. package/web/assets/{xychartDiagram-N2JHSOCM-BOLm0eNm.js → xychartDiagram-N2JHSOCM-CoZ_Tits.js} +6 -6
  490. package/web/index.html +18 -10
  491. package/skills/gitnexus-pr-review.md +0 -163
  492. package/web/assets/agent-Dli_x7bU.js +0 -660
  493. package/web/assets/architecture-7EHR7CIX-6QZW5X65-DxJw65fT.js +0 -1
  494. package/web/assets/architectureDiagram-UL44E2DR-dEkqUZN9.js +0 -36
  495. package/web/assets/chunk-3SSMPTDK-B4_etUhr.js +0 -321
  496. package/web/assets/chunk-67TQ5CYL-4sNPJU1q.js +0 -124
  497. package/web/assets/chunk-7W6UQGC5-KyEG0HQg.js +0 -1
  498. package/web/assets/chunk-KGYTTC2M-CWC_c3H9.js +0 -161
  499. package/web/assets/chunk-LII3EMHJ-D-jm-dLa.js +0 -1
  500. package/web/assets/chunk-QA3QBVWF-BgRW3SXd.js +0 -2
  501. package/web/assets/chunk-RG4AUYOV-CqWzWsV3.js +0 -206
  502. package/web/assets/chunk-T2UQINTJ-DSBqkzH7.js +0 -1
  503. package/web/assets/chunk-UY5QBCOK-DyF0vyGd.js +0 -1
  504. package/web/assets/chunk-ZXARS5L4-CxN8oiwI.js +0 -1
  505. package/web/assets/classDiagram-KGZ6W3CR-CMne4tG9.js +0 -1
  506. package/web/assets/classDiagram-v2-72OJOZXJ-CMne4tG9.js +0 -1
  507. package/web/assets/cose-bilkent-UX7MHV2Q-D71wNYRJ.js +0 -1
  508. package/web/assets/dagre-ND4H6XIP-Co5rRx9X.js +0 -4
  509. package/web/assets/diagram-3NCE3AQN-DBp4O00j.js +0 -43
  510. package/web/assets/diagram-GF46GFSD-6N_8yS0i.js +0 -24
  511. package/web/assets/diagram-HNR7UZ2L-DFCfd5LI.js +0 -3
  512. package/web/assets/diagram-QXG6HAR7-Di093rFg.js +0 -24
  513. package/web/assets/diagram-WEQXMOUZ-DZWi4SUN.js +0 -10
  514. package/web/assets/erDiagram-L5TCEMPS-8X1y6R4Y.js +0 -85
  515. package/web/assets/eventmodeling-FCH6USID-MREXMVOE-BQm9QzEa.js +0 -1
  516. package/web/assets/flowDiagram-H6V6AXG4-CMpY8Ufn.js +0 -162
  517. package/web/assets/ganttDiagram-JCBTUEKG-D3iL2Aet.js +0 -292
  518. package/web/assets/gitGraph-WXDBUCRP-R675I2BI-Cr_Bm2Nb.js +0 -1
  519. package/web/assets/gitGraphDiagram-S2ZK5IYY-CPIDgxGm.js +0 -106
  520. package/web/assets/index-KctnI3Vq.js +0 -635
  521. package/web/assets/index-_lgn7hs5.css +0 -2
  522. package/web/assets/info-J43DQDTF-KCYPFFUO-Cs4p2oyk.js +0 -1
  523. package/web/assets/infoDiagram-3YFTVSEB-BaLMZkUg.js +0 -2
  524. package/web/assets/journeyDiagram-M6C3CM3L-CvArszLo.js +0 -139
  525. package/web/assets/kanban-definition-75IXJCU3-CpfVLLf9.js +0 -89
  526. package/web/assets/packet-YPE3B663-LP52Z2RK-CKCrztD2.js +0 -1
  527. package/web/assets/pie-LRSECV5Y-TCRJHUBD-BB7pHqoD.js +0 -1
  528. package/web/assets/pieDiagram-CU6KROY3-s27EFWgP.js +0 -30
  529. package/web/assets/radar-GUYGQ44K-RDLRG3WG-DhoTOcuK.js +0 -1
  530. package/web/assets/requirementDiagram-JXO7QTGE-CWhqInuf.js +0 -84
  531. package/web/assets/sankeyDiagram-URQDO5SZ-1qEjlFIB.js +0 -40
  532. package/web/assets/sequenceDiagram-VS2MUI6T-RyTlZ5cR.js +0 -162
  533. package/web/assets/stateDiagram-7D4R322I-DTjVfX4A.js +0 -1
  534. package/web/assets/stateDiagram-v2-36443NZ5-xY9_k9_Q.js +0 -1
  535. package/web/assets/timeline-definition-O6YCAMPW-DrrqLYAf.js +0 -120
  536. package/web/assets/treeView-BLDUP644-QA4HXRO3-ODehyGKL.js +0 -1
  537. package/web/assets/treemap-LRROVOQU-LLAWBHMP-BfpgC-7o.js +0 -1
  538. package/web/assets/vennDiagram-MWXL3ELB-B0RkWAim.js +0 -34
  539. package/web/assets/wardley-L42UT6IY-5TKZOOLJ-CNeL6VHE.js +0 -1
  540. package/web/assets/wardleyDiagram-CUQ6CDDI-boUxiQ5F.js +0 -78
@@ -0,0 +1,2084 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash, randomBytes } from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import process from 'node:process';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ export const EVIDENCE_PROVENANCE_SCHEMA_VERSION = 2;
11
+ export const EVIDENCE_PROVENANCE_CANONICALIZATION =
12
+ 'gitnexus-evidence-provenance-v2 NUL-framed UTF-8 records';
13
+
14
+ const ABSENT = 'absent';
15
+ const OBJECT_KINDS = new Set(['regular', 'symlink', 'gitlink', 'directory', ABSENT]);
16
+ const STATES = new Set([
17
+ 'clean',
18
+ 'staged',
19
+ 'unstaged',
20
+ 'untracked',
21
+ 'deleted',
22
+ 'renamed',
23
+ 'mixed',
24
+ ABSENT,
25
+ ]);
26
+ const RECORD_FIELDS = [
27
+ 'path',
28
+ 'state',
29
+ 'head_kind',
30
+ 'index_kind',
31
+ 'worktree_kind',
32
+ 'untracked_kind',
33
+ 'rename_from',
34
+ 'rename_to',
35
+ 'head_digest',
36
+ 'index_digest',
37
+ 'worktree_digest',
38
+ 'untracked_digest',
39
+ ];
40
+ const UTF8_FATAL = new TextDecoder('utf-8', { fatal: true });
41
+ const MAX_GIT_OUTPUT = 1024 * 1024 * 1024;
42
+ const MAX_PLAN_BYTES = 16 * 1024 * 1024;
43
+ const GENERATED_PLAN_READ_PATTERN = /^docs\/plans\/[^/]*gitnexus-plan[^/]*\.md$/;
44
+ const GENERATED_PLAN_WRITE_PATTERN =
45
+ /^docs\/plans\/(\d{4}-\d{2}-\d{2})-gitnexus-plan-[a-z0-9]+(?:-[a-z0-9]+){2,4}\.md$/;
46
+ export const DIRECTORY_LIMITS = Object.freeze({
47
+ maxEntries: 10_000,
48
+ maxDepth: 256,
49
+ maxBytes: 256 * 1024 * 1024,
50
+ });
51
+
52
+ function sha256(bytes) {
53
+ return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
54
+ }
55
+
56
+ function statIdentity(stat) {
57
+ return [stat.dev, stat.ino, stat.mode, stat.nlink, stat.size, stat.mtimeNs, stat.ctimeNs]
58
+ .map(String)
59
+ .join(':');
60
+ }
61
+
62
+ function assertStableIdentity(before, after, label) {
63
+ if (statIdentity(before) !== statIdentity(after)) {
64
+ throw new Error(`${label} changed while evidence was being read`);
65
+ }
66
+ }
67
+
68
+ function hashFile(file, mutationGuards, directoryTraversal) {
69
+ const hash = createHash('sha256');
70
+ const noFollow = fs.constants.O_NOFOLLOW ?? 0;
71
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
72
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
73
+ try {
74
+ const before = fs.fstatSync(fd, { bigint: true });
75
+ if (!before.isFile()) throw new Error(`Expected a regular file at ${file}`);
76
+ if (directoryTraversal) {
77
+ directoryTraversal.bytes += before.size;
78
+ if (directoryTraversal.bytes > BigInt(DIRECTORY_LIMITS.maxBytes)) {
79
+ throw new Error(`Directory inventory exceeds ${DIRECTORY_LIMITS.maxBytes} content bytes`);
80
+ }
81
+ }
82
+ for (;;) {
83
+ const count = fs.readSync(fd, buffer, 0, buffer.length, null);
84
+ if (count === 0) break;
85
+ hash.update(buffer.subarray(0, count));
86
+ }
87
+ const after = fs.fstatSync(fd, { bigint: true });
88
+ assertStableIdentity(before, after, file);
89
+ mutationGuards.push({ type: 'stat', absolute: file, identity: statIdentity(after) });
90
+ } finally {
91
+ fs.closeSync(fd);
92
+ }
93
+ return `sha256:${hash.digest('hex')}`;
94
+ }
95
+
96
+ function git(repo, args, { allowFailure = false, input } = {}) {
97
+ const result = spawnSync('git', ['-C', repo, ...args], {
98
+ encoding: null,
99
+ env: { ...process.env, LANG: 'C', LC_ALL: 'C', GIT_OPTIONAL_LOCKS: '0' },
100
+ input,
101
+ maxBuffer: MAX_GIT_OUTPUT,
102
+ windowsHide: true,
103
+ });
104
+ if (result.error) throw result.error;
105
+ if (result.status !== 0 && !allowFailure) {
106
+ const stderr = Buffer.from(result.stderr ?? [])
107
+ .toString('utf8')
108
+ .trim();
109
+ throw new Error(`git ${args.join(' ')} failed (${result.status}): ${stderr}`);
110
+ }
111
+ return {
112
+ status: result.status,
113
+ stdout: Buffer.from(result.stdout ?? []),
114
+ stderr: Buffer.from(result.stderr ?? []),
115
+ };
116
+ }
117
+
118
+ function decodeUtf8(bytes, label) {
119
+ let decoded;
120
+ try {
121
+ decoded = UTF8_FATAL.decode(bytes);
122
+ } catch {
123
+ throw new Error(`${label} is not valid UTF-8`);
124
+ }
125
+ return decoded;
126
+ }
127
+
128
+ export function normalizeRepoPath(input, label = 'path') {
129
+ if (typeof input !== 'string') throw new Error(`${label} must be a string`);
130
+ if (input.length === 0) throw new Error(`${label} must not be empty`);
131
+ if (input.includes('\0')) throw new Error(`${label} must not contain NUL`);
132
+ if (input.includes('\\')) throw new Error(`${label} must use POSIX '/' separators`);
133
+ if (input !== input.normalize('NFC')) throw new Error(`${label} must already be Unicode NFC`);
134
+ if (Buffer.from(input, 'utf8').toString('utf8') !== input) {
135
+ throw new Error(`${label} contains an invalid Unicode scalar value`);
136
+ }
137
+ if (input.startsWith('/') || /^[A-Za-z]:\//.test(input)) {
138
+ throw new Error(`${label} must be repo-relative`);
139
+ }
140
+ const components = input.split('/');
141
+ if (components.some((component) => component === '' || component === '.' || component === '..')) {
142
+ throw new Error(`${label} must be a normalized repo-relative path without dot segments`);
143
+ }
144
+ return input;
145
+ }
146
+
147
+ function requireString(value, label) {
148
+ if (typeof value !== 'string') throw new Error(`${label} must be a string`);
149
+ return value;
150
+ }
151
+
152
+ function requireBoolean(value, label) {
153
+ if (typeof value !== 'boolean') throw new Error(`${label} must be a literal boolean`);
154
+ return value;
155
+ }
156
+
157
+ function normalizeSha256Digest(value, label = 'plan digest') {
158
+ if (typeof value !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(value)) {
159
+ throw new Error(`${label} must be sha256:<64 lowercase hexadecimal characters>`);
160
+ }
161
+ return value;
162
+ }
163
+
164
+ function normalizeGeneratedPlanWritePath(input) {
165
+ const normalized = normalizeRepoPath(input, 'generated plan path');
166
+ const match = GENERATED_PLAN_WRITE_PATTERN.exec(normalized);
167
+ if (!match) {
168
+ throw new Error(
169
+ 'Generated-plan writes are restricted to docs/plans/YYYY-MM-DD-gitnexus-plan-<3-5-word-slug>.md',
170
+ );
171
+ }
172
+ const parsedDate = new Date(`${match[1]}T00:00:00Z`);
173
+ if (Number.isNaN(parsedDate.valueOf()) || parsedDate.toISOString().slice(0, 10) !== match[1]) {
174
+ throw new Error(`Generated-plan path has an invalid calendar date: ${match[1]}`);
175
+ }
176
+ return normalized;
177
+ }
178
+
179
+ function normalizeGeneratedPlanReadPath(input) {
180
+ const normalized = normalizeRepoPath(input, 'existing plan path');
181
+ if (!GENERATED_PLAN_READ_PATTERN.test(normalized)) {
182
+ throw new Error('Existing-plan reads are restricted to docs/plans/*gitnexus-plan*.md');
183
+ }
184
+ return normalized;
185
+ }
186
+
187
+ function decodeRepoPath(bytes, label) {
188
+ return normalizeRepoPath(decodeUtf8(bytes, label), label);
189
+ }
190
+
191
+ function splitNul(bytes) {
192
+ const parts = [];
193
+ let start = 0;
194
+ for (let index = 0; index < bytes.length; index += 1) {
195
+ if (bytes[index] !== 0) continue;
196
+ parts.push(bytes.subarray(start, index));
197
+ start = index + 1;
198
+ }
199
+ if (start !== bytes.length) throw new Error('Git emitted a non-NUL-terminated record stream');
200
+ return parts;
201
+ }
202
+
203
+ function splitFixedHeader(record, fieldCount, label) {
204
+ const fields = [];
205
+ let cursor = 0;
206
+ for (let index = 0; index < fieldCount; index += 1) {
207
+ const separator = record.indexOf(' ', cursor);
208
+ if (separator < 0) throw new Error(`Malformed ${label} record`);
209
+ fields.push(record.slice(cursor, separator));
210
+ cursor = separator + 1;
211
+ }
212
+ return { fields, path: record.slice(cursor) };
213
+ }
214
+
215
+ function classifyXY(xy) {
216
+ if (!/^[.MTADRCU?!]{2}$/.test(xy)) throw new Error(`Unsupported Git XY status: ${xy}`);
217
+ const [indexState, worktreeState] = xy;
218
+ if (indexState === 'U' || worktreeState === 'U') {
219
+ throw new Error('Unmerged paths cannot be canonicalized; resolve the index first');
220
+ }
221
+ if (indexState !== '.' && worktreeState !== '.') return 'mixed';
222
+ if (indexState === 'D' || worktreeState === 'D') return 'deleted';
223
+ if (indexState !== '.') return 'staged';
224
+ if (worktreeState !== '.') return 'unstaged';
225
+ throw new Error(`Porcelain reported a non-dirty ordinary record (${xy})`);
226
+ }
227
+
228
+ function addDirtyRecord(records, record) {
229
+ const incomingFacts = new Set(record.fact_states ?? [record.state]);
230
+ const current = records.get(record.path);
231
+ if (!current) {
232
+ records.set(record.path, {
233
+ ...record,
234
+ fact_states: incomingFacts,
235
+ has_untracked: record.has_untracked ?? record.state === 'untracked',
236
+ directory_hint: record.directory_hint ?? false,
237
+ });
238
+ return;
239
+ }
240
+ const mergeEndpoint = (field) => {
241
+ const left = current[field];
242
+ const right = record[field];
243
+ if (left && right && left !== right) {
244
+ throw new Error(`Conflicting ${field} facts for ${JSON.stringify(record.path)}`);
245
+ }
246
+ return left ?? right ?? null;
247
+ };
248
+ const facts = new Set([...current.fact_states, ...incomingFacts]);
249
+ current.fact_states = facts;
250
+ current.state = facts.has('mixed') || facts.size > 1 ? 'mixed' : [...facts][0];
251
+ current.rename_from = mergeEndpoint('rename_from');
252
+ current.rename_to = mergeEndpoint('rename_to');
253
+ current.has_untracked =
254
+ current.has_untracked || record.has_untracked || record.state === 'untracked';
255
+ current.directory_hint = current.directory_hint || record.directory_hint;
256
+ }
257
+
258
+ function readDirtySnapshot(repo) {
259
+ const output = git(repo, [
260
+ '-c',
261
+ 'diff.renameLimit=0',
262
+ '-c',
263
+ 'status.renameLimit=0',
264
+ 'status',
265
+ '--porcelain=v2',
266
+ '-z',
267
+ '--untracked-files=all',
268
+ '--find-renames=50%',
269
+ '--ignore-submodules=none',
270
+ ]).stdout;
271
+ const tokens = splitNul(output);
272
+ const records = new Map();
273
+
274
+ for (let index = 0; index < tokens.length; index += 1) {
275
+ const token = tokens[index];
276
+ if (token.length === 0) continue;
277
+ const kind = String.fromCharCode(token[0]);
278
+ const text = decodeUtf8(token, 'git status record');
279
+
280
+ if (kind === '1') {
281
+ const parsed = splitFixedHeader(text, 8, 'ordinary status');
282
+ const xy = parsed.fields[1];
283
+ const repoPath = normalizeRepoPath(parsed.path, 'git status path');
284
+ addDirtyRecord(records, {
285
+ path: repoPath,
286
+ state: classifyXY(xy),
287
+ rename_from: null,
288
+ rename_to: null,
289
+ has_untracked: false,
290
+ });
291
+ continue;
292
+ }
293
+
294
+ if (kind === '2') {
295
+ const parsed = splitFixedHeader(text, 9, 'rename status');
296
+ const newPath = normalizeRepoPath(parsed.path, 'rename destination');
297
+ index += 1;
298
+ if (index >= tokens.length) throw new Error('Rename status is missing its source endpoint');
299
+ const oldPath = decodeRepoPath(tokens[index], 'rename source');
300
+ addDirtyRecord(records, {
301
+ path: oldPath,
302
+ state: 'renamed',
303
+ rename_from: null,
304
+ rename_to: newPath,
305
+ has_untracked: false,
306
+ });
307
+ addDirtyRecord(records, {
308
+ path: newPath,
309
+ state: parsed.fields[1][1] === '.' ? 'renamed' : 'mixed',
310
+ rename_from: oldPath,
311
+ rename_to: null,
312
+ has_untracked: false,
313
+ });
314
+ continue;
315
+ }
316
+
317
+ if (kind === '?') {
318
+ const rawPath = text.slice(2);
319
+ const directoryHint = rawPath.endsWith('/');
320
+ const repoPath = normalizeRepoPath(
321
+ directoryHint ? rawPath.slice(0, -1) : rawPath,
322
+ 'untracked path',
323
+ );
324
+ addDirtyRecord(records, {
325
+ path: repoPath,
326
+ state: 'untracked',
327
+ rename_from: null,
328
+ rename_to: null,
329
+ has_untracked: true,
330
+ directory_hint: directoryHint,
331
+ });
332
+ continue;
333
+ }
334
+
335
+ if (kind === 'u') {
336
+ throw new Error('Unmerged paths cannot be canonicalized; resolve the index first');
337
+ }
338
+ if (kind !== '!') throw new Error(`Unsupported porcelain-v2 record kind: ${kind}`);
339
+ }
340
+ return { output, records };
341
+ }
342
+
343
+ function kindFromMode(mode) {
344
+ if (mode === '040000') return 'directory';
345
+ if (mode === '100644' || mode === '100755') return 'regular';
346
+ if (mode === '120000') return 'symlink';
347
+ if (mode === '160000') return 'gitlink';
348
+ throw new Error(`Unsupported Git object mode: ${mode}`);
349
+ }
350
+
351
+ function readBatchObjects(repo, descriptors) {
352
+ const requested = new Map();
353
+ for (const descriptor of descriptors) {
354
+ if (descriptor.kind === 'gitlink') continue;
355
+ const expectedType = descriptor.kind === 'directory' ? 'tree' : 'blob';
356
+ const prior = requested.get(descriptor.oid);
357
+ if (prior && prior !== expectedType) {
358
+ throw new Error(
359
+ `Git object ${descriptor.oid} is requested as both ${prior} and ${expectedType}`,
360
+ );
361
+ }
362
+ requested.set(descriptor.oid, expectedType);
363
+ }
364
+ if (requested.size === 0) return new Map();
365
+ const input = Buffer.from(`${[...requested.keys()].join('\n')}\n`, 'ascii');
366
+ const output = git(repo, ['cat-file', '--batch'], { input }).stdout;
367
+ const digests = new Map();
368
+ let cursor = 0;
369
+ for (const [requestedOid, expectedType] of requested) {
370
+ const newline = output.indexOf(10, cursor);
371
+ if (newline < 0) throw new Error(`Missing cat-file header for ${requestedOid}`);
372
+ const header = decodeUtf8(output.subarray(cursor, newline), 'cat-file header').split(' ');
373
+ if (header.length !== 3 || header[0] !== requestedOid) {
374
+ throw new Error(`Malformed cat-file header for ${requestedOid}`);
375
+ }
376
+ const [, actualType, sizeText] = header;
377
+ const size = Number(sizeText);
378
+ if (actualType !== expectedType || !Number.isSafeInteger(size) || size < 0) {
379
+ throw new Error(`Unexpected cat-file object metadata for ${requestedOid}`);
380
+ }
381
+ const start = newline + 1;
382
+ const end = start + size;
383
+ if (end >= output.length || output[end] !== 10) {
384
+ throw new Error(`Truncated cat-file object ${requestedOid}`);
385
+ }
386
+ digests.set(requestedOid, sha256(output.subarray(start, end)));
387
+ cursor = end + 1;
388
+ }
389
+ if (cursor !== output.length) throw new Error('cat-file emitted unexpected trailing bytes');
390
+ return digests;
391
+ }
392
+
393
+ function loadGitLayers(repo, neededPaths, headOid, indexOutput) {
394
+ const headDescriptors = new Map();
395
+ const headOutput = git(repo, ['ls-tree', '-r', '-t', '-z', '--full-tree', headOid]).stdout;
396
+ for (const record of splitNul(headOutput)) {
397
+ if (record.length === 0) continue;
398
+ const tab = record.indexOf(9);
399
+ if (tab < 0) throw new Error('Malformed HEAD tree entry');
400
+ const repoPath = decodeRepoPath(record.subarray(tab + 1), 'HEAD path');
401
+ if (!neededPaths.has(repoPath)) continue;
402
+ const header = decodeUtf8(record.subarray(0, tab), 'HEAD entry').split(' ');
403
+ if (header.length !== 3) throw new Error(`Malformed HEAD entry for ${repoPath}`);
404
+ const [mode, type, oid] = header;
405
+ const objectKind = kindFromMode(mode);
406
+ const expectedType =
407
+ objectKind === 'directory' ? 'tree' : objectKind === 'gitlink' ? 'commit' : 'blob';
408
+ if (type !== expectedType) throw new Error(`Unexpected HEAD object type for ${repoPath}`);
409
+ headDescriptors.set(repoPath, { kind: objectKind, oid });
410
+ }
411
+
412
+ const indexDescriptors = new Map();
413
+ for (const record of splitNul(indexOutput)) {
414
+ if (record.length === 0) continue;
415
+ const tab = record.indexOf(9);
416
+ if (tab < 0) throw new Error('Malformed index entry');
417
+ const repoPath = decodeRepoPath(record.subarray(tab + 1), 'index path');
418
+ if (!neededPaths.has(repoPath)) continue;
419
+ const header = decodeUtf8(record.subarray(0, tab), 'index entry').split(' ');
420
+ if (header.length !== 3) throw new Error(`Malformed index entry for ${repoPath}`);
421
+ const [mode, oid, stage] = header;
422
+ if (stage !== '0' || indexDescriptors.has(repoPath)) {
423
+ throw new Error(`Unmerged index stages cannot be canonicalized for ${repoPath}`);
424
+ }
425
+ const objectKind = kindFromMode(mode);
426
+ if (objectKind === 'directory') throw new Error('The Git index cannot contain a tree entry');
427
+ indexDescriptors.set(repoPath, { kind: objectKind, oid });
428
+ }
429
+
430
+ const allDescriptors = [...headDescriptors.values(), ...indexDescriptors.values()];
431
+ const objectDigests = readBatchObjects(repo, allDescriptors);
432
+ const materialize = (descriptor) => {
433
+ if (!descriptor) return { kind: ABSENT, digest: ABSENT };
434
+ return {
435
+ kind: descriptor.kind,
436
+ digest:
437
+ descriptor.kind === 'gitlink'
438
+ ? sha256(Buffer.from(descriptor.oid, 'ascii'))
439
+ : objectDigests.get(descriptor.oid),
440
+ };
441
+ };
442
+ return {
443
+ head(repoPath) {
444
+ return materialize(headDescriptors.get(repoPath));
445
+ },
446
+ index(repoPath) {
447
+ return materialize(indexDescriptors.get(repoPath));
448
+ },
449
+ };
450
+ }
451
+
452
+ function compareUtf8(left, right) {
453
+ return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'));
454
+ }
455
+
456
+ function serializeFields(prefixFields, records, fields) {
457
+ const chunks = [];
458
+ const append = (value) => {
459
+ if (typeof value !== 'string' || value.includes('\0')) {
460
+ throw new Error('Canonical provenance fields must be NUL-free strings');
461
+ }
462
+ chunks.push(Buffer.from(value, 'utf8'), Buffer.from([0]));
463
+ };
464
+ for (const field of prefixFields) append(field);
465
+ chunks.push(Buffer.from([0]));
466
+ for (const record of records) {
467
+ append('record');
468
+ for (const field of fields) {
469
+ append(field);
470
+ append(record[field]);
471
+ }
472
+ chunks.push(Buffer.from([0]));
473
+ }
474
+ return Buffer.concat(chunks);
475
+ }
476
+
477
+ function resolveOwnGitTopLevel(absolute) {
478
+ const result = git(absolute, ['rev-parse', '--show-toplevel'], { allowFailure: true });
479
+ if (result.status !== 0) return null;
480
+ let topLevel;
481
+ try {
482
+ topLevel = fs.realpathSync(decodeUtf8(result.stdout, 'nested repository root').trim());
483
+ } catch {
484
+ return null;
485
+ }
486
+ return topLevel === fs.realpathSync(absolute) ? topLevel : null;
487
+ }
488
+
489
+ function readOwnGitlinkHead(absolute) {
490
+ const topLevel = resolveOwnGitTopLevel(absolute);
491
+ if (!topLevel) {
492
+ throw new Error(`Gitlink worktree is not its own repository: ${absolute}`);
493
+ }
494
+ const result = git(absolute, ['rev-parse', '--verify', 'HEAD'], { allowFailure: true });
495
+ if (result.status !== 0)
496
+ throw new Error(`Cannot resolve checked-out gitlink HEAD at ${absolute}`);
497
+ const oid = decodeUtf8(result.stdout, 'gitlink HEAD').trim();
498
+ if (!/^[0-9a-f]{40,64}$/.test(oid)) throw new Error(`Invalid gitlink object ID at ${absolute}`);
499
+ const status = git(absolute, [
500
+ 'status',
501
+ '--porcelain=v2',
502
+ '-z',
503
+ '--untracked-files=all',
504
+ '--ignored=matching',
505
+ '--ignore-submodules=none',
506
+ ]).stdout;
507
+ if (status.length !== 0) {
508
+ throw new Error(
509
+ `Checked-out gitlink is dirty at ${absolute}; commit or clean staged, unstaged, untracked, and ignored changes before snapshotting`,
510
+ );
511
+ }
512
+ return { oid, topLevel };
513
+ }
514
+
515
+ function readStableSymlink(absolute, mutationGuards) {
516
+ const before = fs.lstatSync(absolute, { bigint: true });
517
+ const target = fs.readlinkSync(absolute, { encoding: 'buffer' });
518
+ const after = fs.lstatSync(absolute, { bigint: true });
519
+ assertStableIdentity(before, after, absolute);
520
+ mutationGuards.push({
521
+ type: 'symlink',
522
+ absolute,
523
+ identity: statIdentity(after),
524
+ target: Buffer.from(target),
525
+ });
526
+ return { kind: 'symlink', digest: sha256(target) };
527
+ }
528
+
529
+ function digestDirectory(root, mutationGuards, testHooks) {
530
+ const traversal = { entries: 0, bytes: 0n };
531
+ const walk = (directory, depth) => {
532
+ if (depth > DIRECTORY_LIMITS.maxDepth) {
533
+ throw new Error(`Directory inventory exceeds depth ${DIRECTORY_LIMITS.maxDepth}`);
534
+ }
535
+ const before = fs.lstatSync(directory, { bigint: true });
536
+ if (!before.isDirectory()) throw new Error(`Expected a directory at ${directory}`);
537
+ const children = fs
538
+ .readdirSync(directory, { withFileTypes: true, encoding: 'buffer' })
539
+ .map((child) => ({
540
+ child,
541
+ name: decodeUtf8(Buffer.from(child.name), 'directory entry name'),
542
+ }))
543
+ .sort((left, right) => compareUtf8(left.name, right.name));
544
+ const ownRepository = children.some(({ name }) => name === '.git')
545
+ ? resolveOwnGitTopLevel(directory)
546
+ : null;
547
+ const entries = [];
548
+ for (const { name: childName } of children) {
549
+ if (ownRepository && childName === '.git') continue;
550
+ normalizeRepoPath(childName, 'directory entry name');
551
+ const absolute = path.join(directory, childName);
552
+ const childStat = fs.lstatSync(absolute, { bigint: true });
553
+ traversal.entries += 1;
554
+ if (traversal.entries > DIRECTORY_LIMITS.maxEntries) {
555
+ throw new Error(`Directory inventory exceeds ${DIRECTORY_LIMITS.maxEntries} entries`);
556
+ }
557
+ testHooks?.onDirectoryEntry?.({ absolute, count: traversal.entries, depth: depth + 1 });
558
+
559
+ let layer;
560
+ let descendants = [];
561
+ if (childStat.isFile()) {
562
+ layer = {
563
+ kind: 'regular',
564
+ digest: hashFile(absolute, mutationGuards, traversal),
565
+ };
566
+ } else if (childStat.isSymbolicLink()) {
567
+ layer = readStableSymlink(absolute, mutationGuards);
568
+ } else if (childStat.isDirectory()) {
569
+ const nested = walk(absolute, depth + 1);
570
+ layer = { kind: 'directory', digest: nested.digest };
571
+ descendants = nested.entries.map((entry) => ({
572
+ ...entry,
573
+ path: `${childName}/${entry.path}`,
574
+ }));
575
+ } else {
576
+ throw new Error(`Unsupported filesystem object at ${absolute}`);
577
+ }
578
+ entries.push({ path: childName, kind: layer.kind, digest: layer.digest }, ...descendants);
579
+ }
580
+ const after = fs.lstatSync(directory, { bigint: true });
581
+ assertStableIdentity(before, after, directory);
582
+ mutationGuards.push({ type: 'stat', absolute: directory, identity: statIdentity(after) });
583
+ entries.sort((left, right) => compareUtf8(left.path, right.path));
584
+ const bytes = serializeFields(['gitnexus-evidence-directory', 'schema_version', '1'], entries, [
585
+ 'path',
586
+ 'kind',
587
+ 'digest',
588
+ ]);
589
+ return { digest: sha256(bytes), entries };
590
+ };
591
+ return walk(root, 0).digest;
592
+ }
593
+
594
+ function filesystemObject(absolute, expectedKind, mutationGuards, testHooks) {
595
+ let stat;
596
+ try {
597
+ stat = fs.lstatSync(absolute);
598
+ } catch (error) {
599
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
600
+ return { kind: ABSENT, digest: ABSENT };
601
+ }
602
+ throw error;
603
+ }
604
+
605
+ if (expectedKind === 'gitlink') {
606
+ if (!stat.isDirectory()) throw new Error(`Expected gitlink directory at ${absolute}`);
607
+ const { oid, topLevel } = readOwnGitlinkHead(absolute);
608
+ mutationGuards.push({ type: 'gitlink', absolute, oid, topLevel });
609
+ return { kind: 'gitlink', digest: sha256(Buffer.from(oid, 'ascii')) };
610
+ }
611
+ if (stat.isFile()) return { kind: 'regular', digest: hashFile(absolute, mutationGuards) };
612
+ if (stat.isSymbolicLink()) return readStableSymlink(absolute, mutationGuards);
613
+ if (stat.isDirectory()) {
614
+ return { kind: 'directory', digest: digestDirectory(absolute, mutationGuards, testHooks) };
615
+ }
616
+ throw new Error(`Unsupported filesystem object at ${absolute}`);
617
+ }
618
+
619
+ function guardPathParents(repo, repoPath, mutationGuards) {
620
+ const components = repoPath.split('/');
621
+ let current = repo;
622
+ const rootStat = fs.lstatSync(repo, { bigint: true });
623
+ mutationGuards.push({
624
+ type: 'directory',
625
+ absolute: repo,
626
+ identity: stableDirectoryIdentity(rootStat),
627
+ });
628
+ for (const component of components.slice(0, -1)) {
629
+ current = path.join(current, component);
630
+ let stat;
631
+ try {
632
+ stat = fs.lstatSync(current, { bigint: true });
633
+ } catch (error) {
634
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return;
635
+ throw error;
636
+ }
637
+ if (stat.isSymbolicLink()) {
638
+ throw new Error(`Refusing to traverse symlink parent for ${repoPath}`);
639
+ }
640
+ if (!stat.isDirectory()) return;
641
+ mutationGuards.push({
642
+ type: 'directory',
643
+ absolute: current,
644
+ identity: stableDirectoryIdentity(stat),
645
+ });
646
+ }
647
+ }
648
+
649
+ function recordAnchoredAbsence(repo, repoPath, mutationGuards) {
650
+ requireDescriptorAnchoring();
651
+ const flags =
652
+ fs.constants.O_RDONLY |
653
+ fs.constants.O_DIRECTORY |
654
+ fs.constants.O_NOFOLLOW |
655
+ (fs.constants.O_CLOEXEC ?? 0);
656
+ const descriptors = [];
657
+ let retainedFd;
658
+ try {
659
+ let currentFd = fs.openSync(repo, flags);
660
+ descriptors.push(currentFd);
661
+ const components = repoPath.split('/');
662
+ for (let index = 0; index < components.length; index += 1) {
663
+ const component = components[index];
664
+ const child = descriptorPath(currentFd, component);
665
+ let childStat;
666
+ try {
667
+ childStat = fs.lstatSync(child, { bigint: true });
668
+ } catch (error) {
669
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error;
670
+ const parentStat = fs.fstatSync(currentFd, { bigint: true });
671
+ if (!parentStat.isDirectory()) {
672
+ throw new Error(`Absence parent is no longer a directory for ${repoPath}`);
673
+ }
674
+ retainedFd = currentFd;
675
+ mutationGuards.push({
676
+ type: 'absence',
677
+ fd: retainedFd,
678
+ childName: component,
679
+ repoPath,
680
+ parentIdentity: stableDirectoryIdentity(parentStat),
681
+ parentMutationIdentity: statIdentity(parentStat),
682
+ });
683
+ for (const fd of descriptors) {
684
+ if (fd !== retainedFd) fs.closeSync(fd);
685
+ }
686
+ return;
687
+ }
688
+ if (index === components.length - 1) {
689
+ throw new Error(`${repoPath} appeared while its absence was being anchored`);
690
+ }
691
+ if (childStat.isSymbolicLink() || !childStat.isDirectory()) {
692
+ throw new Error(`Refusing a non-directory parent while anchoring absence for ${repoPath}`);
693
+ }
694
+ const nextFd = fs.openSync(child, flags);
695
+ descriptors.push(nextFd);
696
+ currentFd = nextFd;
697
+ }
698
+ throw new Error(`Could not anchor absence for ${repoPath}`);
699
+ } catch (error) {
700
+ for (const fd of descriptors) {
701
+ if (fd === retainedFd) continue;
702
+ try {
703
+ fs.closeSync(fd);
704
+ } catch {
705
+ // Preserve the primary absence-anchoring error.
706
+ }
707
+ }
708
+ throw error;
709
+ }
710
+ }
711
+
712
+ function materializeRecord(repo, statusRecord, layers, mutationGuards, testHooks) {
713
+ const head = layers.head(statusRecord.path);
714
+ const index = layers.index(statusRecord.path);
715
+ const expectedKind = index.kind === 'gitlink' || head.kind === 'gitlink' ? 'gitlink' : null;
716
+ guardPathParents(repo, statusRecord.path, mutationGuards);
717
+ const filesystem = filesystemObject(
718
+ path.join(repo, ...statusRecord.path.split('/')),
719
+ expectedKind,
720
+ mutationGuards,
721
+ testHooks,
722
+ );
723
+ if (filesystem.kind === ABSENT) recordAnchoredAbsence(repo, statusRecord.path, mutationGuards);
724
+ if (statusRecord.directory_hint && filesystem.kind !== 'directory') {
725
+ throw new Error(
726
+ `Git reported an embedded directory but found ${filesystem.kind}: ${statusRecord.path}`,
727
+ );
728
+ }
729
+ const isUntracked = statusRecord.has_untracked || (head.kind === ABSENT && index.kind === ABSENT);
730
+ const worktree = isUntracked ? { kind: ABSENT, digest: ABSENT } : filesystem;
731
+ const untracked = isUntracked ? filesystem : { kind: ABSENT, digest: ABSENT };
732
+
733
+ return {
734
+ path: statusRecord.path,
735
+ object_kind: {
736
+ head: head.kind,
737
+ index: index.kind,
738
+ worktree: worktree.kind,
739
+ untracked: untracked.kind,
740
+ },
741
+ state: statusRecord.state,
742
+ rename_from: statusRecord.rename_from,
743
+ rename_to: statusRecord.rename_to,
744
+ head_digest: head.digest,
745
+ index_digest: index.digest,
746
+ worktree_digest: worktree.digest,
747
+ untracked_digest: untracked.digest,
748
+ };
749
+ }
750
+
751
+ function canonicalRecord(manifestEntry) {
752
+ const record = {
753
+ path: manifestEntry.path,
754
+ state: manifestEntry.state,
755
+ head_kind: manifestEntry.object_kind.head,
756
+ index_kind: manifestEntry.object_kind.index,
757
+ worktree_kind: manifestEntry.object_kind.worktree,
758
+ untracked_kind: manifestEntry.object_kind.untracked,
759
+ rename_from: manifestEntry.rename_from ?? ABSENT,
760
+ rename_to: manifestEntry.rename_to ?? ABSENT,
761
+ head_digest: manifestEntry.head_digest,
762
+ index_digest: manifestEntry.index_digest,
763
+ worktree_digest: manifestEntry.worktree_digest,
764
+ untracked_digest: manifestEntry.untracked_digest,
765
+ };
766
+ if (!STATES.has(record.state)) throw new Error(`Unsupported evidence state: ${record.state}`);
767
+ for (const kindField of ['head_kind', 'index_kind', 'worktree_kind', 'untracked_kind']) {
768
+ if (!OBJECT_KINDS.has(record[kindField])) {
769
+ throw new Error(`Unsupported object kind: ${record[kindField]}`);
770
+ }
771
+ }
772
+ return record;
773
+ }
774
+
775
+ export function serializeDirtyRecords(entries) {
776
+ const records = entries
777
+ .map(canonicalRecord)
778
+ .sort((left, right) => compareUtf8(left.path, right.path));
779
+ for (let index = 1; index < records.length; index += 1) {
780
+ if (records[index - 1].path === records[index].path) {
781
+ throw new Error(`Duplicate canonical dirty path: ${records[index].path}`);
782
+ }
783
+ }
784
+ return serializeFields(
785
+ ['gitnexus-evidence-provenance', 'schema_version', String(EVIDENCE_PROVENANCE_SCHEMA_VERSION)],
786
+ records,
787
+ RECORD_FIELDS,
788
+ );
789
+ }
790
+
791
+ function assertRepository(repoInput) {
792
+ const repo = fs.realpathSync(requireString(repoInput, 'repo'));
793
+ const topLevelResult = git(repo, ['rev-parse', '--show-toplevel']);
794
+ const topLevel = fs.realpathSync(decodeUtf8(topLevelResult.stdout, 'repository root').trim());
795
+ if (topLevel !== repo) throw new Error(`--repo must be the Git worktree root (${topLevel})`);
796
+ return repo;
797
+ }
798
+
799
+ function resolveAdministrativePath(repo, gitPath) {
800
+ const raw = decodeUtf8(
801
+ git(repo, ['rev-parse', '--git-path', gitPath]).stdout,
802
+ `Git administrative path ${gitPath}`,
803
+ ).trim();
804
+ return path.resolve(repo, raw);
805
+ }
806
+
807
+ function captureControlFile(absolute, label) {
808
+ let before;
809
+ try {
810
+ before = fs.lstatSync(absolute, { bigint: true });
811
+ } catch (error) {
812
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
813
+ return { absolute, label, kind: ABSENT };
814
+ }
815
+ throw error;
816
+ }
817
+ if (before.isSymbolicLink() || !before.isFile()) {
818
+ throw new Error(`${label} must be a regular no-follow file`);
819
+ }
820
+ const fd = fs.openSync(
821
+ absolute,
822
+ fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_CLOEXEC ?? 0),
823
+ );
824
+ try {
825
+ const opened = fs.fstatSync(fd, { bigint: true });
826
+ if (!opened.isFile() || statIdentity(opened) !== statIdentity(before)) {
827
+ throw new Error(`${label} changed while its descriptor opened`);
828
+ }
829
+ const hash = createHash('sha256');
830
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
831
+ for (;;) {
832
+ const count = fs.readSync(fd, buffer, 0, buffer.length, null);
833
+ if (count === 0) break;
834
+ hash.update(buffer.subarray(0, count));
835
+ }
836
+ const after = fs.fstatSync(fd, { bigint: true });
837
+ assertStableIdentity(opened, after, label);
838
+ return {
839
+ absolute,
840
+ label,
841
+ kind: 'regular',
842
+ identity: statIdentity(after),
843
+ digest: `sha256:${hash.digest('hex')}`,
844
+ };
845
+ } finally {
846
+ fs.closeSync(fd);
847
+ }
848
+ }
849
+
850
+ function verifyControlFile(guard) {
851
+ const current = captureControlFile(guard.absolute, guard.label);
852
+ if (
853
+ current.kind !== guard.kind ||
854
+ current.identity !== guard.identity ||
855
+ current.digest !== guard.digest
856
+ ) {
857
+ throw new Error(`${guard.label} changed while evidence was materialized`);
858
+ }
859
+ }
860
+
861
+ function captureHeadGuards(repo) {
862
+ const symbolic = git(repo, ['symbolic-ref', '-q', 'HEAD'], { allowFailure: true });
863
+ const paths = new Set(['HEAD', 'logs/HEAD', 'packed-refs']);
864
+ if (symbolic.status === 0) {
865
+ const ref = decodeUtf8(symbolic.stdout, 'symbolic HEAD ref').trim();
866
+ if (!/^refs\/[A-Za-z0-9._\/-]+$/.test(ref) || ref.includes('..')) {
867
+ throw new Error(`Invalid symbolic HEAD ref: ${ref}`);
868
+ }
869
+ paths.add(ref);
870
+ paths.add(`logs/${ref}`);
871
+ }
872
+ return [...paths].map((gitPath) =>
873
+ captureControlFile(resolveAdministrativePath(repo, gitPath), `Git ${gitPath}`),
874
+ );
875
+ }
876
+
877
+ function stableDirectoryIdentity(stat) {
878
+ return [stat.dev, stat.ino, stat.mode].map(String).join(':');
879
+ }
880
+
881
+ function stableFileIdentity(stat) {
882
+ return [stat.dev, stat.ino, stat.mode, stat.size].map(String).join(':');
883
+ }
884
+
885
+ function requireDescriptorAnchoring() {
886
+ if (
887
+ process.platform !== 'linux' ||
888
+ fs.constants.O_DIRECTORY === undefined ||
889
+ fs.constants.O_NOFOLLOW === undefined ||
890
+ !fs.existsSync('/proc/self/fd')
891
+ ) {
892
+ throw new Error(
893
+ 'Safe generated-plan writes require Linux /proc/self/fd and O_DIRECTORY/O_NOFOLLOW; refusing an unanchored write',
894
+ );
895
+ }
896
+ }
897
+
898
+ function descriptorPath(fd, childName) {
899
+ const base = `/proc/self/fd/${fd}`;
900
+ return childName === undefined ? base : path.join(base, childName);
901
+ }
902
+
903
+ function externalDescriptorPath(fd, childName) {
904
+ const base = `/proc/${process.pid}/fd/${fd}`;
905
+ return childName === undefined ? base : path.join(base, childName);
906
+ }
907
+
908
+ const RENAME_NOREPLACE_SCRIPT = String.raw`
909
+ import ctypes
910
+ import errno
911
+ import os
912
+ import sys
913
+
914
+ libc = ctypes.CDLL(None, use_errno=True)
915
+ try:
916
+ renameat2 = libc.renameat2
917
+ except AttributeError:
918
+ print("libc does not expose renameat2", file=sys.stderr)
919
+ raise SystemExit(125)
920
+
921
+ renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
922
+ renameat2.restype = ctypes.c_int
923
+ result = renameat2(-100, os.fsencode(sys.argv[1]), -100, os.fsencode(sys.argv[2]), 1)
924
+ if result != 0:
925
+ error_number = ctypes.get_errno()
926
+ error_name = errno.errorcode.get(error_number, "UNKNOWN")
927
+ print(f"renameat2 RENAME_NOREPLACE failed: {error_name}: {os.strerror(error_number)}", file=sys.stderr)
928
+ raise SystemExit(17 if error_number == errno.EEXIST else 126)
929
+ `;
930
+
931
+ let atomicMoverPath;
932
+
933
+ function spawnHeldExecutable(executable, args, options) {
934
+ const before = fs.fstatSync(executable.fd, { bigint: true });
935
+ if (!before.isFile() || statIdentity(before) !== executable.identity) {
936
+ throw new Error('Validated Python executable changed before invocation');
937
+ }
938
+ const result = spawnSync('/proc/self/fd/3', args, {
939
+ ...options,
940
+ stdio: ['ignore', 'pipe', 'pipe', executable.fd],
941
+ });
942
+ const after = fs.fstatSync(executable.fd, { bigint: true });
943
+ assertStableIdentity(before, after, 'validated Python executable');
944
+ return result;
945
+ }
946
+
947
+ function validatedPathExecutable(candidate) {
948
+ if (!path.isAbsolute(candidate)) return null;
949
+ const candidateDirectory = path.dirname(candidate);
950
+ let resolvedDirectory;
951
+ let resolved;
952
+ let directoryStats;
953
+ let executableStat;
954
+ try {
955
+ resolvedDirectory = fs.realpathSync(candidateDirectory);
956
+ resolved = fs.realpathSync(candidate);
957
+ const resolvedExecutableDirectory = fs.realpathSync(path.dirname(resolved));
958
+ directoryStats = [...new Set([resolvedDirectory, resolvedExecutableDirectory])].map(
959
+ (directory) => fs.statSync(directory),
960
+ );
961
+ executableStat = fs.lstatSync(resolved);
962
+ fs.accessSync(resolved, fs.constants.X_OK);
963
+ } catch {
964
+ return null;
965
+ }
966
+ if (
967
+ directoryStats.some((stat) => !stat.isDirectory()) ||
968
+ !executableStat.isFile() ||
969
+ executableStat.isSymbolicLink()
970
+ ) {
971
+ return null;
972
+ }
973
+ const uid = typeof process.getuid === 'function' ? process.getuid() : null;
974
+ const trustedOwner = (stat) => uid === null || stat.uid === 0 || stat.uid === uid;
975
+ if (
976
+ directoryStats.some((stat) => !trustedOwner(stat) || (stat.mode & 0o022) !== 0) ||
977
+ !trustedOwner(executableStat) ||
978
+ (executableStat.mode & 0o022) !== 0
979
+ ) {
980
+ return null;
981
+ }
982
+ return resolved;
983
+ }
984
+
985
+ function resolveAtomicMover() {
986
+ if (atomicMoverPath) return atomicMoverPath;
987
+ const candidates = new Set();
988
+ for (const entry of (process.env.PATH ?? '').split(path.delimiter)) {
989
+ if (entry && path.isAbsolute(entry)) candidates.add(path.join(entry, 'python3'));
990
+ }
991
+ for (const entry of ['/usr/local/bin/python3', '/usr/bin/python3', '/bin/python3']) {
992
+ candidates.add(entry);
993
+ }
994
+ for (const candidate of candidates) {
995
+ const resolved = validatedPathExecutable(candidate);
996
+ if (!resolved) continue;
997
+ let fd;
998
+ try {
999
+ fd = fs.openSync(
1000
+ resolved,
1001
+ fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0),
1002
+ );
1003
+ } catch {
1004
+ continue;
1005
+ }
1006
+ const opened = fs.fstatSync(fd, { bigint: true });
1007
+ const executable = { fd, identity: statIdentity(opened), resolved };
1008
+ const version = spawnHeldExecutable(
1009
+ executable,
1010
+ ['-I', '-S', '-c', 'import sys; print(sys.version_info[0])'],
1011
+ {
1012
+ encoding: 'utf8',
1013
+ env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
1014
+ timeout: 10_000,
1015
+ windowsHide: true,
1016
+ },
1017
+ );
1018
+ if (version.status === 0 && version.stdout.trim() === '3') {
1019
+ atomicMoverPath = executable;
1020
+ return executable;
1021
+ }
1022
+ fs.closeSync(fd);
1023
+ }
1024
+ throw new Error(
1025
+ 'Safe generated-plan publication requires a trusted absolute Python 3 PATH candidate with libc renameat2 support',
1026
+ );
1027
+ }
1028
+
1029
+ function atomicMoveNoReplace(source, destination) {
1030
+ const mover = resolveAtomicMover();
1031
+ const result = spawnHeldExecutable(
1032
+ mover,
1033
+ ['-I', '-S', '-c', RENAME_NOREPLACE_SCRIPT, source, destination],
1034
+ {
1035
+ encoding: 'utf8',
1036
+ env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
1037
+ timeout: 10_000,
1038
+ windowsHide: true,
1039
+ },
1040
+ );
1041
+ if (result.error) throw result.error;
1042
+ if (result.status === 17) return false;
1043
+ if (result.status !== 0) {
1044
+ throw new Error(
1045
+ `Atomic no-replace move failed (${result.status}): ${(result.stderr ?? '').trim()}`,
1046
+ );
1047
+ }
1048
+ return true;
1049
+ }
1050
+
1051
+ function lstatOptional(absolute) {
1052
+ try {
1053
+ return fs.lstatSync(absolute, { bigint: true });
1054
+ } catch (error) {
1055
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null;
1056
+ throw error;
1057
+ }
1058
+ }
1059
+
1060
+ function openPlanParent(
1061
+ repo,
1062
+ parentComponents,
1063
+ { createMissing = true, purpose = 'Generated-plan' } = {},
1064
+ ) {
1065
+ requireDescriptorAnchoring();
1066
+ const flags =
1067
+ fs.constants.O_RDONLY |
1068
+ fs.constants.O_DIRECTORY |
1069
+ fs.constants.O_NOFOLLOW |
1070
+ (fs.constants.O_CLOEXEC ?? 0);
1071
+ const descriptors = [];
1072
+ try {
1073
+ let currentFd = fs.openSync(repo, flags);
1074
+ descriptors.push(currentFd);
1075
+ const rootStat = fs.fstatSync(currentFd, { bigint: true });
1076
+ const chain = [{ expectedPath: repo, identity: stableDirectoryIdentity(rootStat) }];
1077
+ const traversed = [];
1078
+ for (const component of parentComponents) {
1079
+ traversed.push(component);
1080
+ const anchoredChild = descriptorPath(currentFd, component);
1081
+ let childStat;
1082
+ let created = false;
1083
+ try {
1084
+ childStat = fs.lstatSync(anchoredChild, { bigint: true });
1085
+ } catch (error) {
1086
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') throw error;
1087
+ if (!createMissing) {
1088
+ throw new Error(`${purpose} parent does not exist: ${traversed.join('/')}`);
1089
+ }
1090
+ fs.mkdirSync(anchoredChild, { mode: 0o755 });
1091
+ childStat = fs.lstatSync(anchoredChild, { bigint: true });
1092
+ created = true;
1093
+ }
1094
+ if (childStat.isSymbolicLink() || !childStat.isDirectory()) {
1095
+ throw new Error(`${purpose} parent is not a real directory: ${traversed.join('/')}`);
1096
+ }
1097
+ const parentFd = currentFd;
1098
+ const childFd = fs.openSync(anchoredChild, flags);
1099
+ descriptors.push(childFd);
1100
+ currentFd = childFd;
1101
+ if (created) {
1102
+ fs.fsyncSync(childFd);
1103
+ fs.fsyncSync(parentFd);
1104
+ }
1105
+ const expected = path.join(repo, ...traversed);
1106
+ const actual = fs.realpathSync(descriptorPath(currentFd));
1107
+ if (actual !== expected) {
1108
+ throw new Error(`${purpose} parent escaped the repository: ${traversed.join('/')}`);
1109
+ }
1110
+ const openedStat = fs.fstatSync(currentFd, { bigint: true });
1111
+ chain.push({ expectedPath: expected, identity: stableDirectoryIdentity(openedStat) });
1112
+ }
1113
+ const stat = fs.fstatSync(currentFd, { bigint: true });
1114
+ return {
1115
+ descriptors,
1116
+ fd: currentFd,
1117
+ identity: stableDirectoryIdentity(stat),
1118
+ expectedPath: path.join(repo, ...parentComponents),
1119
+ chain,
1120
+ };
1121
+ } catch (error) {
1122
+ closeDescriptors(descriptors);
1123
+ throw error;
1124
+ }
1125
+ }
1126
+
1127
+ function closeDescriptors(descriptors) {
1128
+ for (const fd of [...descriptors].reverse()) {
1129
+ try {
1130
+ fs.closeSync(fd);
1131
+ } catch {
1132
+ // Preserve the primary write result/error.
1133
+ }
1134
+ }
1135
+ }
1136
+
1137
+ function resolveGitDirectory(repo) {
1138
+ const result = git(repo, ['rev-parse', '--absolute-git-dir']);
1139
+ return fs.realpathSync(decodeUtf8(result.stdout, 'Git administrative directory').trim());
1140
+ }
1141
+
1142
+ function openBackupVault(repo, { createMissing = true } = {}) {
1143
+ const gitDirectory = resolveGitDirectory(repo);
1144
+ const handle = openPlanParent(gitDirectory, ['gitnexus-plan-backups'], {
1145
+ createMissing,
1146
+ purpose: 'Git-admin backup vault',
1147
+ });
1148
+ fs.fchmodSync(handle.fd, 0o700);
1149
+ fs.fsyncSync(handle.fd);
1150
+ const stat = fs.fstatSync(handle.fd, { bigint: true });
1151
+ handle.identity = stableDirectoryIdentity(stat);
1152
+ handle.chain[handle.chain.length - 1].identity = handle.identity;
1153
+ return { ...handle, gitDirectory };
1154
+ }
1155
+
1156
+ function validatePlanParent(parentHandle) {
1157
+ const descriptorStat = fs.fstatSync(parentHandle.fd, { bigint: true });
1158
+ if (
1159
+ !descriptorStat.isDirectory() ||
1160
+ stableDirectoryIdentity(descriptorStat) !== parentHandle.identity
1161
+ ) {
1162
+ throw new Error('Generated-plan parent descriptor changed during the write');
1163
+ }
1164
+ const descriptorRealPath = fs.realpathSync(descriptorPath(parentHandle.fd));
1165
+ if (descriptorRealPath !== parentHandle.expectedPath) {
1166
+ throw new Error('Generated-plan parent moved or was replaced during the write');
1167
+ }
1168
+ for (const item of parentHandle.chain) {
1169
+ const lexicalStat = fs.lstatSync(item.expectedPath, { bigint: true });
1170
+ if (
1171
+ lexicalStat.isSymbolicLink() ||
1172
+ !lexicalStat.isDirectory() ||
1173
+ stableDirectoryIdentity(lexicalStat) !== item.identity
1174
+ ) {
1175
+ throw new Error('Generated-plan lexical parent no longer matches its directory descriptor');
1176
+ }
1177
+ }
1178
+ }
1179
+
1180
+ function inspectPlanDestination(
1181
+ finalPath,
1182
+ { replace, expectedIdentity, mustBeAbsent = false } = {},
1183
+ ) {
1184
+ let stat;
1185
+ try {
1186
+ stat = fs.lstatSync(finalPath, { bigint: true });
1187
+ } catch (error) {
1188
+ if (error?.code === 'ENOENT') {
1189
+ if (expectedIdentity) throw new Error('Generated plan disappeared during the write');
1190
+ return null;
1191
+ }
1192
+ throw error;
1193
+ }
1194
+ if (stat.isSymbolicLink() || !stat.isFile()) {
1195
+ throw new Error('Generated-plan destination must be a regular file, never a symlink');
1196
+ }
1197
+ if (mustBeAbsent) throw new Error('Generated plan appeared during the write');
1198
+ const identity = statIdentity(stat);
1199
+ if (!replace)
1200
+ throw new Error('Generated plan already exists; use --replace only for Deepen mode');
1201
+ if (expectedIdentity && identity !== expectedIdentity) {
1202
+ throw new Error('Generated plan changed during the write');
1203
+ }
1204
+ return identity;
1205
+ }
1206
+
1207
+ function openExistingPlanDestination(finalPath, replace) {
1208
+ const identity = inspectPlanDestination(finalPath, { replace });
1209
+ if (identity === null) {
1210
+ if (replace) throw new Error('Deepen mode requires an existing generated plan to replace');
1211
+ return { fd: undefined, identity: null, stableIdentity: null };
1212
+ }
1213
+ const fd = fs.openSync(
1214
+ finalPath,
1215
+ fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0),
1216
+ );
1217
+ try {
1218
+ const opened = fs.fstatSync(fd, { bigint: true });
1219
+ if (!opened.isFile() || statIdentity(opened) !== identity) {
1220
+ throw new Error('Generated plan changed while its no-follow descriptor was opened');
1221
+ }
1222
+ return { fd, identity, stableIdentity: stableFileIdentity(opened) };
1223
+ } catch (error) {
1224
+ fs.closeSync(fd);
1225
+ throw error;
1226
+ }
1227
+ }
1228
+
1229
+ function validateOpenPlanDestination(destination) {
1230
+ if (destination.fd === undefined) return;
1231
+ const opened = fs.fstatSync(destination.fd, { bigint: true });
1232
+ if (!opened.isFile() || statIdentity(opened) !== destination.identity) {
1233
+ throw new Error('Generated plan changed through its open descriptor');
1234
+ }
1235
+ }
1236
+
1237
+ function writeAll(fd, contents) {
1238
+ let offset = 0;
1239
+ while (offset < contents.length) {
1240
+ const written = fs.writeSync(fd, contents, offset, contents.length - offset);
1241
+ if (written <= 0) throw new Error('Generated-plan write made no progress');
1242
+ offset += written;
1243
+ }
1244
+ }
1245
+
1246
+ function hashOpenFile(fd, label) {
1247
+ const before = fs.fstatSync(fd, { bigint: true });
1248
+ if (!before.isFile()) throw new Error(`${label} is no longer a regular file`);
1249
+ const hash = createHash('sha256');
1250
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
1251
+ let position = 0;
1252
+ for (;;) {
1253
+ const count = fs.readSync(fd, buffer, 0, buffer.length, position);
1254
+ if (count === 0) break;
1255
+ hash.update(buffer.subarray(0, count));
1256
+ position += count;
1257
+ }
1258
+ const after = fs.fstatSync(fd, { bigint: true });
1259
+ assertStableIdentity(before, after, label);
1260
+ return {
1261
+ digest: `sha256:${hash.digest('hex')}`,
1262
+ identity: stableFileIdentity(after),
1263
+ size: after.size,
1264
+ };
1265
+ }
1266
+
1267
+ function validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks) {
1268
+ const before = fs.lstatSync(finalPath, { bigint: true });
1269
+ if (
1270
+ before.isSymbolicLink() ||
1271
+ !before.isFile() ||
1272
+ stableFileIdentity(before) !== expectedTemp.identity
1273
+ ) {
1274
+ throw new Error('Generated-plan destination failed its first post-write identity check');
1275
+ }
1276
+ const finalFd = fs.openSync(
1277
+ finalPath,
1278
+ fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0),
1279
+ );
1280
+ try {
1281
+ const opened = fs.fstatSync(finalFd, { bigint: true });
1282
+ if (!opened.isFile() || stableFileIdentity(opened) !== expectedTemp.identity) {
1283
+ throw new Error('Generated-plan destination changed while its no-follow descriptor opened');
1284
+ }
1285
+ testHooks?.afterFinalOpen?.({ fd: finalFd, finalPath });
1286
+ const committedViaTemp = hashOpenFile(tempFd, 'generated-plan committed file');
1287
+ const committedViaPath = hashOpenFile(finalFd, 'generated-plan destination descriptor');
1288
+ const after = fs.lstatSync(finalPath, { bigint: true });
1289
+ const openedAfter = fs.fstatSync(finalFd, { bigint: true });
1290
+ if (
1291
+ after.isSymbolicLink() ||
1292
+ !after.isFile() ||
1293
+ stableFileIdentity(after) !== expectedTemp.identity ||
1294
+ stableFileIdentity(openedAfter) !== expectedTemp.identity ||
1295
+ committedViaTemp.identity !== expectedTemp.identity ||
1296
+ committedViaPath.identity !== expectedTemp.identity ||
1297
+ committedViaTemp.digest !== expectedTemp.digest ||
1298
+ committedViaPath.digest !== expectedTemp.digest
1299
+ ) {
1300
+ throw new Error('Generated-plan destination failed post-write verification');
1301
+ }
1302
+ } finally {
1303
+ fs.closeSync(finalFd);
1304
+ }
1305
+ }
1306
+
1307
+ function copyOpenFile(sourceFd, destinationFd, label) {
1308
+ const before = fs.fstatSync(sourceFd, { bigint: true });
1309
+ if (!before.isFile()) throw new Error(`${label} source is no longer a regular file`);
1310
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
1311
+ let position = 0;
1312
+ for (;;) {
1313
+ const count = fs.readSync(sourceFd, buffer, 0, buffer.length, position);
1314
+ if (count === 0) break;
1315
+ writeAll(destinationFd, buffer.subarray(0, count));
1316
+ position += count;
1317
+ }
1318
+ const after = fs.fstatSync(sourceFd, { bigint: true });
1319
+ assertStableIdentity(before, after, `${label} source`);
1320
+ return after;
1321
+ }
1322
+
1323
+ function openVerifiedPathFile(absolute, label) {
1324
+ const before = fs.lstatSync(absolute, { bigint: true });
1325
+ if (before.isSymbolicLink() || !before.isFile()) {
1326
+ throw new Error(`${label} is not a regular no-follow file`);
1327
+ }
1328
+ const fd = fs.openSync(
1329
+ absolute,
1330
+ fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0),
1331
+ );
1332
+ try {
1333
+ const opened = fs.fstatSync(fd, { bigint: true });
1334
+ if (!opened.isFile() || stableFileIdentity(opened) !== stableFileIdentity(before)) {
1335
+ throw new Error(`${label} changed while its descriptor opened`);
1336
+ }
1337
+ const layer = hashOpenFile(fd, label);
1338
+ const after = fs.lstatSync(absolute, { bigint: true });
1339
+ if (after.isSymbolicLink() || !after.isFile() || stableFileIdentity(after) !== layer.identity) {
1340
+ throw new Error(`${label} changed after verification`);
1341
+ }
1342
+ return { fd, layer };
1343
+ } catch (error) {
1344
+ fs.closeSync(fd);
1345
+ throw error;
1346
+ }
1347
+ }
1348
+
1349
+ export function readPlanSafely({ repo: repoInput, generatedPlanPath, testHooks } = {}) {
1350
+ const repo = assertRepository(repoInput);
1351
+ const generatedPlan = normalizeGeneratedPlanReadPath(generatedPlanPath);
1352
+ const components = generatedPlan.split('/');
1353
+ const finalName = components.pop();
1354
+ const parentHandle = openPlanParent(repo, components, {
1355
+ createMissing: false,
1356
+ purpose: 'Loaded-plan',
1357
+ });
1358
+ let fd;
1359
+ try {
1360
+ validatePlanParent(parentHandle);
1361
+ const finalPath = descriptorPath(parentHandle.fd, finalName);
1362
+ let before;
1363
+ try {
1364
+ before = fs.lstatSync(finalPath, { bigint: true });
1365
+ } catch (error) {
1366
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
1367
+ throw new Error(`Loaded plan does not exist: ${generatedPlan}`);
1368
+ }
1369
+ throw error;
1370
+ }
1371
+ if (before.isSymbolicLink() || !before.isFile()) {
1372
+ throw new Error('Loaded plan must be a regular file, never a symlink');
1373
+ }
1374
+ fd = fs.openSync(
1375
+ finalPath,
1376
+ fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_CLOEXEC ?? 0),
1377
+ );
1378
+ const opened = fs.fstatSync(fd, { bigint: true });
1379
+ if (!opened.isFile() || statIdentity(opened) !== statIdentity(before)) {
1380
+ throw new Error('Loaded plan changed while its no-follow descriptor opened');
1381
+ }
1382
+ testHooks?.afterPlanOpen?.({ fd, finalPath });
1383
+ const chunks = [];
1384
+ let total = 0;
1385
+ const buffer = Buffer.allocUnsafe(64 * 1024);
1386
+ for (;;) {
1387
+ const count = fs.readSync(fd, buffer, 0, buffer.length, null);
1388
+ if (count === 0) break;
1389
+ total += count;
1390
+ if (total > MAX_PLAN_BYTES) throw new Error(`Loaded plan exceeds ${MAX_PLAN_BYTES} bytes`);
1391
+ chunks.push(Buffer.from(buffer.subarray(0, count)));
1392
+ }
1393
+ const contents = Buffer.concat(chunks, total);
1394
+ decodeUtf8(contents, 'loaded plan');
1395
+ const after = fs.fstatSync(fd, { bigint: true });
1396
+ assertStableIdentity(opened, after, 'loaded plan');
1397
+ const pathAfter = fs.lstatSync(finalPath, { bigint: true });
1398
+ if (
1399
+ pathAfter.isSymbolicLink() ||
1400
+ !pathAfter.isFile() ||
1401
+ statIdentity(pathAfter) !== statIdentity(after)
1402
+ ) {
1403
+ throw new Error('Loaded plan changed before its receipt was produced');
1404
+ }
1405
+ validatePlanParent(parentHandle);
1406
+ return {
1407
+ generated_plan_path: generatedPlan,
1408
+ bytes_read: contents.length,
1409
+ plan_digest: sha256(contents),
1410
+ plan_bytes_base64: contents.toString('base64'),
1411
+ };
1412
+ } finally {
1413
+ if (fd !== undefined) fs.closeSync(fd);
1414
+ closeDescriptors(parentHandle.descriptors);
1415
+ }
1416
+ }
1417
+
1418
+ function artifactGitPath(name) {
1419
+ return `gitnexus-plan-backups/${name}`;
1420
+ }
1421
+
1422
+ function verifyVaultArtifactFromFreshRoot(repo, gitPath, expectedLayer) {
1423
+ const components = gitPath.split('/');
1424
+ if (components.length !== 2 || components[0] !== 'gitnexus-plan-backups') {
1425
+ throw new Error(`Invalid Git-admin artifact path: ${gitPath}`);
1426
+ }
1427
+ const freshVault = openBackupVault(repo, { createMissing: false });
1428
+ try {
1429
+ validatePlanParent(freshVault);
1430
+ const opened = openVerifiedPathFile(
1431
+ descriptorPath(freshVault.fd, components[1]),
1432
+ `Git-admin artifact ${gitPath}`,
1433
+ );
1434
+ try {
1435
+ if (
1436
+ opened.layer.identity !== expectedLayer.identity ||
1437
+ opened.layer.digest !== expectedLayer.digest
1438
+ ) {
1439
+ throw new Error(`Git-admin artifact changed before fresh-root verification: ${gitPath}`);
1440
+ }
1441
+ } finally {
1442
+ fs.closeSync(opened.fd);
1443
+ }
1444
+ } finally {
1445
+ closeDescriptors(freshVault.descriptors);
1446
+ }
1447
+ }
1448
+
1449
+ function createVaultCopyFromFd(repo, vault, sourceFd, role) {
1450
+ validatePlanParent(vault);
1451
+ const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`;
1452
+ const absolute = descriptorPath(vault.fd, name);
1453
+ const destinationFd = fs.openSync(
1454
+ absolute,
1455
+ fs.constants.O_RDWR |
1456
+ fs.constants.O_CREAT |
1457
+ fs.constants.O_EXCL |
1458
+ fs.constants.O_NOFOLLOW |
1459
+ (fs.constants.O_CLOEXEC ?? 0),
1460
+ 0o600,
1461
+ );
1462
+ let destination;
1463
+ try {
1464
+ const sourceStat = copyOpenFile(sourceFd, destinationFd, role);
1465
+ fs.fchmodSync(destinationFd, Number(sourceStat.mode & 0o777n));
1466
+ fs.fsyncSync(destinationFd);
1467
+ const source = hashOpenFile(sourceFd, role);
1468
+ destination = hashOpenFile(destinationFd, `${role} vault copy`);
1469
+ if (source.size !== destination.size || source.digest !== destination.digest) {
1470
+ throw new Error(`${role} vault copy does not match its held source descriptor`);
1471
+ }
1472
+ const pathStat = fs.lstatSync(absolute, { bigint: true });
1473
+ if (
1474
+ pathStat.isSymbolicLink() ||
1475
+ !pathStat.isFile() ||
1476
+ stableFileIdentity(pathStat) !== destination.identity
1477
+ ) {
1478
+ throw new Error(`${role} vault path changed during preservation`);
1479
+ }
1480
+ fs.fsyncSync(vault.fd);
1481
+ } finally {
1482
+ fs.closeSync(destinationFd);
1483
+ }
1484
+ const gitPath = artifactGitPath(name);
1485
+ verifyVaultArtifactFromFreshRoot(repo, gitPath, destination);
1486
+ return { role, gitPath, layer: destination };
1487
+ }
1488
+
1489
+ function createVaultCopyFromBytes(repo, vault, contents, role) {
1490
+ validatePlanParent(vault);
1491
+ const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`;
1492
+ const absolute = descriptorPath(vault.fd, name);
1493
+ const fd = fs.openSync(
1494
+ absolute,
1495
+ fs.constants.O_RDWR |
1496
+ fs.constants.O_CREAT |
1497
+ fs.constants.O_EXCL |
1498
+ fs.constants.O_NOFOLLOW |
1499
+ (fs.constants.O_CLOEXEC ?? 0),
1500
+ 0o600,
1501
+ );
1502
+ let layer;
1503
+ try {
1504
+ writeAll(fd, contents);
1505
+ fs.fchmodSync(fd, 0o644);
1506
+ fs.fsyncSync(fd);
1507
+ layer = hashOpenFile(fd, `${role} vault copy`);
1508
+ if (layer.size !== BigInt(contents.length) || layer.digest !== sha256(contents)) {
1509
+ throw new Error(`${role} vault copy does not match the intended plan bytes`);
1510
+ }
1511
+ const pathStat = fs.lstatSync(absolute, { bigint: true });
1512
+ if (
1513
+ pathStat.isSymbolicLink() ||
1514
+ !pathStat.isFile() ||
1515
+ stableFileIdentity(pathStat) !== layer.identity
1516
+ ) {
1517
+ throw new Error(`${role} vault path changed during preservation`);
1518
+ }
1519
+ fs.fsyncSync(vault.fd);
1520
+ } finally {
1521
+ fs.closeSync(fd);
1522
+ }
1523
+ const gitPath = artifactGitPath(name);
1524
+ verifyVaultArtifactFromFreshRoot(repo, gitPath, layer);
1525
+ return { role, gitPath, layer };
1526
+ }
1527
+
1528
+ function movePathToVault(repo, sourceHandle, sourceName, vault, role) {
1529
+ const source = descriptorPath(sourceHandle.fd, sourceName);
1530
+ if (!lstatOptional(source)) return null;
1531
+ const name = `.gitnexus-plan-${role}-${process.pid}-${randomBytes(16).toString('hex')}.bak`;
1532
+ const destination = descriptorPath(vault.fd, name);
1533
+ const moved = atomicMoveNoReplace(
1534
+ externalDescriptorPath(sourceHandle.fd, sourceName),
1535
+ externalDescriptorPath(vault.fd, name),
1536
+ );
1537
+ if (!moved) throw new Error(`${role} preservation destination unexpectedly exists`);
1538
+ fs.fsyncSync(sourceHandle.fd);
1539
+ if (vault.fd !== sourceHandle.fd) fs.fsyncSync(vault.fd);
1540
+ const sourceAfter = lstatOptional(source);
1541
+ const destinationAfter = lstatOptional(destination);
1542
+ if (sourceAfter || !destinationAfter) {
1543
+ throw new Error(`${role} could not be atomically moved into the Git-admin vault`);
1544
+ }
1545
+ const opened = openVerifiedPathFile(destination, `${role} Git-admin artifact`);
1546
+ const gitPath = artifactGitPath(name);
1547
+ verifyVaultArtifactFromFreshRoot(repo, gitPath, opened.layer);
1548
+ return { role, gitPath, layer: opened.layer, fd: opened.fd };
1549
+ }
1550
+
1551
+ function formatPreservedArtifacts(artifacts) {
1552
+ if (artifacts.length === 0) return '';
1553
+ return `; preserved Git-admin artifacts: ${artifacts
1554
+ .map((artifact) => `${artifact.role}=git-path:${artifact.gitPath}`)
1555
+ .join(', ')}`;
1556
+ }
1557
+
1558
+ export function writePlanSafely({
1559
+ repo: repoInput,
1560
+ generatedPlanPath,
1561
+ contents: inputContents,
1562
+ replace = false,
1563
+ expectedPlanPath,
1564
+ expectedPlanDigest,
1565
+ testHooks,
1566
+ } = {}) {
1567
+ const shouldReplace = requireBoolean(replace, 'replace');
1568
+ if (!Buffer.isBuffer(inputContents) && typeof inputContents !== 'string') {
1569
+ throw new Error('contents must be a string or Buffer');
1570
+ }
1571
+ let expectedDigest;
1572
+ if (shouldReplace) {
1573
+ expectedDigest = normalizeSha256Digest(
1574
+ expectedPlanDigest,
1575
+ 'expectedPlanDigest from the read-plan receipt',
1576
+ );
1577
+ } else if (expectedPlanPath !== undefined || expectedPlanDigest !== undefined) {
1578
+ throw new Error('expectedPlanPath and expectedPlanDigest are valid only when replace is true');
1579
+ }
1580
+ const repo = assertRepository(repoInput);
1581
+ const generatedPlan = normalizeGeneratedPlanWritePath(generatedPlanPath);
1582
+ if (shouldReplace) {
1583
+ const receiptPath = normalizeGeneratedPlanWritePath(
1584
+ requireString(expectedPlanPath, 'expectedPlanPath from the read-plan receipt'),
1585
+ );
1586
+ if (receiptPath !== generatedPlan) {
1587
+ throw new Error(
1588
+ 'expectedPlanPath from the read-plan receipt must exactly match generatedPlanPath',
1589
+ );
1590
+ }
1591
+ }
1592
+ const contents = Buffer.isBuffer(inputContents)
1593
+ ? Buffer.from(inputContents)
1594
+ : Buffer.from(inputContents, 'utf8');
1595
+ decodeUtf8(contents, 'generated plan');
1596
+ if (contents.length > MAX_PLAN_BYTES) {
1597
+ throw new Error(`Generated plan exceeds ${MAX_PLAN_BYTES} bytes`);
1598
+ }
1599
+ const components = generatedPlan.split('/');
1600
+ const finalName = components.pop();
1601
+ let parentHandle;
1602
+ let vaultHandle;
1603
+ let tempPath;
1604
+ let tempName;
1605
+ let tempFd;
1606
+ let finalPath;
1607
+ let expectedTemp;
1608
+ let originalDestination;
1609
+ let priorBackup;
1610
+ const preservedArtifacts = [];
1611
+ try {
1612
+ parentHandle = openPlanParent(repo, components);
1613
+ vaultHandle = openBackupVault(repo);
1614
+ resolveAtomicMover();
1615
+ const parentDevice = fs.fstatSync(parentHandle.fd, { bigint: true }).dev;
1616
+ const vaultDevice = fs.fstatSync(vaultHandle.fd, { bigint: true }).dev;
1617
+ if (parentDevice !== vaultDevice) {
1618
+ throw new Error(
1619
+ 'Generated-plan parent and Git-admin backup vault must share a filesystem for atomic publication',
1620
+ );
1621
+ }
1622
+ testHooks?.afterParentOpen?.({ fd: parentHandle.fd, path: parentHandle.expectedPath });
1623
+ validatePlanParent(parentHandle);
1624
+ validatePlanParent(vaultHandle);
1625
+ finalPath = descriptorPath(parentHandle.fd, finalName);
1626
+ originalDestination = openExistingPlanDestination(finalPath, shouldReplace);
1627
+ tempName = `.gitnexus-plan-${process.pid}-${randomBytes(16).toString('hex')}.tmp`;
1628
+ tempPath = descriptorPath(parentHandle.fd, tempName);
1629
+ tempFd = fs.openSync(
1630
+ tempPath,
1631
+ fs.constants.O_RDWR |
1632
+ fs.constants.O_CREAT |
1633
+ fs.constants.O_EXCL |
1634
+ fs.constants.O_NOFOLLOW |
1635
+ (fs.constants.O_CLOEXEC ?? 0),
1636
+ 0o600,
1637
+ );
1638
+ writeAll(tempFd, contents);
1639
+ fs.fchmodSync(tempFd, 0o644);
1640
+ fs.fsyncSync(tempFd);
1641
+ expectedTemp = hashOpenFile(tempFd, 'generated-plan temporary file');
1642
+ if (expectedTemp.size !== BigInt(contents.length) || expectedTemp.digest !== sha256(contents)) {
1643
+ throw new Error('Generated-plan temporary file failed verification');
1644
+ }
1645
+
1646
+ testHooks?.beforeRename?.({
1647
+ fd: parentHandle.fd,
1648
+ path: parentHandle.expectedPath,
1649
+ tempPath,
1650
+ });
1651
+ validatePlanParent(parentHandle);
1652
+ validatePlanParent(vaultHandle);
1653
+ validateOpenPlanDestination(originalDestination);
1654
+ const tempPathStat = fs.lstatSync(tempPath, { bigint: true });
1655
+ const currentTemp = hashOpenFile(tempFd, 'generated-plan temporary file');
1656
+ if (
1657
+ tempPathStat.isSymbolicLink() ||
1658
+ !tempPathStat.isFile() ||
1659
+ stableFileIdentity(tempPathStat) !== expectedTemp.identity ||
1660
+ currentTemp.identity !== expectedTemp.identity ||
1661
+ currentTemp.digest !== expectedTemp.digest
1662
+ ) {
1663
+ throw new Error('Generated-plan temporary path or content changed before rename');
1664
+ }
1665
+
1666
+ if (shouldReplace) {
1667
+ testHooks?.beforeBackupMove?.({ fd: parentHandle.fd, finalPath });
1668
+ const originalLayer = hashOpenFile(originalDestination.fd, 'prior generated plan');
1669
+ if (originalLayer.digest !== expectedDigest) {
1670
+ throw new Error(
1671
+ 'Generated plan no longer matches the exact digest from the read-plan receipt',
1672
+ );
1673
+ }
1674
+ validatePlanParent(parentHandle);
1675
+ validateOpenPlanDestination(originalDestination);
1676
+ inspectPlanDestination(finalPath, {
1677
+ replace: true,
1678
+ expectedIdentity: originalDestination.identity,
1679
+ });
1680
+ priorBackup = movePathToVault(repo, parentHandle, finalName, vaultHandle, 'prior-plan');
1681
+ if (!priorBackup) {
1682
+ throw new Error('Existing generated plan disappeared before preservation');
1683
+ }
1684
+ preservedArtifacts.push(priorBackup);
1685
+ if (
1686
+ priorBackup.layer.identity !== originalDestination.stableIdentity ||
1687
+ priorBackup.layer.digest !== originalLayer.digest
1688
+ ) {
1689
+ preservedArtifacts.push(
1690
+ createVaultCopyFromFd(repo, vaultHandle, originalDestination.fd, 'expected-prior-plan'),
1691
+ );
1692
+ throw new Error('Destination raced while the prior plan was moved into preservation');
1693
+ }
1694
+ if (lstatOptional(finalPath)) {
1695
+ throw new Error('Destination reappeared after the prior plan was preserved');
1696
+ }
1697
+ }
1698
+
1699
+ testHooks?.beforePublication?.({
1700
+ fd: parentHandle.fd,
1701
+ finalPath,
1702
+ tempPath,
1703
+ replace: shouldReplace,
1704
+ });
1705
+ validatePlanParent(parentHandle);
1706
+ validatePlanParent(vaultHandle);
1707
+ const finalTempPathStat = fs.lstatSync(tempPath, { bigint: true });
1708
+ const finalTemp = hashOpenFile(tempFd, 'generated-plan temporary file');
1709
+ if (
1710
+ finalTempPathStat.isSymbolicLink() ||
1711
+ !finalTempPathStat.isFile() ||
1712
+ stableFileIdentity(finalTempPathStat) !== expectedTemp.identity ||
1713
+ finalTemp.identity !== expectedTemp.identity ||
1714
+ finalTemp.digest !== expectedTemp.digest
1715
+ ) {
1716
+ throw new Error('Generated-plan temporary path or content changed at publication');
1717
+ }
1718
+ atomicMoveNoReplace(
1719
+ externalDescriptorPath(parentHandle.fd, tempName),
1720
+ externalDescriptorPath(parentHandle.fd, finalName),
1721
+ );
1722
+ if (lstatOptional(tempPath) || !lstatOptional(finalPath)) {
1723
+ throw new Error('Generated-plan publication was refused because the destination raced');
1724
+ }
1725
+ fs.fsyncSync(parentHandle.fd);
1726
+ testHooks?.afterPublication?.({ fd: parentHandle.fd, finalPath });
1727
+ testHooks?.afterRename?.({ fd: parentHandle.fd, finalPath });
1728
+ validatePlanParent(parentHandle);
1729
+ validatePlanParent(vaultHandle);
1730
+ validateCommittedPlan(finalPath, tempFd, expectedTemp, testHooks);
1731
+ const receipt = { generated_plan_path: generatedPlan, bytes_written: contents.length };
1732
+ if (priorBackup) receipt.prior_plan_backup_git_path = priorBackup.gitPath;
1733
+ return receipt;
1734
+ } catch (error) {
1735
+ const preservationErrors = [];
1736
+ let intendedPreserved = preservedArtifacts.some(
1737
+ (artifact) =>
1738
+ expectedTemp &&
1739
+ artifact.layer.identity === expectedTemp.identity &&
1740
+ artifact.layer.digest === expectedTemp.digest,
1741
+ );
1742
+ if (parentHandle && vaultHandle && tempName) {
1743
+ try {
1744
+ const movedTemp = movePathToVault(
1745
+ repo,
1746
+ parentHandle,
1747
+ tempName,
1748
+ vaultHandle,
1749
+ 'unpublished-plan',
1750
+ );
1751
+ if (movedTemp) {
1752
+ preservedArtifacts.push(movedTemp);
1753
+ intendedPreserved =
1754
+ Boolean(expectedTemp) &&
1755
+ movedTemp.layer.identity === expectedTemp.identity &&
1756
+ movedTemp.layer.digest === expectedTemp.digest;
1757
+ fs.closeSync(movedTemp.fd);
1758
+ }
1759
+ } catch (preservationError) {
1760
+ preservationErrors.push(preservationError);
1761
+ }
1762
+ }
1763
+ if (vaultHandle && expectedTemp && !intendedPreserved) {
1764
+ try {
1765
+ preservedArtifacts.push(
1766
+ createVaultCopyFromBytes(repo, vaultHandle, contents, 'intended-plan'),
1767
+ );
1768
+ } catch (preservationError) {
1769
+ preservationErrors.push(preservationError);
1770
+ }
1771
+ }
1772
+ if (vaultHandle && originalDestination?.fd !== undefined) {
1773
+ try {
1774
+ const originalLayer = hashOpenFile(originalDestination.fd, 'prior generated plan');
1775
+ const priorPreserved = preservedArtifacts.some(
1776
+ (artifact) => artifact.layer.digest === originalLayer.digest,
1777
+ );
1778
+ if (!priorPreserved) {
1779
+ preservedArtifacts.push(
1780
+ createVaultCopyFromFd(repo, vaultHandle, originalDestination.fd, 'expected-prior-plan'),
1781
+ );
1782
+ }
1783
+ } catch (preservationError) {
1784
+ preservationErrors.push(preservationError);
1785
+ }
1786
+ }
1787
+ const message = error instanceof Error ? error.message : String(error);
1788
+ const artifactSummary = formatPreservedArtifacts(preservedArtifacts);
1789
+ const preservationSummary =
1790
+ preservationErrors.length === 0
1791
+ ? ''
1792
+ : `; preservation failures: ${preservationErrors
1793
+ .map((failure) => (failure instanceof Error ? failure.message : String(failure)))
1794
+ .join(' | ')}`;
1795
+ if (error?.code === 'EACCES' || error?.code === 'EPERM' || error?.code === 'EROFS') {
1796
+ throw new Error(
1797
+ `Cannot safely write generated plan: checkout is read-only or its parent is not writable (${error.code})${artifactSummary}${preservationSummary}`,
1798
+ );
1799
+ }
1800
+ throw new Error(`${message}${artifactSummary}${preservationSummary}`);
1801
+ } finally {
1802
+ if (priorBackup?.fd !== undefined) {
1803
+ try {
1804
+ fs.closeSync(priorBackup.fd);
1805
+ } catch {
1806
+ // Preserve the primary write result/error.
1807
+ }
1808
+ }
1809
+ if (originalDestination?.fd !== undefined) {
1810
+ try {
1811
+ fs.closeSync(originalDestination.fd);
1812
+ } catch {
1813
+ // Preserve the primary write result/error.
1814
+ }
1815
+ }
1816
+ if (tempFd !== undefined) {
1817
+ try {
1818
+ fs.closeSync(tempFd);
1819
+ } catch {
1820
+ // Preserve the primary write result/error.
1821
+ }
1822
+ }
1823
+ if (vaultHandle) closeDescriptors(vaultHandle.descriptors);
1824
+ if (parentHandle) closeDescriptors(parentHandle.descriptors);
1825
+ }
1826
+ }
1827
+
1828
+ export function snapshotEvidence({
1829
+ repo: repoInput,
1830
+ generatedPlanPath,
1831
+ citedPaths = [],
1832
+ testHooks,
1833
+ } = {}) {
1834
+ if (!Array.isArray(citedPaths) || citedPaths.some((entry) => typeof entry !== 'string')) {
1835
+ throw new Error('citedPaths must be an array of strings');
1836
+ }
1837
+ const repo = assertRepository(repoInput);
1838
+ const generatedPlan = normalizeGeneratedPlanWritePath(generatedPlanPath);
1839
+ const normalizedCitations = new Set(
1840
+ citedPaths.map((citedPath) => normalizeRepoPath(citedPath, 'cited path')),
1841
+ );
1842
+ const initialHead = git(repo, ['rev-parse', '--verify', 'HEAD']).stdout;
1843
+ const head = decodeUtf8(initialHead, 'HEAD commit').trim();
1844
+ if (!/^[0-9a-f]{40,64}$/.test(head)) throw new Error('HEAD did not resolve to a full object ID');
1845
+ const initialDirty = readDirtySnapshot(repo);
1846
+ const initialIndex = git(repo, ['ls-files', '--stage', '-z']).stdout;
1847
+ const indexGuard = captureControlFile(resolveAdministrativePath(repo, 'index'), 'Git index');
1848
+ const headGuards = captureHeadGuards(repo);
1849
+ const dirty = initialDirty.records;
1850
+ const mutationGuards = [];
1851
+
1852
+ try {
1853
+ testHooks?.afterAnchorCapture?.({ headCommit: head });
1854
+ for (const citedPath of [...normalizedCitations]) {
1855
+ const status = dirty.get(citedPath);
1856
+ if (status?.rename_from) normalizedCitations.add(status.rename_from);
1857
+ if (status?.rename_to) normalizedCitations.add(status.rename_to);
1858
+ }
1859
+
1860
+ const neededPaths = new Set([...dirty.keys(), ...normalizedCitations]);
1861
+ const layers = loadGitLayers(repo, neededPaths, head, initialIndex);
1862
+ testHooks?.afterGitLayerLoad?.({ headCommit: head });
1863
+ const globalEntries = [...dirty.values()]
1864
+ .filter((record) => record.path !== generatedPlan)
1865
+ .map((record) => materializeRecord(repo, record, layers, mutationGuards, testHooks));
1866
+ const citedEntries = [...normalizedCitations].sort(compareUtf8).map((repoPath) => {
1867
+ const status = dirty.get(repoPath) ?? {
1868
+ path: repoPath,
1869
+ state: 'clean',
1870
+ rename_from: null,
1871
+ rename_to: null,
1872
+ has_untracked: false,
1873
+ };
1874
+ const entry = materializeRecord(repo, status, layers, mutationGuards, testHooks);
1875
+ const present = Object.values(entry.object_kind).some((kind) => kind !== ABSENT);
1876
+ if (!present) entry.state = ABSENT;
1877
+ else if (entry.state === 'clean' && entry.object_kind.untracked !== ABSENT) {
1878
+ entry.state = 'untracked';
1879
+ }
1880
+ return entry;
1881
+ });
1882
+ const dirtyBytes = serializeDirtyRecords(globalEntries);
1883
+ const verifyGuards = () => {
1884
+ for (const guard of mutationGuards) {
1885
+ if (guard.type === 'stat') {
1886
+ const current = fs.lstatSync(guard.absolute, { bigint: true });
1887
+ if (statIdentity(current) !== guard.identity) {
1888
+ throw new Error(`${guard.absolute} changed before evidence materialization completed`);
1889
+ }
1890
+ } else if (guard.type === 'directory') {
1891
+ const current = fs.lstatSync(guard.absolute, { bigint: true });
1892
+ if (!current.isDirectory() || stableDirectoryIdentity(current) !== guard.identity) {
1893
+ throw new Error(`${guard.absolute} changed before evidence materialization completed`);
1894
+ }
1895
+ } else if (guard.type === 'symlink') {
1896
+ const before = fs.lstatSync(guard.absolute, { bigint: true });
1897
+ const target = fs.readlinkSync(guard.absolute, { encoding: 'buffer' });
1898
+ const after = fs.lstatSync(guard.absolute, { bigint: true });
1899
+ assertStableIdentity(before, after, guard.absolute);
1900
+ if (statIdentity(after) !== guard.identity || !Buffer.from(target).equals(guard.target)) {
1901
+ throw new Error(`${guard.absolute} changed before evidence materialization completed`);
1902
+ }
1903
+ } else if (guard.type === 'gitlink') {
1904
+ const current = readOwnGitlinkHead(guard.absolute);
1905
+ if (current.oid !== guard.oid || current.topLevel !== guard.topLevel) {
1906
+ throw new Error(`${guard.absolute} changed before evidence materialization completed`);
1907
+ }
1908
+ } else if (guard.type === 'absence') {
1909
+ const parent = fs.fstatSync(guard.fd, { bigint: true });
1910
+ if (
1911
+ !parent.isDirectory() ||
1912
+ stableDirectoryIdentity(parent) !== guard.parentIdentity ||
1913
+ statIdentity(parent) !== guard.parentMutationIdentity
1914
+ ) {
1915
+ throw new Error(`Absence anchor changed for ${guard.repoPath}`);
1916
+ }
1917
+ try {
1918
+ fs.lstatSync(descriptorPath(guard.fd, guard.childName), { bigint: true });
1919
+ } catch (error) {
1920
+ if (error?.code === 'ENOENT') continue;
1921
+ throw error;
1922
+ }
1923
+ throw new Error(`${guard.repoPath} appeared before evidence materialization completed`);
1924
+ }
1925
+ }
1926
+ for (const guard of headGuards) verifyControlFile(guard);
1927
+ verifyControlFile(indexGuard);
1928
+ };
1929
+ testHooks?.afterMaterialize?.();
1930
+ verifyGuards();
1931
+ testHooks?.afterFirstGuardPass?.();
1932
+ const finalDirty = readDirtySnapshot(repo);
1933
+ const finalHead = git(repo, ['rev-parse', '--verify', 'HEAD']).stdout;
1934
+ const finalIndex = git(repo, ['ls-files', '--stage', '-z']).stdout;
1935
+ if (
1936
+ !initialDirty.output.equals(finalDirty.output) ||
1937
+ !initialHead.equals(finalHead) ||
1938
+ !initialIndex.equals(finalIndex)
1939
+ ) {
1940
+ throw new Error(
1941
+ 'HEAD, index, or working-tree status changed while evidence was materialized',
1942
+ );
1943
+ }
1944
+ verifyGuards();
1945
+
1946
+ return {
1947
+ schema_version: EVIDENCE_PROVENANCE_SCHEMA_VERSION,
1948
+ head_commit: head,
1949
+ generated_plan_path: generatedPlan,
1950
+ global_dirty_digest: {
1951
+ algorithm: 'sha256',
1952
+ canonicalization: EVIDENCE_PROVENANCE_CANONICALIZATION,
1953
+ value: sha256(dirtyBytes).slice('sha256:'.length),
1954
+ },
1955
+ cited_path_manifest: citedEntries,
1956
+ };
1957
+ } finally {
1958
+ const closed = new Set();
1959
+ for (const guard of mutationGuards) {
1960
+ if (guard.type !== 'absence' || closed.has(guard.fd)) continue;
1961
+ closed.add(guard.fd);
1962
+ try {
1963
+ fs.closeSync(guard.fd);
1964
+ } catch {
1965
+ // Preserve the primary snapshot result/error.
1966
+ }
1967
+ }
1968
+ }
1969
+ }
1970
+
1971
+ function parseCli(argv) {
1972
+ const args = [...argv];
1973
+ const command = args[0] && !args[0].startsWith('--') ? args.shift() : 'snapshot';
1974
+ if (!['snapshot', 'read-plan', 'write-plan'].includes(command)) {
1975
+ throw new Error(`Unsupported command: ${command}`);
1976
+ }
1977
+ const allowed = {
1978
+ snapshot: new Set(['--repo', '--generated-plan', '--cited', '--schema-version']),
1979
+ 'read-plan': new Set(['--repo', '--generated-plan']),
1980
+ 'write-plan': new Set([
1981
+ '--repo',
1982
+ '--generated-plan',
1983
+ '--replace',
1984
+ '--expected-plan-path',
1985
+ '--expected-plan-digest',
1986
+ ]),
1987
+ }[command];
1988
+ let repo;
1989
+ let generatedPlanPath;
1990
+ let schemaVersion = EVIDENCE_PROVENANCE_SCHEMA_VERSION;
1991
+ let replace = false;
1992
+ let expectedPlanPath;
1993
+ let expectedPlanDigest;
1994
+ const citedPaths = [];
1995
+ const seen = new Set();
1996
+ while (args.length > 0) {
1997
+ const flag = args.shift();
1998
+ if (typeof flag !== 'string' || !flag.startsWith('--')) {
1999
+ throw new Error(`Unexpected positional argument: ${flag}`);
2000
+ }
2001
+ if (!allowed.has(flag)) throw new Error(`${flag} is not valid for ${command}`);
2002
+ if (flag === '--replace') {
2003
+ if (seen.has(flag)) throw new Error(`Duplicate option: ${flag}`);
2004
+ seen.add(flag);
2005
+ replace = true;
2006
+ continue;
2007
+ }
2008
+ if (flag !== '--cited' && seen.has(flag)) throw new Error(`Duplicate option: ${flag}`);
2009
+ seen.add(flag);
2010
+ const value = args.shift();
2011
+ if (value === undefined || value.startsWith('--')) throw new Error(`Missing value for ${flag}`);
2012
+ if (flag === '--repo') repo = value;
2013
+ else if (flag === '--generated-plan') generatedPlanPath = value;
2014
+ else if (flag === '--cited') citedPaths.push(value);
2015
+ else if (flag === '--schema-version') {
2016
+ if (!/^\d+$/.test(value)) throw new Error('--schema-version must be an integer');
2017
+ schemaVersion = Number(value);
2018
+ } else if (flag === '--expected-plan-path') expectedPlanPath = value;
2019
+ else if (flag === '--expected-plan-digest') expectedPlanDigest = value;
2020
+ }
2021
+ if (!repo) throw new Error('--repo is required');
2022
+ if (!generatedPlanPath) throw new Error('--generated-plan is required');
2023
+ if (command === 'snapshot' && schemaVersion !== EVIDENCE_PROVENANCE_SCHEMA_VERSION) {
2024
+ throw new Error(
2025
+ `Unsupported evidence provenance schema ${schemaVersion}; schema 1 is legacy and must be conservatively re-anchored`,
2026
+ );
2027
+ }
2028
+ if (command === 'write-plan') {
2029
+ if (replace && (expectedPlanPath === undefined || expectedPlanDigest === undefined)) {
2030
+ throw new Error(
2031
+ '--replace requires --expected-plan-path and --expected-plan-digest from read-plan',
2032
+ );
2033
+ }
2034
+ if (!replace && (expectedPlanPath !== undefined || expectedPlanDigest !== undefined)) {
2035
+ throw new Error('--expected-plan-path and --expected-plan-digest require --replace');
2036
+ }
2037
+ }
2038
+ return {
2039
+ command,
2040
+ repo,
2041
+ generatedPlanPath,
2042
+ citedPaths,
2043
+ replace,
2044
+ expectedPlanPath,
2045
+ expectedPlanDigest,
2046
+ };
2047
+ }
2048
+
2049
+ function readStdinBounded() {
2050
+ const chunks = [];
2051
+ let total = 0;
2052
+ const buffer = Buffer.allocUnsafe(64 * 1024);
2053
+ for (;;) {
2054
+ const count = fs.readSync(0, buffer, 0, buffer.length, null);
2055
+ if (count === 0) break;
2056
+ total += count;
2057
+ if (total > MAX_PLAN_BYTES) throw new Error(`Generated plan exceeds ${MAX_PLAN_BYTES} bytes`);
2058
+ chunks.push(Buffer.from(buffer.subarray(0, count)));
2059
+ }
2060
+ return Buffer.concat(chunks, total);
2061
+ }
2062
+
2063
+ function main() {
2064
+ try {
2065
+ const options = parseCli(process.argv.slice(2));
2066
+ let result;
2067
+ if (options.command === 'write-plan') {
2068
+ result = writePlanSafely({ ...options, contents: readStdinBounded() });
2069
+ } else if (options.command === 'read-plan') {
2070
+ result = readPlanSafely(options);
2071
+ } else {
2072
+ result = snapshotEvidence(options);
2073
+ }
2074
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
2075
+ } catch (error) {
2076
+ process.stderr.write(
2077
+ `evidence-provenance: ${error instanceof Error ? error.message : String(error)}\n`,
2078
+ );
2079
+ process.exitCode = 1;
2080
+ }
2081
+ }
2082
+
2083
+ const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : null;
2084
+ if (invokedPath && invokedPath === fileURLToPath(import.meta.url)) main();