audit-tools 0.55.4 → 0.55.6

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 (262) hide show
  1. package/dist/audit/cli/auditReviewBindings.d.ts +4 -4
  2. package/dist/audit/cli/auditStep.d.ts +15 -1
  3. package/dist/audit/cli/auditStep.d.ts.map +1 -1
  4. package/dist/audit/cli/auditStep.js +24 -11
  5. package/dist/audit/cli/auditStep.js.map +1 -1
  6. package/dist/audit/cli/charterExtractionPrompt.d.ts.map +1 -1
  7. package/dist/audit/cli/charterExtractionPrompt.js +3 -1
  8. package/dist/audit/cli/charterExtractionPrompt.js.map +1 -1
  9. package/dist/audit/cli/cleanup.d.ts.map +1 -1
  10. package/dist/audit/cli/cleanup.js +26 -2
  11. package/dist/audit/cli/cleanup.js.map +1 -1
  12. package/dist/audit/cli/conceptualDispatch.d.ts +2 -0
  13. package/dist/audit/cli/conceptualDispatch.d.ts.map +1 -1
  14. package/dist/audit/cli/conceptualDispatch.js +29 -8
  15. package/dist/audit/cli/conceptualDispatch.js.map +1 -1
  16. package/dist/audit/cli/dispatch/hostHandoff.d.ts +37 -3
  17. package/dist/audit/cli/dispatch/hostHandoff.d.ts.map +1 -1
  18. package/dist/audit/cli/dispatch/hostHandoff.js +185 -16
  19. package/dist/audit/cli/dispatch/hostHandoff.js.map +1 -1
  20. package/dist/audit/cli/dispatch/packetFilter.d.ts +1 -1
  21. package/dist/audit/cli/envelope.d.ts +1 -1
  22. package/dist/audit/cli/envelope.d.ts.map +1 -1
  23. package/dist/audit/cli/envelope.js +4 -1
  24. package/dist/audit/cli/envelope.js.map +1 -1
  25. package/dist/audit/cli/fanoutLanes.d.ts +30 -0
  26. package/dist/audit/cli/fanoutLanes.d.ts.map +1 -1
  27. package/dist/audit/cli/fanoutLanes.js +76 -8
  28. package/dist/audit/cli/fanoutLanes.js.map +1 -1
  29. package/dist/audit/cli/laneValidators.d.ts +9 -6
  30. package/dist/audit/cli/laneValidators.d.ts.map +1 -1
  31. package/dist/audit/cli/laneValidators.js +22 -7
  32. package/dist/audit/cli/laneValidators.js.map +1 -1
  33. package/dist/audit/cli/nextStepCommand.d.ts +3 -0
  34. package/dist/audit/cli/nextStepCommand.d.ts.map +1 -1
  35. package/dist/audit/cli/nextStepCommand.js +104 -66
  36. package/dist/audit/cli/nextStepCommand.js.map +1 -1
  37. package/dist/audit/cli/nextStepHelpers.d.ts +49 -14
  38. package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
  39. package/dist/audit/cli/nextStepHelpers.js +169 -68
  40. package/dist/audit/cli/nextStepHelpers.js.map +1 -1
  41. package/dist/audit/cli/reviewRun.d.ts +10 -0
  42. package/dist/audit/cli/reviewRun.d.ts.map +1 -1
  43. package/dist/audit/cli/reviewRun.js +4 -3
  44. package/dist/audit/cli/reviewRun.js.map +1 -1
  45. package/dist/audit/cli/sampleRunCommand.d.ts +1 -1
  46. package/dist/audit/cli/semanticReviewStep.d.ts +2 -0
  47. package/dist/audit/cli/semanticReviewStep.d.ts.map +1 -1
  48. package/dist/audit/cli/semanticReviewStep.js +5 -50
  49. package/dist/audit/cli/semanticReviewStep.js.map +1 -1
  50. package/dist/audit/cli/validateResultsCommand.d.ts.map +1 -1
  51. package/dist/audit/cli/validateResultsCommand.js +3 -1
  52. package/dist/audit/cli/validateResultsCommand.js.map +1 -1
  53. package/dist/audit/contracts/workerSchemas.d.ts +14 -14
  54. package/dist/audit/extractors/fsIntake.d.ts +13 -0
  55. package/dist/audit/extractors/fsIntake.d.ts.map +1 -1
  56. package/dist/audit/extractors/fsIntake.js +11 -1
  57. package/dist/audit/extractors/fsIntake.js.map +1 -1
  58. package/dist/audit/io/artifacts.d.ts +35 -9
  59. package/dist/audit/io/artifacts.d.ts.map +1 -1
  60. package/dist/audit/io/artifacts.js +84 -67
  61. package/dist/audit/io/artifacts.js.map +1 -1
  62. package/dist/audit/io/rollover.d.ts +18 -0
  63. package/dist/audit/io/rollover.d.ts.map +1 -0
  64. package/dist/audit/io/rollover.js +152 -0
  65. package/dist/audit/io/rollover.js.map +1 -0
  66. package/dist/audit/io/runSnapshot.d.ts +149 -0
  67. package/dist/audit/io/runSnapshot.d.ts.map +1 -0
  68. package/dist/audit/io/runSnapshot.js +710 -0
  69. package/dist/audit/io/runSnapshot.js.map +1 -0
  70. package/dist/audit/orchestrator/advanceTypes.d.ts +11 -0
  71. package/dist/audit/orchestrator/advanceTypes.d.ts.map +1 -1
  72. package/dist/audit/orchestrator/autoFixExecutor.d.ts +9 -0
  73. package/dist/audit/orchestrator/autoFixExecutor.d.ts.map +1 -1
  74. package/dist/audit/orchestrator/autoFixExecutor.js +22 -2
  75. package/dist/audit/orchestrator/autoFixExecutor.js.map +1 -1
  76. package/dist/audit/orchestrator/charterExtractionExecutor.d.ts.map +1 -1
  77. package/dist/audit/orchestrator/charterExtractionExecutor.js +18 -3
  78. package/dist/audit/orchestrator/charterExtractionExecutor.js.map +1 -1
  79. package/dist/audit/orchestrator/charterPackets.d.ts +11 -1
  80. package/dist/audit/orchestrator/charterPackets.d.ts.map +1 -1
  81. package/dist/audit/orchestrator/charterPackets.js +13 -0
  82. package/dist/audit/orchestrator/charterPackets.js.map +1 -1
  83. package/dist/audit/orchestrator/dependencyMap.d.ts.map +1 -1
  84. package/dist/audit/orchestrator/dependencyMap.js +6 -0
  85. package/dist/audit/orchestrator/dependencyMap.js.map +1 -1
  86. package/dist/audit/orchestrator/dependencySlices.d.ts.map +1 -1
  87. package/dist/audit/orchestrator/dependencySlices.js +35 -11
  88. package/dist/audit/orchestrator/dependencySlices.js.map +1 -1
  89. package/dist/audit/orchestrator/designReviewPrompt.d.ts +9 -0
  90. package/dist/audit/orchestrator/designReviewPrompt.d.ts.map +1 -1
  91. package/dist/audit/orchestrator/designReviewPrompt.js +69 -31
  92. package/dist/audit/orchestrator/designReviewPrompt.js.map +1 -1
  93. package/dist/audit/orchestrator/designReviewSnapshot.d.ts +1 -0
  94. package/dist/audit/orchestrator/designReviewSnapshot.d.ts.map +1 -1
  95. package/dist/audit/orchestrator/designReviewSnapshot.js +2 -1
  96. package/dist/audit/orchestrator/designReviewSnapshot.js.map +1 -1
  97. package/dist/audit/orchestrator/executorRunners.d.ts.map +1 -1
  98. package/dist/audit/orchestrator/executorRunners.js +13 -4
  99. package/dist/audit/orchestrator/executorRunners.js.map +1 -1
  100. package/dist/audit/orchestrator/hostInputPause.js +1 -1
  101. package/dist/audit/orchestrator/hostInputPause.js.map +1 -1
  102. package/dist/audit/orchestrator/intakeExecutors.d.ts +8 -1
  103. package/dist/audit/orchestrator/intakeExecutors.d.ts.map +1 -1
  104. package/dist/audit/orchestrator/intakeExecutors.js +13 -4
  105. package/dist/audit/orchestrator/intakeExecutors.js.map +1 -1
  106. package/dist/audit/orchestrator/obligationPolicy.d.ts.map +1 -1
  107. package/dist/audit/orchestrator/obligationPolicy.js +4 -1
  108. package/dist/audit/orchestrator/obligationPolicy.js.map +1 -1
  109. package/dist/audit/orchestrator/planningExecutors.d.ts +1 -1
  110. package/dist/audit/orchestrator/planningExecutors.d.ts.map +1 -1
  111. package/dist/audit/orchestrator/planningExecutors.js +40 -6
  112. package/dist/audit/orchestrator/planningExecutors.js.map +1 -1
  113. package/dist/audit/orchestrator/requeueCommand.d.ts +1 -1
  114. package/dist/audit/orchestrator/systemicChallengeExecutor.d.ts.map +1 -1
  115. package/dist/audit/orchestrator/systemicChallengeExecutor.js +4 -1
  116. package/dist/audit/orchestrator/systemicChallengeExecutor.js.map +1 -1
  117. package/dist/audit/orchestrator/taskBuilder.d.ts +20 -0
  118. package/dist/audit/orchestrator/taskBuilder.d.ts.map +1 -1
  119. package/dist/audit/orchestrator/taskBuilder.js +80 -18
  120. package/dist/audit/orchestrator/taskBuilder.js.map +1 -1
  121. package/dist/audit/systemic/reviewFileMap.d.ts +72 -21
  122. package/dist/audit/systemic/reviewFileMap.d.ts.map +1 -1
  123. package/dist/audit/systemic/reviewFileMap.js +96 -45
  124. package/dist/audit/systemic/reviewFileMap.js.map +1 -1
  125. package/dist/audit/systemic/secondOrderAdversaryPrompt.d.ts +7 -0
  126. package/dist/audit/systemic/secondOrderAdversaryPrompt.d.ts.map +1 -1
  127. package/dist/audit/systemic/secondOrderAdversaryPrompt.js +39 -8
  128. package/dist/audit/systemic/secondOrderAdversaryPrompt.js.map +1 -1
  129. package/dist/audit/types.d.ts +12 -12
  130. package/dist/audit/validation/auditResults.d.ts +1 -0
  131. package/dist/audit/validation/auditResults.d.ts.map +1 -1
  132. package/dist/audit/validation/auditResults.js +1 -0
  133. package/dist/audit/validation/auditResults.js.map +1 -1
  134. package/dist/remediate/findingFilter.js +1 -1
  135. package/dist/remediate/findingFilter.js.map +1 -1
  136. package/dist/remediate/phases/close.d.ts +1 -0
  137. package/dist/remediate/phases/close.d.ts.map +1 -1
  138. package/dist/remediate/phases/close.js +29 -5
  139. package/dist/remediate/phases/close.js.map +1 -1
  140. package/dist/remediate/phases/closeVerifyAnalyzerLeads.d.ts +1 -1
  141. package/dist/remediate/phases/triage.d.ts +2 -1
  142. package/dist/remediate/phases/triage.d.ts.map +1 -1
  143. package/dist/remediate/phases/triage.js +46 -25
  144. package/dist/remediate/phases/triage.js.map +1 -1
  145. package/dist/remediate/phases/triageConformance.d.ts +14 -0
  146. package/dist/remediate/phases/triageConformance.d.ts.map +1 -0
  147. package/dist/remediate/phases/triageConformance.js +189 -0
  148. package/dist/remediate/phases/triageConformance.js.map +1 -0
  149. package/dist/remediate/state/decisionConsumption.d.ts +110 -0
  150. package/dist/remediate/state/decisionConsumption.d.ts.map +1 -0
  151. package/dist/remediate/state/decisionConsumption.js +122 -0
  152. package/dist/remediate/state/decisionConsumption.js.map +1 -0
  153. package/dist/remediate/state/store.d.ts +4 -0
  154. package/dist/remediate/state/store.d.ts.map +1 -1
  155. package/dist/remediate/state/store.js +16 -0
  156. package/dist/remediate/state/store.js.map +1 -1
  157. package/dist/remediate/steps/contractPipeline.d.ts +0 -6
  158. package/dist/remediate/steps/contractPipeline.d.ts.map +1 -1
  159. package/dist/remediate/steps/contractPipeline.js +3 -32
  160. package/dist/remediate/steps/contractPipeline.js.map +1 -1
  161. package/dist/remediate/steps/dispatch/contractConformanceReview.d.ts +9 -0
  162. package/dist/remediate/steps/dispatch/contractConformanceReview.d.ts.map +1 -1
  163. package/dist/remediate/steps/dispatch/contractConformanceReview.js +32 -14
  164. package/dist/remediate/steps/dispatch/contractConformanceReview.js.map +1 -1
  165. package/dist/remediate/steps/dispatch/hostContracts.js +1 -1
  166. package/dist/remediate/steps/dispatch/hostContracts.js.map +1 -1
  167. package/dist/remediate/steps/dispatch/hostHandoff.d.ts.map +1 -1
  168. package/dist/remediate/steps/dispatch/hostHandoff.js +6 -2
  169. package/dist/remediate/steps/dispatch/hostHandoff.js.map +1 -1
  170. package/dist/remediate/steps/dispatch/requiredTests.js +1 -1
  171. package/dist/remediate/steps/dispatch/requiredTests.js.map +1 -1
  172. package/dist/remediate/steps/gateCommands.d.ts +1 -0
  173. package/dist/remediate/steps/gateCommands.d.ts.map +1 -1
  174. package/dist/remediate/steps/gateCommands.js +8 -52
  175. package/dist/remediate/steps/gateCommands.js.map +1 -1
  176. package/dist/remediate/steps/nextStep.d.ts +7 -0
  177. package/dist/remediate/steps/nextStep.d.ts.map +1 -1
  178. package/dist/remediate/steps/nextStep.js +69 -75
  179. package/dist/remediate/steps/nextStep.js.map +1 -1
  180. package/dist/remediate/steps/prompts.d.ts.map +1 -1
  181. package/dist/remediate/steps/prompts.js +6 -9
  182. package/dist/remediate/steps/prompts.js.map +1 -1
  183. package/dist/remediate/steps/recoverIngest.js +1 -1
  184. package/dist/remediate/steps/recoverIngest.js.map +1 -1
  185. package/dist/shared/analyzers/provenance.d.ts +1 -1
  186. package/dist/shared/analyzers/provenance.js +1 -1
  187. package/dist/shared/contentKey.d.ts +3 -2
  188. package/dist/shared/contentKey.d.ts.map +1 -1
  189. package/dist/shared/contentKey.js +3 -2
  190. package/dist/shared/contentKey.js.map +1 -1
  191. package/dist/shared/decompose/charterExtraction.d.ts +200 -13
  192. package/dist/shared/decompose/charterExtraction.d.ts.map +1 -1
  193. package/dist/shared/decompose/charterExtraction.js +34 -6
  194. package/dist/shared/decompose/charterExtraction.js.map +1 -1
  195. package/dist/shared/decompose/systemicChallenge.js +1 -1
  196. package/dist/shared/decompose/systemicChallenge.js.map +1 -1
  197. package/dist/shared/engine/obligationEngine.d.ts +1 -1
  198. package/dist/shared/engine/obligationEngine.js +1 -1
  199. package/dist/shared/findings/dedupe.js +1 -1
  200. package/dist/shared/findings/dedupe.js.map +1 -1
  201. package/dist/shared/git.d.ts +30 -1
  202. package/dist/shared/git.d.ts.map +1 -1
  203. package/dist/shared/git.js +65 -6
  204. package/dist/shared/git.js.map +1 -1
  205. package/dist/shared/index.d.ts +1 -0
  206. package/dist/shared/index.d.ts.map +1 -1
  207. package/dist/shared/index.js +1 -0
  208. package/dist/shared/index.js.map +1 -1
  209. package/dist/shared/intent/clauseBoundaries.d.ts +1 -0
  210. package/dist/shared/intent/clauseBoundaries.d.ts.map +1 -1
  211. package/dist/shared/intent/clauseBoundaries.js +1 -0
  212. package/dist/shared/intent/clauseBoundaries.js.map +1 -1
  213. package/dist/shared/intent/pathScope.d.ts +1 -0
  214. package/dist/shared/intent/pathScope.d.ts.map +1 -1
  215. package/dist/shared/intent/pathScope.js +1 -0
  216. package/dist/shared/intent/pathScope.js.map +1 -1
  217. package/dist/shared/io/nodeWorktreeGuard.d.ts +1 -1
  218. package/dist/shared/io/nodeWorktreeGuard.js +1 -1
  219. package/dist/shared/io/pathIdentity.d.ts.map +1 -1
  220. package/dist/shared/io/pathIdentity.js +1 -0
  221. package/dist/shared/io/pathIdentity.js.map +1 -1
  222. package/dist/shared/io/stepContractWriter.d.ts +1 -0
  223. package/dist/shared/io/stepContractWriter.d.ts.map +1 -1
  224. package/dist/shared/io/stepContractWriter.js +1 -0
  225. package/dist/shared/io/stepContractWriter.js.map +1 -1
  226. package/dist/shared/loopCorePaths.d.ts.map +1 -1
  227. package/dist/shared/loopCorePaths.js +11 -0
  228. package/dist/shared/loopCorePaths.js.map +1 -1
  229. package/dist/shared/prompts.d.ts +2 -0
  230. package/dist/shared/prompts.d.ts.map +1 -1
  231. package/dist/shared/prompts.js +8 -2
  232. package/dist/shared/prompts.js.map +1 -1
  233. package/dist/shared/submission/ingestReport.d.ts.map +1 -1
  234. package/dist/shared/submission/ingestReport.js +19 -1
  235. package/dist/shared/submission/ingestReport.js.map +1 -1
  236. package/dist/shared/submission/submissionLedger.js +2 -2
  237. package/dist/shared/submission/submissionLedger.js.map +1 -1
  238. package/dist/shared/submission/submissionScan.js +1 -1
  239. package/dist/shared/submission/submissionScan.js.map +1 -1
  240. package/dist/shared/tooling/exec.js +2 -2
  241. package/dist/shared/tooling/exec.js.map +1 -1
  242. package/dist/shared/types/charter.d.ts +9 -0
  243. package/dist/shared/types/charter.d.ts.map +1 -1
  244. package/dist/shared/types/charter.js +7 -0
  245. package/dist/shared/types/charter.js.map +1 -1
  246. package/dist/shared/types/closingActions.js +1 -1
  247. package/dist/shared/types/closingActions.js.map +1 -1
  248. package/dist/shared/types/finding.js +2 -2
  249. package/dist/shared/types/finding.js.map +1 -1
  250. package/dist/shared/types/stepContract.js +1 -1
  251. package/dist/shared/types/stepContract.js.map +1 -1
  252. package/dist/shared/validation/findingGrounding.d.ts +1 -0
  253. package/dist/shared/validation/findingGrounding.d.ts.map +1 -1
  254. package/dist/shared/validation/findingGrounding.js +1 -0
  255. package/dist/shared/validation/findingGrounding.js.map +1 -1
  256. package/dist/shared/validation/producerBoundary.d.ts +10 -0
  257. package/dist/shared/validation/producerBoundary.d.ts.map +1 -0
  258. package/dist/shared/validation/producerBoundary.js +26 -0
  259. package/dist/shared/validation/producerBoundary.js.map +1 -0
  260. package/docs/audit-pkg/contracts.md +6 -2
  261. package/docs/audit-pkg/operator-guide.md +13 -4
  262. package/package.json +5 -1
@@ -0,0 +1,710 @@
1
+ // sites-pinned: tests/audit/run-snapshot.test.ts, tests/audit/frozen-snapshot.test.ts
2
+ /**
3
+ * The frozen snapshot an audit run reads (docs/backlog/forward-tracks.md, the
4
+ * frozen-snapshot track; design record docs/reviews/frozen-snapshot-design-2026-10-08.md).
5
+ *
6
+ * PROPERTY: no edit to the live tree during a run changes that run's inputs. At
7
+ * run start the repository's working tree — uncommitted and untracked work
8
+ * included — is copied BYTE-EXACT into a directory OUTSIDE the repository root.
9
+ * Every content read and every repo-local spawn of the run uses that directory
10
+ * (the SOURCE root); identity (consent, policy, artifacts dir, re-issued
11
+ * commands) stays on the LIVE root.
12
+ *
13
+ * A GIT root becomes a detached `git worktree` whose `HEAD` is the live `HEAD`
14
+ * at run start (the BASE — unborn when the repository has no commit), whose
15
+ * index is the live INDEX at run start, and whose files are the live bytes. So
16
+ * git inside the snapshot answers exactly as it did in the live tree when the
17
+ * run started: the same commit, the same tracked and staged set, the same dirty
18
+ * and untracked paths, the same history — `audit_read` keeps its meaning, and no
19
+ * synthetic commit reaches history mining, the report, or a ref anybody else
20
+ * reads. A content PIN (a commit of the copied bytes) is kept only under this
21
+ * run's own ref, so a checkout that went missing can be restored; the run end
22
+ * deletes it. Each populated submodule is snapshotted the same way inside its
23
+ * parent's checkout, so `ls-files --recurse-submodules` still recurses.
24
+ *
25
+ * A NON-GIT root is frozen by a COPY. A missing copy fails closed: the frozen
26
+ * content is gone, and re-copying the live tree would break the property.
27
+ *
28
+ * Git-ignored entries (dependency directories, build output, local config) are
29
+ * not snapshot content: a directory is LINKED from the live root (so repo-local
30
+ * tools resolve as they do there), a symlink is re-created, and a file is
31
+ * COPIED — never hard-linked, so a tool writing an ignored file in the snapshot
32
+ * (a build cache) can never write into the user's file. One entry that cannot
33
+ * be placed is reported, never fatal. A `.audit-tools` path at any depth and
34
+ * `.git` are never placed.
35
+ *
36
+ * RESIDUAL: git discovery inside a COPY snapshot is fenced for this process
37
+ * (`GIT_CEILING_DIRECTORIES`), not for a host lane's own git; host lanes are
38
+ * told only to read files there.
39
+ *
40
+ * Location: `<state dir>/snapshots/<key>` (the machine-global state dir,
41
+ * `resolveAuditCodeStateDir`), `<key>` derived from the live root and the
42
+ * artifacts dir. Outside the root, so the target's own test runner never
43
+ * discovers the copy and the node-worktree CLI guard never fires on it; short,
44
+ * so `git worktree add` stays clear of the Windows long-path failure; not under
45
+ * the OS temp dir, so no temp cleaner removes it mid-run; deterministic, so a
46
+ * snapshot leaked by a crashed run is replaced, never accumulated.
47
+ */
48
+ import { constants as fsConstants, existsSync } from "node:fs";
49
+ import { copyFile, lstat, mkdir, mkdtemp, readdir, readFile, readlink, realpath, rm, rmdir, stat, symlink, unlink, writeFile } from "node:fs/promises";
50
+ import { tmpdir } from "node:os";
51
+ import { delimiter, dirname, join, relative, resolve } from "node:path";
52
+ import { z } from "zod";
53
+ import { compareCodeUnits, hashContent, readOptionalJsonFile, resolveAuditCodeStateDir, resolveWithinRoot, toPosixPath, writeJsonFile } from "audit-tools/shared";
54
+ import { headCommit, isGitRepo, workingTreeTree } from "../../shared/git.js";
55
+ import { runTrackedAsync, TRACKED_CHILD_DEADLINE_MS } from "../../shared/tooling/exec.js";
56
+ import { AUDIT_TOOLS_DIRNAME } from "../../shared/io/auditToolsPaths.js";
57
+ import { isIntakeIgnored } from "../extractors/fsIntake.js";
58
+ import { loadIgnoreFile } from "../extractors/ignore.js";
59
+ const GitPinSchema = z.object({
60
+ /** realpath of the live repository directory that owns this pin's objects. */
61
+ repository: z.string(),
62
+ /** The checkout inside the snapshot. */
63
+ path: z.string(),
64
+ /** The live `HEAD` at run start — the checkout's `HEAD`; `null` when unborn. */
65
+ base: z.string().nullable(),
66
+ /** The tree of the live INDEX at run start — the checkout's index. */
67
+ index: z.string(),
68
+ /** The copied bytes as a commit, kept only to restore the checkout. */
69
+ commit: z.string(),
70
+ /** This run's own ref holding `commit`. */
71
+ ref: z.string(),
72
+ }).strict();
73
+ const RunSnapshotSchema = z.object({
74
+ schema_version: z.literal("audit-run-snapshot/v1"),
75
+ repository_root: z.string(),
76
+ source_root: z.string(),
77
+ kind: z.enum(["git", "copy"]),
78
+ /** Root repository first, then each submodule after its parent. Empty for `copy`. */
79
+ pins: z.array(GitPinSchema),
80
+ /** Snapshot-relative (POSIX) paths of the directory links the snapshot placed. */
81
+ linked: z.array(z.string()),
82
+ /** Each `--since` ref this run was given, resolved ONCE against the live root. */
83
+ since: z.record(z.string(), z.string()),
84
+ }).strict();
85
+ export const runSnapshotPath = (artifactsDir) => join(artifactsDir, "run-snapshot.json");
86
+ /** The branch an unborn checkout's `HEAD` names; it is never created. */
87
+ const UNBORN_BRANCH = "refs/heads/audit-tools-unborn-snapshot";
88
+ const SNAPSHOT_COMMIT_ENV = {
89
+ GIT_AUTHOR_NAME: "audit-tools",
90
+ GIT_AUTHOR_EMAIL: "audit-tools@localhost",
91
+ GIT_COMMITTER_NAME: "audit-tools",
92
+ GIT_COMMITTER_EMAIL: "audit-tools@localhost",
93
+ };
94
+ /**
95
+ * Every git call of this module runs with `core.fsmonitor` off: a monitor
96
+ * daemon started inside a snapshot checkout would hold it open and block its
97
+ * removal.
98
+ */
99
+ async function git(cwd, args, env) {
100
+ const result = await runTrackedAsync(["git", "-c", "core.fsmonitor=false", ...args], {
101
+ cwd,
102
+ encoding: "utf8",
103
+ timeout: TRACKED_CHILD_DEADLINE_MS,
104
+ ...(env ? { env: { ...process.env, ...env } } : {}),
105
+ });
106
+ if (result.status !== 0) {
107
+ throw new Error(`audit snapshot: git ${args.join(" ")} failed in ${cwd}: ${result.stderr.trim()}`);
108
+ }
109
+ return result.stdout;
110
+ }
111
+ const snapshotsRoot = () => join(resolveAuditCodeStateDir(), "snapshots");
112
+ /**
113
+ * Stop git discovery at the snapshots dir. A COPY snapshot has no `.git`, and
114
+ * without this fence git run inside it would climb to whatever repository holds
115
+ * the state dir (a home directory under version control) and answer for that
116
+ * one. A git snapshot's own `.git` sits below the fence, so it is unaffected.
117
+ * Process-wide, so every git child of the run inherits it.
118
+ */
119
+ function fenceSnapshotsFromParentRepositories() {
120
+ const fence = resolve(snapshotsRoot());
121
+ const current = process.env.GIT_CEILING_DIRECTORIES ?? "";
122
+ if (current.split(delimiter).includes(fence))
123
+ return;
124
+ process.env.GIT_CEILING_DIRECTORIES = current.length > 0 ? `${current}${delimiter}${fence}` : fence;
125
+ }
126
+ /**
127
+ * Unlink every link inside `dir` without following it: each one points INTO THE
128
+ * LIVE TREE (a linked dependency directory), so nothing may recurse through it.
129
+ */
130
+ async function unlinkPlacedLinks(dir) {
131
+ let entries;
132
+ try {
133
+ entries = await readdir(dir, { withFileTypes: true });
134
+ }
135
+ catch {
136
+ return;
137
+ }
138
+ for (const entry of entries) {
139
+ const path = join(dir, entry.name);
140
+ if (entry.isSymbolicLink() || (await lstat(path)).isSymbolicLink()) {
141
+ // A win32 junction refuses `unlink` on some versions; `rmdir` removes the
142
+ // link itself, never its target.
143
+ await unlink(path).catch(() => rmdir(path));
144
+ }
145
+ else if (entry.isDirectory()) {
146
+ await unlinkPlacedLinks(path);
147
+ }
148
+ }
149
+ }
150
+ /** Remove `dir`: its links first, then the remainder. */
151
+ async function removeSnapshotDir(dir) {
152
+ await unlinkPlacedLinks(dir);
153
+ await rm(dir, { recursive: true, force: true });
154
+ }
155
+ /**
156
+ * `git worktree add --detach --force --no-checkout` with hooks off (a user's
157
+ * post-checkout hook must not run in the snapshot). `--force` re-registers the
158
+ * deterministic path when an earlier run's registration outlived its
159
+ * directory, so nothing here ever prunes the user's OTHER worktrees.
160
+ */
161
+ async function addEmptyWorktree(repository, path, commit) {
162
+ await removeSnapshotDir(path);
163
+ await mkdir(dirname(path), { recursive: true });
164
+ await git(repository, [
165
+ "-c", `core.hooksPath=${join(dirname(path), ".no-hooks")}`,
166
+ "worktree", "add", "--detach", "--force", "--no-checkout", path, commit,
167
+ ]);
168
+ }
169
+ async function commitTree(repository, tree, parent) {
170
+ return (await git(repository, ["commit-tree", tree, ...(parent === null ? [] : ["-p", parent]), "-m", "audit-tools run snapshot"], SNAPSHOT_COMMIT_ENV)).trim();
171
+ }
172
+ async function emptyTree(repository) {
173
+ return (await git(repository, ["mktree"])).trim();
174
+ }
175
+ /**
176
+ * The checkout's `HEAD` and index: the base commit (or unborn) and the live
177
+ * index tree. An unborn checkout is first added at a throwaway empty commit (a
178
+ * worktree needs one) and then pointed at a branch that is never created.
179
+ */
180
+ async function setCheckoutState(pin) {
181
+ if (pin.base === null)
182
+ await git(pin.path, ["symbolic-ref", "HEAD", UNBORN_BRANCH]);
183
+ await git(pin.path, ["read-tree", pin.index]);
184
+ await git(pin.path, ["update-index", "-q", "--refresh"]).catch(() => undefined);
185
+ }
186
+ async function addCheckout(pin) {
187
+ const start = pin.base ?? await commitTree(pin.repository, await emptyTree(pin.repository), null);
188
+ await addEmptyWorktree(pin.repository, pin.path, start);
189
+ }
190
+ /**
191
+ * Re-create a symlink as a symlink; `false` when the platform refuses (win32
192
+ * without the privilege). A target inside `mapping.live` — relative or absolute
193
+ * — is mapped onto the snapshot, so it resolves there and never reaches the live
194
+ * tree; one outside points at its absolute live path. A win32 junction needs an
195
+ * absolute target, so it gets the snapshot-side one; any other link a relative one.
196
+ */
197
+ async function copySymlink(source, dest, mapping) {
198
+ const absolute = resolve(dirname(source), await readlink(source));
199
+ const inside = resolveWithinRoot(mapping.live, absolute) !== null;
200
+ const pointsAtDirectory = await stat(source).then((info) => info.isDirectory(), () => false);
201
+ const junction = pointsAtDirectory && process.platform === "win32";
202
+ const snapTarget = join(mapping.snap, relative(mapping.live, absolute));
203
+ const linkTarget = !inside ? absolute : junction ? snapTarget : relative(dirname(dest), snapTarget) || ".";
204
+ try {
205
+ await symlink(linkTarget, dest, pointsAtDirectory ? (junction ? "junction" : "dir") : "file");
206
+ return true;
207
+ }
208
+ catch {
209
+ return false;
210
+ }
211
+ }
212
+ /**
213
+ * True for a path the snapshot never places when it is NOT tracked: `.git`, and
214
+ * a `.audit-tools` segment at any depth (this tool's own state). A TRACKED path
215
+ * is snapshot content wherever it sits.
216
+ */
217
+ function isNeverPlaced(relativePath) {
218
+ const segments = toPosixPath(relativePath).split("/");
219
+ return segments[0] === ".git" || segments.includes(AUDIT_TOOLS_DIRNAME);
220
+ }
221
+ const splitNul = (out) => out.split("\0").filter((entry) => entry.length > 0);
222
+ const withoutTrailingSlash = (path) => (path.endsWith("/") ? path.slice(0, -1) : path);
223
+ /**
224
+ * Copy the files git lists in `liveDir` (tracked, plus untracked and not
225
+ * ignored) into `snapDir`, byte-exact. `skip` names the populated submodules,
226
+ * each snapshotted as its own checkout. An untracked NESTED repository (listed
227
+ * as one directory) is copied the same way through its own listing — so its
228
+ * ignored content (dependency and build output) is never copied — and is
229
+ * returned so its ignored entries are placed with the others.
230
+ */
231
+ async function copyListedFiles(liveDir, snapDir, mapping, skip, warnings, nested) {
232
+ const tracked = new Set(splitNul(await git(liveDir, ["ls-files", "-z", "--cached"])));
233
+ const paths = new Set(tracked);
234
+ for (const raw of splitNul(await git(liveDir, ["ls-files", "-z", "--others", "--exclude-standard"]))) {
235
+ if (!isNeverPlaced(withoutTrailingSlash(raw)))
236
+ paths.add(raw);
237
+ }
238
+ for (const raw of [...paths].sort(compareCodeUnits)) {
239
+ const path = withoutTrailingSlash(raw);
240
+ if (skip.includes(path))
241
+ continue;
242
+ const source = join(liveDir, path);
243
+ const dest = join(snapDir, path);
244
+ const info = await lstat(source).catch(() => undefined);
245
+ // A deleted-but-tracked file is absent from the snapshot, as from the live tree.
246
+ if (info === undefined)
247
+ continue;
248
+ await mkdir(dirname(dest), { recursive: true });
249
+ if (info.isSymbolicLink()) {
250
+ if (!(await copySymlink(source, dest, mapping)))
251
+ warnings.push(`could not re-create the symlink ${source}`);
252
+ }
253
+ else if (info.isDirectory()) {
254
+ // A tracked directory is a submodule that is not checked out: empty, as live.
255
+ if (tracked.has(raw)) {
256
+ await mkdir(dest, { recursive: true });
257
+ continue;
258
+ }
259
+ try {
260
+ await copyListedFiles(source, dest, mapping, [], warnings, nested);
261
+ nested.push({ live: source, snap: dest });
262
+ }
263
+ catch (error) {
264
+ warnings.push(`could not copy the nested repository ${source}: ${error.message}`);
265
+ }
266
+ }
267
+ else if (info.isFile()) {
268
+ await copyFile(source, dest);
269
+ }
270
+ }
271
+ }
272
+ /**
273
+ * Place one entry the snapshot does not own by its own type. Never throws: an
274
+ * entry that cannot be placed is returned as a warning.
275
+ */
276
+ async function placeEntry(source, dest, mapping, linked, linkKey) {
277
+ if (existsSync(dest))
278
+ return null;
279
+ try {
280
+ const info = await lstat(source);
281
+ await mkdir(dirname(dest), { recursive: true });
282
+ if (info.isSymbolicLink()) {
283
+ return (await copySymlink(source, dest, mapping)) ? null : `could not re-create the symlink ${source}`;
284
+ }
285
+ if (info.isDirectory()) {
286
+ await symlink(source, dest, process.platform === "win32" ? "junction" : "dir");
287
+ linked.add(linkKey);
288
+ return null;
289
+ }
290
+ // A copy, never a hard link: a hard link is the SAME file, so a tool that
291
+ // rewrites it in the snapshot would rewrite the user's. FICLONE makes it a
292
+ // free reflink where the filesystem supports one.
293
+ if (info.isFile())
294
+ await copyFile(source, dest, fsConstants.COPYFILE_FICLONE);
295
+ return null;
296
+ }
297
+ catch (error) {
298
+ return `could not place ${source}: ${error.message}`;
299
+ }
300
+ }
301
+ /**
302
+ * Place each git-ignored entry of `repository` into `snapDir`; symlink targets
303
+ * map through `mapping` (the enclosing checkout's live and snapshot dirs).
304
+ */
305
+ async function placeIgnoredEntries(repository, snapDir, mapping, linked, linkPrefix, warnings) {
306
+ const out = await git(repository, ["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--directory"]);
307
+ for (const raw of splitNul(out).sort(compareCodeUnits)) {
308
+ const relativePath = withoutTrailingSlash(raw);
309
+ if (isNeverPlaced(relativePath))
310
+ continue;
311
+ const warning = await placeEntry(join(repository, relativePath), join(snapDir, relativePath), mapping, linked, `${linkPrefix}${toPosixPath(relativePath)}`);
312
+ if (warning !== null)
313
+ warnings.push(warning);
314
+ }
315
+ }
316
+ /** Place the ignored entries of each untracked nested repository `copyListedFiles` copied. */
317
+ async function placeNestedIgnoredEntries(nested, mapping, linked, linkPrefix, warnings) {
318
+ for (const repository of nested) {
319
+ const prefix = `${linkPrefix}${toPosixPath(relative(mapping.snap, repository.snap))}/`;
320
+ try {
321
+ await placeIgnoredEntries(repository.live, repository.snap, mapping, linked, prefix, warnings);
322
+ }
323
+ catch (error) {
324
+ warnings.push(`could not place the ignored entries of ${repository.live}: ${error.message}`);
325
+ }
326
+ }
327
+ }
328
+ /** Paths of `repository`'s submodules whose live directory is a checked-out repository. */
329
+ async function populatedSubmodules(repository) {
330
+ const out = await git(repository, ["ls-files", "-z", "--stage"]);
331
+ return out.split("\0")
332
+ .filter((line) => line.startsWith("160000 "))
333
+ .map((line) => line.slice(line.indexOf("\t") + 1))
334
+ .filter((path) => existsSync(join(repository, path, ".git")))
335
+ .sort(compareCodeUnits);
336
+ }
337
+ /**
338
+ * The tree of `repository`'s live index, written from a COPY of the index file:
339
+ * `write-tree` on the live index would take the user's `index.lock` (failing
340
+ * beside an editor's background `git status`, or failing the user's own commit)
341
+ * and rewrite the user's index. `write-tree` refuses an index with unmerged
342
+ * entries; only then does the checkout take the base's tree (or the empty tree),
343
+ * with a warning. Any other failure is thrown.
344
+ */
345
+ async function liveIndexTree(repository, base, warnings) {
346
+ const liveIndex = resolve(repository, (await git(repository, ["rev-parse", "--git-path", "index"])).trim());
347
+ const scratch = await mkdtemp(join(tmpdir(), "audit-snapshot-index-"));
348
+ try {
349
+ const env = { GIT_INDEX_FILE: join(scratch, "index") };
350
+ if (existsSync(liveIndex))
351
+ await copyFile(liveIndex, env.GIT_INDEX_FILE);
352
+ try {
353
+ return (await git(repository, ["write-tree"], env)).trim();
354
+ }
355
+ catch (error) {
356
+ if ((await git(repository, ["ls-files", "--unmerged"], env)).trim().length === 0)
357
+ throw error;
358
+ warnings.push(`${repository} has unmerged index entries; the snapshot's index is its HEAD tree`);
359
+ return base === null ? emptyTree(repository) : (await git(repository, ["rev-parse", `${base}^{tree}`])).trim();
360
+ }
361
+ }
362
+ finally {
363
+ await rm(scratch, { recursive: true, force: true });
364
+ }
365
+ }
366
+ /**
367
+ * Snapshot `liveDir` at `snapDir`, each populated submodule inside it. The
368
+ * checkout is added EMPTY and filled with the live bytes (a normal checkout
369
+ * would re-apply line-ending and other filters), so the run reads exactly what
370
+ * was on disk; the content pin is computed FROM the copy.
371
+ */
372
+ async function snapshotRepository(liveDir, snapDir, ref, pins, linked, linkPrefix, warnings) {
373
+ const repository = await realpath(liveDir);
374
+ const mapping = { live: repository, snap: snapDir };
375
+ const base = await headCommit(repository);
376
+ const indexTree = await liveIndexTree(repository, base, warnings);
377
+ await addCheckout({ repository, path: snapDir, base });
378
+ const slot = pins.length;
379
+ pins.push({ repository, path: snapDir, base, index: indexTree, commit: indexTree, ref });
380
+ const submodules = await populatedSubmodules(repository);
381
+ const nested = [];
382
+ await copyListedFiles(repository, snapDir, mapping, submodules, warnings, nested);
383
+ for (const sub of submodules) {
384
+ await snapshotRepository(join(repository, sub), join(snapDir, sub), ref, pins, linked, `${linkPrefix}${sub}/`, warnings);
385
+ }
386
+ const pin = pins[slot];
387
+ await setCheckoutState(pin);
388
+ const tree = await workingTreeTree(snapDir);
389
+ if (tree === null)
390
+ throw new Error(`audit snapshot: cannot read the copied working tree of ${repository}`);
391
+ const commit = await commitTree(repository, tree, base);
392
+ await git(repository, ["update-ref", ref, commit]);
393
+ pins[slot] = { ...pin, commit };
394
+ await placeIgnoredEntries(repository, snapDir, mapping, linked, linkPrefix, warnings);
395
+ await placeNestedIgnoredEntries(nested, mapping, linked, linkPrefix, warnings);
396
+ }
397
+ /** Copy a non-git root, placing what intake ignores instead of copying it. */
398
+ async function copyTree(liveRoot, snapRoot, ignores, skip, linked, warnings, relativeDir = "") {
399
+ const entries = await readdir(join(liveRoot, relativeDir), { withFileTypes: true });
400
+ await mkdir(join(snapRoot, relativeDir), { recursive: true });
401
+ for (const entry of entries.sort((a, b) => compareCodeUnits(a.name, b.name))) {
402
+ const relativePath = relativeDir ? join(relativeDir, entry.name) : entry.name;
403
+ const source = join(liveRoot, relativePath);
404
+ // The state dir holds the snapshot being written; copying it would recurse.
405
+ if (isNeverPlaced(relativePath) || resolve(source) === skip)
406
+ continue;
407
+ const dest = join(snapRoot, relativePath);
408
+ if (isIntakeIgnored(relativePath, ignores) || entry.isSymbolicLink()) {
409
+ const warning = await placeEntry(source, dest, { live: liveRoot, snap: snapRoot }, linked, toPosixPath(relativePath));
410
+ if (warning !== null)
411
+ warnings.push(warning);
412
+ }
413
+ else if (entry.isDirectory()) {
414
+ await copyTree(liveRoot, snapRoot, ignores, skip, linked, warnings, relativePath);
415
+ }
416
+ else if (entry.isFile()) {
417
+ await copyFile(source, dest);
418
+ }
419
+ }
420
+ }
421
+ async function readRunSnapshot(artifactsDir) {
422
+ const raw = await readOptionalJsonFile(runSnapshotPath(artifactsDir));
423
+ return raw === undefined || raw === null ? undefined : RunSnapshotSchema.parse(raw);
424
+ }
425
+ /**
426
+ * True when the checkout is still a checkout (its `.git` link is present) and
427
+ * sits at its base — an unborn one on the unborn branch. `headCommit` answers
428
+ * `null` on ANY failure, so an unborn pin is checked by the branch its `HEAD`
429
+ * names, never by a `null` head.
430
+ */
431
+ async function pinIntact(pin) {
432
+ if (!existsSync(join(pin.path, ".git")))
433
+ return false;
434
+ if (pin.base !== null)
435
+ return (await headCommit(pin.path)) === pin.base;
436
+ return (await git(pin.path, ["symbolic-ref", "HEAD"]).then((out) => out.trim(), () => "")) === UNBORN_BRANCH;
437
+ }
438
+ async function pinsIntact(pins) {
439
+ for (const pin of pins) {
440
+ if (!(await pinIntact(pin)))
441
+ return false;
442
+ }
443
+ return true;
444
+ }
445
+ /**
446
+ * Re-create every checkout of a git snapshot from its pins, root first, and
447
+ * persist the links it placed. The content comes from the pin through
448
+ * `checkout-index`, so on a repository with line-ending filters it can differ
449
+ * in bytes from the original copy; the fold's integrity re-check then refreshes
450
+ * intake over the restored checkout. The ignored entries of an untracked nested
451
+ * repository are not re-placed (the record does not name nested repositories);
452
+ * intake skips most of them anyway.
453
+ */
454
+ async function restoreSnapshot(record, artifactsDir) {
455
+ const warnings = [];
456
+ const linked = new Set();
457
+ for (const pin of record.pins) {
458
+ await addCheckout(pin);
459
+ await git(pin.path, ["read-tree", pin.commit]);
460
+ await git(pin.path, ["checkout-index", "-a", "-f"]);
461
+ await setCheckoutState(pin);
462
+ const prefix = toPosixPath(relative(record.pins[0].path, pin.path));
463
+ await placeIgnoredEntries(pin.repository, pin.path, { live: pin.repository, snap: pin.path }, linked, prefix ? `${prefix}/` : "", warnings);
464
+ }
465
+ reportWarnings(warnings);
466
+ const restored = { ...record, linked: [...linked].sort(compareCodeUnits) };
467
+ await writeJsonFile(runSnapshotPath(artifactsDir), restored);
468
+ return restored;
469
+ }
470
+ function reportWarnings(warnings) {
471
+ for (const warning of warnings)
472
+ process.stderr.write(`[audit-code] run snapshot: ${warning}\n`);
473
+ }
474
+ function snapshotKey(repositoryRoot, artifactsDir) {
475
+ return hashContent(`${repositoryRoot}\0${artifactsDir}`).slice(0, 12);
476
+ }
477
+ async function createRunSnapshot(root, artifactsDir) {
478
+ const repositoryRoot = await realpath(root);
479
+ const key = snapshotKey(repositoryRoot, await realpath(artifactsDir).catch(() => resolve(artifactsDir)));
480
+ const dir = join(snapshotsRoot(), key);
481
+ await removeSnapshotDir(dir);
482
+ const linked = new Set();
483
+ const warnings = [];
484
+ let record;
485
+ if (await isGitRepo(repositoryRoot)) {
486
+ const toplevel = (await git(repositoryRoot, ["rev-parse", "--show-toplevel"])).trim();
487
+ const prefix = (await git(repositoryRoot, ["rev-parse", "--show-prefix"])).trim();
488
+ const pins = [];
489
+ await snapshotRepository(toplevel, join(dir, "tree"), `refs/audit-tools/snapshots/${key}`, pins, linked, "", warnings);
490
+ record = {
491
+ schema_version: "audit-run-snapshot/v1",
492
+ repository_root: repositoryRoot,
493
+ source_root: prefix ? join(dir, "tree", prefix) : join(dir, "tree"),
494
+ kind: "git",
495
+ pins,
496
+ linked: [...linked].sort(compareCodeUnits),
497
+ since: {},
498
+ };
499
+ }
500
+ else {
501
+ const skip = resolve(await realpath(resolveAuditCodeStateDir()).catch(() => resolveAuditCodeStateDir()));
502
+ await copyTree(repositoryRoot, join(dir, "tree"), await loadIgnoreFile(repositoryRoot), skip, linked, warnings);
503
+ record = {
504
+ schema_version: "audit-run-snapshot/v1",
505
+ repository_root: repositoryRoot,
506
+ source_root: join(dir, "tree"),
507
+ kind: "copy",
508
+ pins: [],
509
+ linked: [...linked].sort(compareCodeUnits),
510
+ since: {},
511
+ };
512
+ }
513
+ reportWarnings(warnings);
514
+ return record;
515
+ }
516
+ /**
517
+ * The run's snapshot: created at run start, its checkouts restored when they
518
+ * went missing, refused when a copy went missing.
519
+ *
520
+ * The caller holds the artifact-tree lock (the snapshot is run state, created
521
+ * once per run). Callers on a run path go through {@link ensureRunSourceRoot},
522
+ * which owns the "a complete run reads nothing" rule.
523
+ */
524
+ export async function ensureRunSnapshot(root, artifactsDir) {
525
+ fenceSnapshotsFromParentRepositories();
526
+ const existing = await readRunSnapshot(artifactsDir);
527
+ if (existing !== undefined) {
528
+ if (existing.repository_root !== (await realpath(root))) {
529
+ throw new Error("Audit run snapshot belongs to another repository");
530
+ }
531
+ if (existing.kind === "copy") {
532
+ if (!existsSync(existing.source_root)) {
533
+ throw new Error(`The frozen copy this audit run reads (${existing.source_root}) is gone, so the run's inputs cannot be ` +
534
+ "recovered. Run `audit-code cleanup --force` to discard the run, then start a new one.");
535
+ }
536
+ return existing;
537
+ }
538
+ return (await pinsIntact(existing.pins)) ? existing : restoreSnapshot(existing, artifactsDir);
539
+ }
540
+ const created = await createRunSnapshot(root, artifactsDir);
541
+ await writeJsonFile(runSnapshotPath(artifactsDir), created);
542
+ return created;
543
+ }
544
+ /**
545
+ * The root a run's content reads use — THE one decision every run path shares
546
+ * (the next-step fold and every standalone step command). A COMPLETE run reads
547
+ * nothing more: it is given no snapshot (promotion removed it, and a new one
548
+ * would freeze a tree the run never read), and its terminal step needs only the
549
+ * live root. Any other run reads its frozen snapshot. The caller holds the
550
+ * artifact-tree lock.
551
+ */
552
+ export async function ensureRunSourceRoot(root, artifactsDir) {
553
+ const status = (await readOptionalJsonFile(join(artifactsDir, "audit_state.json")))?.status;
554
+ if (status === "complete")
555
+ return root;
556
+ return (await ensureRunSnapshot(root, artifactsDir)).source_root;
557
+ }
558
+ /**
559
+ * The commit a `--since` ref named when this run first saw it, resolved against
560
+ * the LIVE root (a pseudo-ref such as the branch upstream @{u} or ORIG_HEAD does
561
+ * not resolve in the detached snapshot) and persisted, so a moving HEAD cannot move the
562
+ * delta mid-run. An unresolvable ref is returned unchanged, for the scope
563
+ * resolver's own full-audit fallback. The caller holds the artifact-tree lock.
564
+ */
565
+ export async function resolveRunSince(root, artifactsDir, since) {
566
+ const record = await readRunSnapshot(artifactsDir);
567
+ const known = record?.since[since];
568
+ if (known !== undefined)
569
+ return known;
570
+ const result = await runTrackedAsync(["git", "rev-parse", "--verify", "--quiet", `${since}^{commit}`], {
571
+ cwd: root,
572
+ encoding: "utf8",
573
+ timeout: TRACKED_CHILD_DEADLINE_MS,
574
+ });
575
+ const sha = result.status === 0 ? result.stdout.trim() : "";
576
+ if (sha.length === 0)
577
+ return since;
578
+ if (record !== undefined) {
579
+ await writeJsonFile(runSnapshotPath(artifactsDir), { ...record, since: { ...record.since, [since]: sha } });
580
+ }
581
+ return sha;
582
+ }
583
+ /** The `--since` resolution this run recorded, without resolving anything (no lock). */
584
+ export async function lookupRunSince(artifactsDir, since) {
585
+ return (await readRunSnapshot(artifactsDir))?.since[since] ?? since;
586
+ }
587
+ /**
588
+ * The source root of the run in `artifactsDir`, WITHOUT creating a snapshot —
589
+ * for a read-only command that holds no lock. With no run started there is no
590
+ * frozen tree, and the live root is the only content there is.
591
+ */
592
+ export async function readRunSourceRoot(root, artifactsDir) {
593
+ fenceSnapshotsFromParentRepositories();
594
+ return (await readRunSnapshot(artifactsDir))?.source_root ?? root;
595
+ }
596
+ /** The directory links the run's snapshot placed — never audit input (see `readAuditReadState`). */
597
+ export async function readRunSnapshotLinks(artifactsDir) {
598
+ return (await readRunSnapshot(artifactsDir))?.linked ?? [];
599
+ }
600
+ /**
601
+ * The one statement of where a host lane reads repository source during a run:
602
+ * the frozen snapshot, never its own working directory or the live repository,
603
+ * which may have moved since the run started. Single-sourced so the work-item
604
+ * prompts and every materialized lane prompt say it identically.
605
+ */
606
+ export function sourceTreeInstruction(sourceRoot) {
607
+ return "Read repository source from this directory — the audit run's frozen snapshot — and resolve every " +
608
+ `repository path against it, not against your working directory or the live repository: ${toPosixPath(sourceRoot)}`;
609
+ }
610
+ /**
611
+ * Carry the tool's own edits of the snapshot (the opted-in auto-fix phase) back
612
+ * to the live tree. A file is written only when its live bytes still hash to
613
+ * `startHashes` — the content the run started from — so a user's concurrent
614
+ * edit is never overwritten; such a file is returned as skipped.
615
+ */
616
+ export async function writeBackSnapshotEdits(params) {
617
+ const written = [];
618
+ const skipped = [];
619
+ for (const path of [...new Set(params.paths)].sort(compareCodeUnits)) {
620
+ const edited = await readFile(join(params.sourceRoot, path)).catch(() => undefined);
621
+ if (edited === undefined)
622
+ continue;
623
+ const live = await readFile(join(params.repositoryRoot, path)).catch(() => undefined);
624
+ // A file intake did not hash (oversized) has no recorded start: identical
625
+ // bytes mean nothing was edited; different bytes cannot be attributed.
626
+ const start = params.startHashes.get(path)
627
+ ?? (live !== undefined && live.equals(edited) ? hashContent(edited) : undefined);
628
+ if (start !== undefined && hashContent(edited) === start)
629
+ continue;
630
+ if (start === undefined || live === undefined || hashContent(live) !== start) {
631
+ skipped.push(path);
632
+ continue;
633
+ }
634
+ await writeFile(join(params.repositoryRoot, path), edited);
635
+ written.push(path);
636
+ }
637
+ return { written, skipped };
638
+ }
639
+ /**
640
+ * Re-pin the run's snapshot after the tool edited it (deepest checkout first),
641
+ * so a checkout restored later holds the edited bytes. The checkouts' `HEAD`
642
+ * and index are unchanged: the edit is the run's, not a commit's. The placed
643
+ * links are subtracted — on POSIX a dir-only ignore pattern does not match a
644
+ * symlink, and a pinned link would be restored as a file.
645
+ */
646
+ export async function repinRunSnapshot(artifactsDir) {
647
+ const record = await readRunSnapshot(artifactsDir);
648
+ if (record === undefined || record.kind !== "git")
649
+ return;
650
+ const pins = [...record.pins];
651
+ for (let index = pins.length - 1; index >= 0; index--) {
652
+ const pin = pins[index];
653
+ const prefix = toPosixPath(relative(record.pins[0].path, pin.path));
654
+ const own = record.linked
655
+ .filter((path) => (prefix ? path.startsWith(`${prefix}/`) : true))
656
+ .map((path) => (prefix ? path.slice(prefix.length + 1) : path));
657
+ const tree = await workingTreeTree(pin.path, { excludePaths: own });
658
+ if (tree === null)
659
+ throw new Error(`audit snapshot: cannot read the edited checkout ${pin.path}`);
660
+ if ((await git(pin.repository, ["rev-parse", `${pin.commit}^{tree}`])).trim() === tree)
661
+ continue;
662
+ const commit = await commitTree(pin.repository, tree, pin.base);
663
+ await git(pin.repository, ["update-ref", pin.ref, commit]);
664
+ pins[index] = { ...pin, commit };
665
+ }
666
+ await writeJsonFile(runSnapshotPath(artifactsDir), { ...record, pins });
667
+ }
668
+ /**
669
+ * Remove the run's snapshot: its links first (each points into the live tree),
670
+ * then the directory, then each checkout's own registration and ref (deepest
671
+ * first), then the record. Never throws — a cleanup must not fail on the
672
+ * scratch it is reclaiming; each problem is returned for the caller to report.
673
+ * The record goes last and only on a clean removal, so a failed one can be
674
+ * retried and a second call is a no-op.
675
+ */
676
+ export async function removeRunSnapshot(artifactsDir) {
677
+ const problems = [];
678
+ let record;
679
+ try {
680
+ record = await readRunSnapshot(artifactsDir);
681
+ }
682
+ catch (error) {
683
+ return [`unreadable ${runSnapshotPath(artifactsDir)}: ${error.message}`];
684
+ }
685
+ if (record === undefined)
686
+ return problems;
687
+ const dir = record.pins.length > 0 ? dirname(record.pins[0].path) : dirname(record.source_root);
688
+ try {
689
+ await removeSnapshotDir(dir);
690
+ }
691
+ catch (error) {
692
+ problems.push(`could not remove ${dir}: ${error.message}`);
693
+ return problems;
694
+ }
695
+ for (const pin of [...record.pins].reverse()) {
696
+ // The directory is gone, so `worktree remove` only drops this checkout's
697
+ // own registration; a registration git no longer lists is not an error.
698
+ await git(pin.repository, ["worktree", "remove", "--force", "--force", pin.path]).catch(async (error) => {
699
+ const listed = await git(pin.repository, ["worktree", "list", "--porcelain"]).catch(() => "");
700
+ if (listed.includes(toPosixPath(pin.path)))
701
+ problems.push(error.message);
702
+ });
703
+ await git(pin.repository, ["update-ref", "-d", pin.ref])
704
+ .catch((error) => problems.push(error.message));
705
+ }
706
+ if (problems.length === 0)
707
+ await rm(runSnapshotPath(artifactsDir), { force: true });
708
+ return problems;
709
+ }
710
+ //# sourceMappingURL=runSnapshot.js.map