@joshuafolkken/kit 1.1010.0 → 1.1035.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/.claude/settings.json +1 -5
  2. package/.claude/skills/dependency-update/SKILL.md +6 -3
  3. package/.claude/skills/epic-commands/SKILL.md +46 -17
  4. package/.claude/skills/workflow-commands/SKILL.md +8 -23
  5. package/.claude/skills/workflow-commands/backlogrun-child.md +4 -2
  6. package/.claude/skills/workflow-commands/backlogrun.md +3 -12
  7. package/.claude/skills/workflow-commands/fullrun-steps.md +4 -3
  8. package/.claude/skills/workflow-commands/fullrun.md +1 -1
  9. package/.claude/skills/workflow-commands/halfrun.md +2 -1
  10. package/.claude/skills/workflow-commands/into-target.md +1 -1
  11. package/.claude/skills/workflow-commands/observation-filing.md +42 -43
  12. package/.claude/skills/workflow-commands/prerequisite.md +1 -1
  13. package/.codex/hooks.json +1 -5
  14. package/.github/workflows/sonar-qube.yml +1 -1
  15. package/AGENTS.md +4 -25
  16. package/CLAUDE.md +24 -38
  17. package/GEMINI.md +4 -24
  18. package/README.md +22 -56
  19. package/dist/CLAUDE.md +24 -38
  20. package/dist/hooks/batch-guard.js +4 -4
  21. package/dist/hooks/{chunk-WMIR5SNX.js → chunk-D67KVGGY.js} +3 -3
  22. package/dist/hooks/{chunk-EZLHXKJ6.js → chunk-EIFCAJRB.js} +70 -27
  23. package/dist/hooks/{chunk-KPZN42RT.js → chunk-GJT4BNVE.js} +3 -3
  24. package/dist/hooks/{chunk-PAXMLGSS.js → chunk-GLBDBJOK.js} +44 -24
  25. package/dist/hooks/{chunk-GBJUERYK.js → chunk-HOYJU5RM.js} +1 -1
  26. package/dist/hooks/{chunk-6PSTVOLH.js → chunk-HU66PWWL.js} +33 -57
  27. package/dist/hooks/{chunk-A2H5I2VD.js → chunk-KG6PZD72.js} +2 -2
  28. package/dist/hooks/{chunk-AWTFKKFW.js → chunk-XPLP27WA.js} +3 -8
  29. package/dist/hooks/{chunk-4GDR2YTV.js → chunk-Y27M6A7X.js} +4 -4
  30. package/dist/hooks/{chunk-56LGMJJV.js → chunk-ZXNE6NY3.js} +2 -2
  31. package/dist/hooks/codex-hook-adapter.js +10 -10
  32. package/dist/hooks/duplicate-read-guard.js +5 -5
  33. package/dist/hooks/format-edited.js +2 -2
  34. package/dist/hooks/investigation-guard.js +5 -5
  35. package/dist/hooks/pretool-guard.js +9 -9
  36. package/dist/hooks/stop-guard.js +11 -15
  37. package/dist/josh.js +38 -19
  38. package/dist/version/index.js +93 -41
  39. package/eslint/base.js +2 -11
  40. package/package.json +31 -5
  41. package/prompts/coding-standards.md +15 -290
  42. package/prompts/collaboration-workflow/cross-repo-epic.md +2 -51
  43. package/prompts/collaboration-workflow/epic-audit.md +2 -53
  44. package/prompts/collaboration-workflow/epic-bundle.md +2 -106
  45. package/prompts/collaboration-workflow/issue-template.md +4 -4
  46. package/prompts/collaboration-workflow/operating-rules.md +18 -75
  47. package/prompts/collaboration-workflow/principles.md +74 -0
  48. package/prompts/collaboration-workflow/residency.md +54 -14
  49. package/prompts/collaboration-workflow/rule-delivery.md +1 -11
  50. package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
  51. package/prompts/collaboration-workflow.md +22 -28
  52. package/scripts/adopt/adopt-logic.ts +2 -2
  53. package/scripts/adopt/adopt-toolkits.ts +1 -1
  54. package/scripts/adopt/adopt.ts +2 -2
  55. package/scripts/agent/agent-event.ts +2 -9
  56. package/scripts/agent/agent-exit-record.ts +2 -9
  57. package/scripts/backlog/backlog-budget-cli.ts +2 -6
  58. package/scripts/backlog/backlog-drive-cli.ts +20 -10
  59. package/scripts/backlog/backlog-drive.ts +50 -16
  60. package/scripts/backlog/backlog-offer-cli.ts +6 -5
  61. package/scripts/backlog/backlog-plan.ts +1 -1
  62. package/scripts/build/build-bin.ts +2 -1
  63. package/scripts/build/build-config-merge.ts +2 -1
  64. package/scripts/build/build-hooks.ts +24 -4
  65. package/scripts/build/build-managed-marker.ts +2 -1
  66. package/scripts/build/build-self-sync-guard.ts +2 -1
  67. package/scripts/build/build-version.ts +2 -1
  68. package/scripts/cases/cases-logic.ts +2 -6
  69. package/scripts/ci/pr-classification.ts +13 -11
  70. package/scripts/cost-runtime/cost-transcript.ts +4 -11
  71. package/scripts/doctor/doctor-consumer.ts +5 -12
  72. package/scripts/doctor/doctor-ports.ts +2 -2
  73. package/scripts/document/document-byte-budget.ts +8 -16
  74. package/scripts/document/document-section.ts +2 -6
  75. package/scripts/document/entry-read-budget.ts +1 -1
  76. package/scripts/{git/git-epic-add-body.ts → epic/epic-add-body.ts} +23 -23
  77. package/scripts/{git/git-epic-add-plan.ts → epic/epic-add-plan.ts} +23 -26
  78. package/scripts/{git/git-epic-add.ts → epic/epic-add.ts} +17 -17
  79. package/scripts/epic/epic-audit-cli.ts +4 -4
  80. package/scripts/epic/epic-audit-orphans.ts +2 -2
  81. package/scripts/epic/epic-audit-rationale.ts +1 -1
  82. package/scripts/epic/epic-audit.ts +1 -1
  83. package/scripts/{git/git-epic-body.ts → epic/epic-body.ts} +6 -6
  84. package/scripts/epic/epic-bundle-cli.ts +1 -1
  85. package/scripts/epic/epic-bundle.ts +2 -2
  86. package/scripts/epic/epic-candidate-confirm.ts +2 -1
  87. package/scripts/{git/git-epic-chains.ts → epic/epic-chains.ts} +4 -4
  88. package/{scripts-ai → scripts/epic}/epic-check.ts +5 -4
  89. package/{scripts-ai → scripts/epic}/epic-cli.ts +4 -4
  90. package/scripts/{git/git-epic-close-comment.ts → epic/epic-close-comment.ts} +9 -9
  91. package/scripts/{git/git-epic-close.ts → epic/epic-close.ts} +23 -19
  92. package/scripts/epic/epic-cross-repo.ts +21 -38
  93. package/scripts/{git/git-epic-decision.ts → epic/epic-decision.ts} +12 -14
  94. package/scripts/epic/epic-fetch.ts +3 -3
  95. package/scripts/epic/epic-graph.ts +3 -3
  96. package/scripts/epic/epic-index.ts +2 -2
  97. package/scripts/epic/epic-issue.ts +1 -1
  98. package/scripts/epic/epic-next.ts +3 -3
  99. package/scripts/{git/git-epic-parse.ts → epic/epic-parse.ts} +3 -3
  100. package/scripts/{git/git-epic-promote.ts → epic/epic-promote.ts} +10 -10
  101. package/scripts/{git/git-epic-read.ts → epic/epic-read.ts} +9 -9
  102. package/scripts/{git/git-epic-reconcile.ts → epic/epic-reconcile.ts} +18 -20
  103. package/scripts/{git/git-epic-reference.ts → epic/epic-reference.ts} +4 -23
  104. package/scripts/epic/epic-relation-recheck.ts +4 -3
  105. package/scripts/{git/git-epic-relations.ts → epic/epic-relations.ts} +5 -5
  106. package/scripts/{git/git-epic-remove-plan.ts → epic/epic-remove-plan.ts} +17 -19
  107. package/scripts/{git/git-epic-remove.ts → epic/epic-remove.ts} +13 -13
  108. package/scripts/{git/git-epic-run.ts → epic/epic-run.ts} +27 -27
  109. package/scripts/{git/git-epic-sections.ts → epic/epic-sections.ts} +6 -6
  110. package/scripts/{git/git-epic-shape.ts → epic/epic-shape.ts} +7 -7
  111. package/scripts/{git/git-epic-validate.ts → epic/epic-validate.ts} +13 -13
  112. package/{scripts-ai → scripts/epic}/epic.ts +17 -16
  113. package/scripts/gh/fix-gh-packages.ts +2 -1
  114. package/scripts/gh/gh-spawn.ts +12 -10
  115. package/scripts/git/constants.ts +1 -1
  116. package/scripts/git/git-branch.ts +10 -1
  117. package/scripts/git/git-command.ts +1 -1
  118. package/scripts/git/git-error.ts +2 -5
  119. package/{scripts-ai → scripts/git}/git-followup-finish.ts +7 -7
  120. package/scripts/git/git-followup-flush.ts +74 -48
  121. package/scripts/git/git-followup-issue-close.ts +2 -2
  122. package/scripts/git/git-followup-label.ts +1 -1
  123. package/scripts/git/git-followup-pending.ts +1 -1
  124. package/{scripts-ai → scripts/git}/git-followup-workflow.ts +23 -11
  125. package/scripts/git/git-gh-exec.ts +85 -26
  126. package/scripts/git/git-gh-issue-read.ts +2 -2
  127. package/scripts/git/git-gh-issue-rest.ts +2 -2
  128. package/scripts/git/git-gh-issue-write.ts +6 -6
  129. package/scripts/git/git-gh-pr-read.ts +0 -21
  130. package/scripts/git/git-gh-pr.ts +2 -23
  131. package/scripts/git/git-pr-followup-wrapup.ts +2 -6
  132. package/scripts/git/git-preflight.ts +3 -6
  133. package/scripts/git/git-staging.ts +21 -9
  134. package/{scripts-ai → scripts/git}/git-workflow.ts +11 -11
  135. package/scripts/git/github-issue-url.ts +1 -1
  136. package/scripts/git/issue-labels.ts +1 -1
  137. package/scripts/git/issue-reference.ts +19 -0
  138. package/scripts/git/main-merge.ts +2 -1
  139. package/scripts/git/main-sync.ts +3 -2
  140. package/scripts/git/schemas.ts +1 -1
  141. package/scripts/git/stash-pop-args.ts +7 -13
  142. package/scripts/git/stash-pop-cli.ts +2 -1
  143. package/{scripts-ai → scripts/git}/telegram-test-logic.ts +2 -2
  144. package/{scripts-ai → scripts/git}/telegram-test.ts +7 -7
  145. package/scripts/hooks/format-rewrite-notice.ts +2 -8
  146. package/scripts/init/init-ai-copy.ts +2 -5
  147. package/scripts/init/init.ts +8 -4
  148. package/scripts/init/start-steps.ts +2 -1
  149. package/scripts/init/start.ts +2 -1
  150. package/scripts/issue/issue-comment-cli.ts +2 -1
  151. package/scripts/issue/issue-file-cli.ts +2 -1
  152. package/scripts/issue/issue-fold-cli.ts +9 -16
  153. package/scripts/issue/issue-scout-cli.ts +8 -13
  154. package/scripts/issue/measure-rerun-cli.ts +7 -6
  155. package/scripts/josh/josh-commands-ai.ts +2 -2
  156. package/scripts/josh/josh-commands-backlog.ts +1 -1
  157. package/scripts/josh/josh-commands-development.ts +1 -1
  158. package/scripts/josh/josh-commands-workflow.ts +4 -4
  159. package/scripts/lane/lane-cli.ts +2 -1
  160. package/scripts/lane/lane-open.ts +3 -2
  161. package/scripts/lane/lane-registry.ts +3 -6
  162. package/scripts/lib/cli-flags.ts +20 -2
  163. package/scripts/lib/error-message.ts +9 -0
  164. package/scripts/lib/read-file.ts +26 -2
  165. package/scripts/observations/observation-ledger-home.ts +98 -26
  166. package/scripts/observations/observation-ledger-migrate.ts +26 -21
  167. package/scripts/observations/observation-ledger-prepare.ts +10 -20
  168. package/scripts/observations/observation-ledger.ts +58 -25
  169. package/scripts/observations/observations-flush-cli.ts +6 -23
  170. package/scripts/observations/observations-flush-landing.ts +2 -1
  171. package/scripts/observations/observations-flush.ts +29 -20
  172. package/scripts/package/package-scout-cli.ts +8 -16
  173. package/scripts/ports/port-command.ts +2 -1
  174. package/scripts/propagate/propagate-publish.ts +6 -7
  175. package/scripts/propagate/propagate-steps.ts +2 -1
  176. package/scripts/propagate/propagate.ts +3 -2
  177. package/scripts/refactor/import-graph.ts +2 -6
  178. package/scripts/registry-migration/migrate.ts +3 -2
  179. package/scripts/registry-migration/user-npmrc.ts +2 -2
  180. package/scripts/release/release-cli.ts +2 -1
  181. package/scripts/repo/repo-setting.ts +10 -8
  182. package/scripts/review/review-finding-ledger.ts +3 -3
  183. package/scripts/review/review-findings-cli.ts +4 -11
  184. package/scripts/review/review-record-cli.ts +14 -11
  185. package/scripts/review/review-record.ts +6 -16
  186. package/scripts/rules/decision-oracle.ts +2 -2
  187. package/scripts/rules/rule-body-guard.ts +2 -2
  188. package/scripts/rules/rule-value-cache.ts +99 -0
  189. package/scripts/rules/rule-value-cli.ts +10 -9
  190. package/scripts/rules/rule-value.ts +1 -1
  191. package/scripts/run/run-carry-args.ts +2 -6
  192. package/scripts/run/run-carry-cli.ts +5 -7
  193. package/scripts/run/run-cut-args.ts +7 -13
  194. package/scripts/run/run-ending-cli.ts +4 -7
  195. package/scripts/run/run-event-stream.ts +2 -5
  196. package/scripts/run/run-liveness-cli.ts +4 -7
  197. package/scripts/run/run-progress-args.ts +2 -6
  198. package/scripts/run/run-progress-cli.ts +2 -1
  199. package/scripts/run/run-tail-cli.ts +3 -3
  200. package/scripts/run/run-tidy-stashes.ts +7 -14
  201. package/scripts/run/run-wake-cli.ts +2 -6
  202. package/scripts/run/run-wake-driver.ts +15 -2
  203. package/scripts/run/run-wake-session.ts +14 -4
  204. package/scripts/safe-chain/project-config.ts +3 -6
  205. package/scripts/security/security-audit-provision.ts +6 -7
  206. package/scripts/sonar/sonar-hotspots-cli.ts +2 -1
  207. package/scripts/sync/directory-copy-guard.ts +2 -1
  208. package/scripts/sync/sync-ai-files.ts +3 -3
  209. package/scripts/sync/template-source-logic.ts +3 -4
  210. package/scripts/test/e2e-retry-check.ts +3 -6
  211. package/scripts/ui/ui-routes-cli.ts +3 -6
  212. package/scripts/version/bump-version.ts +2 -1
  213. package/scripts/version/version-remote.ts +18 -27
  214. package/scripts/version/version-targets.ts +3 -3
  215. package/.claude/skills/workflow-commands/rule-residency.md +0 -267
  216. package/evals/scenarios/consult-not-execute.json +0 -20
  217. package/evals/scenarios/no-implicit-workflow.json +0 -26
  218. package/evals/scenarios/overrides-both-locations.json +0 -29
  219. package/evals/scenarios/upstream-interrupt.json +0 -23
  220. package/evals/scenarios/workflow-skill-loaded.json +0 -16
  221. package/prompts/agent-lint-protocol.json +0 -79
  222. package/prompts/agent-rules.md +0 -63
  223. package/prompts/collaboration-workflow/consultation-vs-execution.md +0 -8
  224. package/prompts/collaboration-workflow/distributed-docs.md +0 -7
  225. package/prompts/collaboration-workflow/durable-rules.md +0 -24
  226. package/prompts/collaboration-workflow/latest-first.md +0 -14
  227. package/prompts/collaboration-workflow/no-clones.md +0 -11
  228. package/prompts/collaboration-workflow/simplicity-first.md +0 -9
  229. package/prompts/collaboration-workflow/single-source-rules.md +0 -13
  230. package/scripts/build/shim-shell.ts +0 -25
  231. package/scripts/claude/skill-meta.ts +0 -37
  232. package/scripts/document/document-scan.ts +0 -108
  233. package/scripts/document/generate-catalog.ts +0 -69
  234. package/scripts/document/retired-phrases.ts +0 -143
  235. package/scripts/document/review-agent-type.ts +0 -46
  236. package/scripts/document/single-source.ts +0 -73
  237. package/scripts/git/git-fixture-workspace.ts +0 -88
  238. package/scripts/hooks/format-edited-file-fixtures.ts +0 -109
  239. package/scripts/hooks/prompt-hooks.ts +0 -38
  240. package/scripts/init/hook-command-bootstrap.ts +0 -83
  241. package/scripts/issue/issue-logic.ts +0 -50
  242. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +0 -1227
  243. package/scripts/josh/josh-script-reader.ts +0 -11
  244. package/scripts/package/production-dependency-graph.ts +0 -68
  245. package/scripts/retrospective/retrospective-cli.ts +0 -97
  246. package/scripts/retrospective/retrospective.ts +0 -127
  247. package/scripts/rules/delivered-rules-harness.ts +0 -72
  248. package/scripts/rules/top-level-scripts-guard.ts +0 -47
  249. package/scripts/run/run-carry-flush.ts +0 -58
  250. package/scripts/run/run-transcript.golden.txt +0 -57
  251. package/scripts/run/run-wake-work.ts +0 -108
  252. package/scripts/sync/dependabot-pin-logic.ts +0 -135
  253. package/scripts/test/classify-isolation.ts +0 -149
  254. package/scripts/test/http-fault-injection.ts +0 -79
  255. package/scripts/test/josh-harness-environment.ts +0 -260
  256. package/scripts/test/josh-harness.ts +0 -96
  257. package/scripts/test/pilot-files.ts +0 -452
  258. package/scripts/test/pilot-measure.ts +0 -78
  259. package/scripts/test/test-network-guard.ts +0 -453
  260. package/scripts/test/test-repository-guard.ts +0 -317
  261. package/scripts/test/test-state-guard.ts +0 -85
  262. package/scripts/test/test-stdout-guard.ts +0 -54
  263. package/scripts/test/test-telegram-guard.ts +0 -90
  264. package/scripts/test/unit-guard-environment.ts +0 -12
  265. package/scripts/test/unit-projects.ts +0 -139
  266. package/scripts/test/vitest-include-globs.ts +0 -12
  267. package/scripts/time-runtime/time-batch-guard-fixtures.ts +0 -66
  268. package/scripts-ai/completion-gate.ts +0 -13
  269. package/scripts-ai/environment-loader.ts +0 -9
  270. /package/{scripts-ai → scripts/epic}/epic-cli-argv.ts +0 -0
  271. /package/{scripts-ai → scripts/epic}/epic-cli-remove.ts +0 -0
  272. /package/{scripts-ai → scripts/git}/followup-issue-number.ts +0 -0
@@ -1,453 +0,0 @@
1
- import {
2
- accessSync,
3
- chmodSync,
4
- constants,
5
- mkdirSync,
6
- readFileSync,
7
- rmSync,
8
- writeFileSync,
9
- } from 'node:fs'
10
- import path from 'node:path'
11
- import { fileURLToPath } from 'node:url'
12
- import { shim_shell } from '#scripts/build/shim-shell'
13
- import { platform_temporary } from '#scripts/josh/platform-temporary'
14
- import { test_repository_guard, type WriteGuard } from './test-repository-guard'
15
- import { GUARD_LOG_KEY } from './unit-guard-environment'
16
-
17
- // The unit suite must not reach the network, and until joshuafolkken/kit#1353 nothing said so out
18
- // loud. One test in `epic-bundle-cli.test.ts` mocked the listing read but not the per-issue relation
19
- // read, so `fetch_backlog` called through to a live `gh api` against real issues — 768ms against its
20
- // 26 siblings' 0–1ms, and a CI failure at the 10-second test timeout that had nothing to do with
21
- // the change under test (PR #1351). Three more in `doctor-report.test.ts` drove `doctor.main()`
22
- // without stubbing the Dependabot read and spawned `gh api …/automated-security-fixes` for real.
23
- //
24
- // Fixing those four is the repair; this file is what keeps it repaired. A one-off audit answers for
25
- // the suite as it stands today and for nothing after it — and the failure mode is invisible by
26
- // construction, because a test that calls through still *passes*, just slowly and against whatever
27
- // GitHub happens to answer.
28
- //
29
- // **The guard is a `PATH` shim rather than a module mock.** `gh` is spawned from at least four
30
- // places (`git-gh-exec.ts`, `gh-spawn.ts`, `git-gh-check.ts`, `repo-setting.ts`) through both
31
- // `execa` and `execaSync`, and a test may also spawn a CLI subprocess that spawns `gh` in turn —
32
- // which is exactly how the `doctor` cases reached the network. Intercepting the binary catches every
33
- // one of those shapes, including the ones nobody thought of; mocking a module catches only the
34
- // import graph the mock was written against.
35
- //
36
- // It is deliberately not `gh-subcommand-guard.ts`. That one reads source text and proves a call was
37
- // *written*; this one observes the run and proves a call was *made*.
38
- //
39
- // **`git` is guarded too, and differently** (joshuafolkken/kit#1515). Guarding `gh` alone left the
40
- // other way out of the machine wide open: `git-pr-followup.test.ts` and its stages suite drove
41
- // `notify_completion`, whose release count fetches `origin/<default>` for real — 4.1s per test on the
42
- // machine it was found on, fifteen tests in one file, against a 10s test timeout. The two files were
43
- // half the unit suite's wall clock and failed the pre-push gate non-deterministically, on an idle
44
- // machine as readily as under parallel load: 63.7s of wall clock against 14.9 CPU-seconds, three
45
- // quarters of it spent waiting rather than computing. That is the same defect this file's header
46
- // describes for `gh`, wearing a different binary's name, and the same one-off-audit argument applies
47
- // — so the mechanism is reused rather than a second one written.
48
- //
49
- // **The `git` shim blocks by subcommand and passes everything else through**, which is the one way it
50
- // differs from the `gh` shim. `gh` has no business in the unit suite at all, so its shim blocks
51
- // outright; `git` is how half these tests read the repository they are about — `status`, `log`,
52
- // `rev-parse`, the worktree machinery the lane suites drive — and a shim that refused those would
53
- // stop the suite rather than the network. Only the subcommands that open a connection are refused,
54
- // and the rest `exec` the real binary at the absolute path resolved before `PATH` was touched, so a
55
- // subprocess that spawns `git` in turn is still behind the shim.
56
- //
57
- // **The `git` shim also refuses a write into the repository the suite is running in**
58
- // (joshuafolkken/kit#1530). That is a second dimension rather than a second guard: the arming, the
59
- // `PATH` shim, the shared record and the teardown that throws are all this file's, and only the `sh`
60
- // that decides comes from `test-repository-guard.ts`, which is where the defect, the two findings
61
- // and what is deliberately left unguarded are written down. Sharing the record is what lets one run
62
- // report both kinds — `describe_violations` puts each under its own heading rather than filing a
63
- // stray commit under "mock the read".
64
- //
65
- // **Every failure mode here has to be loud, because the guard's own defects fail open.** A shim that
66
- // cannot run, a log that cannot be read and a clean run all look alike from the outside — so the
67
- // path is quoted rather than interpolated, and an unreadable record throws instead of reading as
68
- // "no violations".
69
- //
70
- // **On Windows it guards nothing, knowingly.** PATH resolution there selects `gh.exe` / `gh.cmd` and
71
- // never a `#!/bin/sh` file named `gh`, so the suite would reach the network with the guard reporting
72
- // clean. CI and every current checkout are POSIX; a `.cmd` companion is what to add if that changes.
73
-
74
- const PROJECT_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
75
- // Keyed by the process that armed it. `josh gate`'s unit step and a `josh test:related` run can be
76
- // in flight in the same checkout at once, and one shared log means the second run truncates the
77
- // first one's records and then inherits whatever the other recorded.
78
- //
79
- // The prefix is named on its own because two readers need it without rebuilding the whole path: a
80
- // test recognizing an armed directory on `PATH` — the pid in it is `globalSetup`'s, and a worker
81
- // asking has a different one — and `resolve_binary`, which must not find a shim where it is looking
82
- // for the real binary.
83
- const GUARD_PREFIX = 'unit-network-guard-'
84
- const GUARD_DIRECTORY = path.join(
85
- PROJECT_ROOT,
86
- 'node_modules',
87
- '.cache',
88
- `${GUARD_PREFIX}${String(process.pid)}`,
89
- )
90
- // Where the run's host-shared records land instead of the real temp root (joshuafolkken/kit#2494,
91
- // `platform-temporary.ts`). **Beside the real root rather than inside `GUARD_DIRECTORY`**, because
92
- // some of those records are Unix sockets, and a socket path under a checkout's `node_modules/.cache`
93
- // runs past the 104-byte limit macOS puts on one. The platform root is resolved directly, not read
94
- // from `PLATFORM_TEMP_ROOT`, so a suite nested inside another's test does not nest its root.
95
- const TEMPORARY_ROOT = path.join(
96
- platform_temporary.resolve_temporary_root(process.platform),
97
- `${GUARD_PREFIX}${String(process.pid)}`,
98
- )
99
- // One record for both shims. Each line starts with the binary's own name, so a mixed run still says
100
- // which call was which, and one log means `disarm` has one place to look.
101
- const LOG_NAME = 'network-calls.log'
102
- // The binaries being stood in for. Single-sourced because each is two things at once: the file name
103
- // that has to shadow the real one on `PATH`, and the word each recorded line starts with.
104
- const SHIM_NAME = 'gh'
105
- const GIT_SHIM_NAME = 'git'
106
- // Owner-writable, everyone-executable: the shim is spawned by the test workers, never edited.
107
- const SHIM_MODE = 0o755
108
-
109
- // The `git` subcommands that open a connection. Everything absent from this list is local and is
110
- // handed to the real binary untouched — see the header on why this shim blocks by subcommand where
111
- // the `gh` one blocks outright.
112
- //
113
- // **`remote`, `submodule` and `archive` are refused whole, though each has local spellings.**
114
- // `remote update` and `remote prune` reach the network while `remote -v` does not; `submodule update`
115
- // fetches while `submodule status` does not; `archive --remote=` does while a plain `archive` does
116
- // not. Splitting on the flag would put that judgement inside a `sh` `case`, and getting it wrong there
117
- // fails **open** — the call goes out and the log stays empty, which is the one failure mode this file
118
- // exists to make impossible. Refusing the whole subcommand fails closed instead, and costs nothing
119
- // real: the unit suite has no business shelling out to any of them, and none of the repository's
120
- // tests does.
121
- const GIT_NETWORK_SUBCOMMANDS: ReadonlyArray<string> = [
122
- 'archive',
123
- 'clone',
124
- 'fetch',
125
- 'ls-remote',
126
- 'pull',
127
- 'push',
128
- 'remote',
129
- 'send-email',
130
- 'submodule',
131
- ]
132
- // Git's global options that take their value as a *separate* word. Without them `git -C <dir> fetch`
133
- // reads `<dir>` as the subcommand, finds it on no list, and lets the fetch through — which is the
134
- // precise shape of failure this guard exists to notice. The `--option=value` spellings need no entry:
135
- // they are one word, and the `-*` arm already skips them.
136
- const GIT_VALUE_OPTIONS: ReadonlyArray<string> = [
137
- '-C',
138
- '-c',
139
- '--git-dir',
140
- '--namespace',
141
- '--super-prefix',
142
- '--work-tree',
143
- ]
144
-
145
- const BLOCKED_MESSAGE = 'josh: the unit suite must not reach the network — mock the read instead'
146
- const VIOLATION_HEADING =
147
- 'The unit suite made a live network call. Mock the read in the test that made it:'
148
- const UNREADABLE_LOG_HEADING =
149
- 'The unit-suite network guard could not read its own record, so the run proves nothing:'
150
-
151
- // The three lines a block writes, shared by both shims so a `git` violation is recorded, explained
152
- // and failed exactly as a `gh` one is. `$*` rather than `$@` because the record is a line of prose
153
- // for a person to read, not something re-executed.
154
- function blocking_lines(log_file: string, name: string): Array<string> {
155
- return [
156
- shim_shell.record_line(log_file, `${name} $*`),
157
- `echo ${shim_shell.quoted(BLOCKED_MESSAGE)} >&2`,
158
- 'exit 1',
159
- ]
160
- }
161
-
162
- // The shim records the invocation and fails, so a caller that ignores the exit code still leaves a
163
- // trace. `exit 1` rather than `exit 0`: a spawn that succeeds with empty output is indistinguishable
164
- // from a repository that answers nothing, and the reads here degrade that into "unreadable" — which
165
- // is what let the original defect pass as a green test for as long as it did.
166
- function shim_script(log_file: string): string {
167
- return ['#!/bin/sh', ...blocking_lines(log_file, SHIM_NAME), ''].join('\n')
168
- }
169
-
170
- // The `git` shim: find the subcommand, refuse it if it opens a connection, otherwise `exec` the real
171
- // binary. **The scan is what makes it correct rather than the list** — git accepts its own global
172
- // options before the subcommand, so the first word is not necessarily the subcommand, and a shim that
173
- // read `$1` would pass `git -C <dir> fetch` straight through. `skip` consumes the value of an option
174
- // that takes one; `-*` skips the rest; the first bare word is the subcommand, and reaching it without
175
- // a match ends the scan.
176
- //
177
- // `exec` rather than a call, so the real `git`'s exit code and signals are the shim's own — a caller
178
- // checking either must not be able to tell the shim is there.
179
- // The writing arm, from joshuafolkken/kit#1530. Unlike the network arm it does not refuse on sight:
180
- // the `sh` it splices in decides, and `break` ends the scan when it lets the command through — the
181
- // subcommand has been found either way.
182
- function guarded_arm(
183
- subcommands: ReadonlyArray<string>,
184
- body: ReadonlyArray<string>,
185
- ): Array<string> {
186
- return [
187
- `\t\t${subcommands.join('|')})`,
188
- ...body.map((line) => `\t\t\t${line}`),
189
- '\t\t\tbreak',
190
- '\t\t\t;;',
191
- ]
192
- }
193
-
194
- // The two arms from joshuafolkken/kit#1530. Unlike the network arm neither refuses on sight — the
195
- // `sh` spliced in decides — and each ends in `break`, because the subcommand has been found whether
196
- // or not it was let through.
197
- function git_write_arms(guard: WriteGuard): Array<string> {
198
- return [
199
- ...guarded_arm(
200
- test_repository_guard.GIT_WRITING_SUBCOMMANDS,
201
- test_repository_guard.write_guard_lines(guard),
202
- ),
203
- ...guarded_arm(
204
- test_repository_guard.GIT_AMBIGUOUS_SUBCOMMANDS,
205
- test_repository_guard.environment_guard_lines(guard.log_file),
206
- ),
207
- ]
208
- }
209
-
210
- // The scan itself: `skip` consumes the value of an option that takes one, `guard_seen` counts the
211
- // words git's own global options took, and the first bare word ends it.
212
- function git_scan_lines(guard: WriteGuard): Array<string> {
213
- return [
214
- 'for arg in "$@"; do',
215
- '\tif [ "$skip" = 1 ]; then skip=0; guard_seen=$((guard_seen + 1)); continue; fi',
216
- '\tcase "$arg" in',
217
- `\t\t${GIT_VALUE_OPTIONS.join('|')}) skip=1 ;;`,
218
- `\t\t${GIT_NETWORK_SUBCOMMANDS.join('|')})`,
219
- ...blocking_lines(guard.log_file, GIT_SHIM_NAME).map((line) => `\t\t\t${line}`),
220
- '\t\t\t;;',
221
- ...git_write_arms(guard),
222
- '\t\t-*) ;;',
223
- '\t\t*) break ;;',
224
- '\tesac',
225
- '\tguard_seen=$((guard_seen + 1))',
226
- 'done',
227
- ]
228
- }
229
-
230
- function git_shim_script(
231
- log_file: string,
232
- git_binary: string,
233
- guarded_directory: string | undefined,
234
- ): string {
235
- return [
236
- '#!/bin/sh',
237
- ...test_repository_guard.prelude_lines(git_binary, guarded_directory),
238
- 'skip=0',
239
- 'guard_seen=0',
240
- ...git_scan_lines({ guarded_directory, log_file }),
241
- `exec ${shim_shell.quoted(git_binary)} "$@"`,
242
- '',
243
- ].join('\n')
244
- }
245
-
246
- // The real binary, found by walking `PATH` the way the shell would, and written into the shim as an
247
- // absolute path. That is what lets `PATH` keep the guard directory in front: a subprocess spawned by
248
- // a test inherits the shim rather than escaping to the real `git` behind it.
249
- //
250
- // **An armed guard directory is skipped, and that is not belt-and-braces.** `arm` puts one at the
251
- // front of `PATH` and every test worker inherits it, so this module loaded inside a worker would find
252
- // `<guard>/git` — and a shim built from that answer `exec`s a shim, which at the default directory is
253
- // itself: an unbounded loop, and a `GIT_BINARY` assertion that proves only that the shim exists.
254
- function is_guard_directory(directory: string): boolean {
255
- return path.basename(directory).startsWith(GUARD_PREFIX)
256
- }
257
-
258
- function is_executable(candidate: string): boolean {
259
- try {
260
- accessSync(candidate, constants.X_OK)
261
-
262
- return true
263
- } catch {
264
- return false
265
- }
266
- }
267
-
268
- function holds_binary(directory: string, name: string): boolean {
269
- return !is_guard_directory(directory) && is_executable(path.join(directory, name))
270
- }
271
-
272
- function resolve_binary(
273
- name: string,
274
- search_path: string = process.env['PATH'] ?? '',
275
- ): string | undefined {
276
- const directory = search_path.split(path.delimiter).find((entry) => holds_binary(entry, name))
277
-
278
- return directory === undefined ? undefined : path.join(directory, name)
279
- }
280
-
281
- const GIT_BINARY = resolve_binary(GIT_SHIM_NAME)
282
-
283
- // The repository a write is refused against, resolved once here rather than in the shim: the answer
284
- // is the same for every call of the run, and asking git for it costs a process. `undefined` where
285
- // `PROJECT_ROOT` is not a repository at all — see `test-repository-guard.ts` on why that leaves the
286
- // inherited-environment finding armed and only the resolution one out.
287
- const GUARDED_GIT_DIRECTORY = test_repository_guard.guarded_git_directory(GIT_BINARY, PROJECT_ROOT)
288
-
289
- // The recorded invocations, blank lines dropped. Split out from the read so the parsing is testable
290
- // without a run: an empty log and a log of one empty line must not read the same.
291
- function calls_of(log_text: string): Array<string> {
292
- return log_text
293
- .split('\n')
294
- .map((line) => line.trim())
295
- .filter((line) => line !== '')
296
- }
297
-
298
- // Each distinct command once, with its count — a single loop repeated 40 times is one defect, and a
299
- // list of 40 identical lines hides the second one under it.
300
- function counted_lines(calls: ReadonlyArray<string>): Array<string> {
301
- const counts = new Map<string, number>()
302
-
303
- for (const call of calls) counts.set(call, (counts.get(call) ?? 0) + 1)
304
-
305
- return [...counts].map(([call, count]) => ` ${String(count)}x ${call}`)
306
- }
307
-
308
- // A heading and its calls, or nothing at all when there are none.
309
- function section(heading: string, calls: ReadonlyArray<string>): Array<string> {
310
- return calls.length === 0 ? [] : [heading, ...counted_lines(calls)]
311
- }
312
-
313
- // One section per kind. The two guards share one record, so a run that both reached the network and
314
- // wrote into its own repository has to report each under its own heading — filing one under the
315
- // other's would tell the reader to mock a read that was never made (joshuafolkken/kit#1530).
316
- function describe_violations(calls: ReadonlyArray<string>): string {
317
- const writes = calls.filter((call) => test_repository_guard.is_write_violation(call))
318
- const network = calls.filter((call) => !test_repository_guard.is_write_violation(call))
319
-
320
- return [
321
- ...section(VIOLATION_HEADING, network),
322
- ...section(test_repository_guard.WRITE_VIOLATION_HEADING, writes),
323
- ].join('\n')
324
- }
325
-
326
- // One armed guard is one directory holding both the shim and its record, so the directory is the
327
- // only thing any of the calls below take. Derived rather than passed alongside it: a `disarm` given
328
- // a log file that lived somewhere else would recursively delete whatever directory that was in.
329
- function log_in(directory: string): string {
330
- return path.join(directory, LOG_NAME)
331
- }
332
-
333
- function write_executable(shim_file: string, script: string): void {
334
- writeFileSync(shim_file, script)
335
- chmodSync(shim_file, SHIM_MODE)
336
- }
337
-
338
- // **Skipped rather than faked when the real `git` cannot be found.** A shim with nothing to `exec`
339
- // would fail every local `git` in the suite, which is a far worse answer than leaving this one binary
340
- // unguarded — and it cannot happen in a checkout, since finding no `git` here means there was no
341
- // `git` to clone with. The branch is a safety net, not a supported mode.
342
- function install_git_shim(directory: string, guarded_directory: string | undefined): void {
343
- if (GIT_BINARY === undefined) return
344
-
345
- write_executable(
346
- path.join(directory, GIT_SHIM_NAME),
347
- git_shim_script(log_in(directory), GIT_BINARY, guarded_directory),
348
- )
349
- }
350
-
351
- // The shims, written where they will be spawned from. Shared with this module's own test rather than
352
- // re-written there: a test that builds its own copy proves that copy works and says nothing about
353
- // the one the suite actually runs behind.
354
- // **`undefined` here means "there is no repository to guard", and a parameter default could not say
355
- // that** — a JavaScript default fires on an explicit `undefined` too, so a caller asking for the
356
- // no-repository shim silently got one guarding the real checkout, and the branch that omits the
357
- // resolver had no test at all (review round 2 of joshuafolkken/kit#1530). The default lives in the
358
- // wrapper below instead, where it is applied rather than defaulted.
359
- function install_shim_guarding(directory: string, guarded_directory: string | undefined): string {
360
- const shim_file = path.join(directory, SHIM_NAME)
361
-
362
- mkdirSync(directory, { recursive: true })
363
- writeFileSync(log_in(directory), '')
364
- write_executable(shim_file, shim_script(log_in(directory)))
365
- install_git_shim(directory, guarded_directory)
366
-
367
- return shim_file
368
- }
369
-
370
- function install_shim(directory: string = GUARD_DIRECTORY): string {
371
- return install_shim_guarding(directory, GUARDED_GIT_DIRECTORY)
372
- }
373
-
374
- // `undefined` for a record that could not be read, which is not the same answer as an empty one:
375
- // the shim runs in its own process and the test workers are processes again, so the only evidence
376
- // this side has is the file — and "the file is not there" says nothing about what the run did.
377
- function read_log(log_file: string): string | undefined {
378
- try {
379
- return readFileSync(log_file, 'utf8')
380
- } catch {
381
- return undefined
382
- }
383
- }
384
-
385
- // `globalSetup` runs before any worker is forked, so the workers inherit this `PATH` and every `gh`
386
- // and `git` they spawn — directly or through a CLI subprocess of their own — resolves to a shim. They
387
- // inherit the record's path for the in-process Telegram guard and the redirected temp root with it.
388
- function arm(directory: string = GUARD_DIRECTORY, temporary_root: string = TEMPORARY_ROOT): void {
389
- install_shim(directory)
390
- mkdirSync(temporary_root, { recursive: true })
391
- process.env['PATH'] = `${directory}${path.delimiter}${process.env['PATH'] ?? ''}`
392
- process.env[GUARD_LOG_KEY] = log_in(directory)
393
- process.env[platform_temporary.TEMP_ROOT_KEY] = temporary_root
394
- }
395
-
396
- // Thrown rather than logged: a warning on a suite that already exited 0 is a warning nobody reads,
397
- // which is the state joshuafolkken/kit#1353 was filed from. The directory goes either way, so a
398
- // failing run does not leave the next one reading its records.
399
- function disarm(directory: string = GUARD_DIRECTORY): void {
400
- const log_text = read_log(log_in(directory))
401
-
402
- try {
403
- if (log_text === undefined) throw new Error(`${UNREADABLE_LOG_HEADING} ${log_in(directory)}`)
404
-
405
- const calls = calls_of(log_text)
406
-
407
- if (calls.length > 0) throw new Error(describe_violations(calls))
408
- } finally {
409
- rmSync(directory, { recursive: true, force: true })
410
- }
411
- }
412
-
413
- // Vitest's `globalSetup` contract: a named `setup` whose return value becomes the teardown.
414
- function teardown(): void {
415
- try {
416
- disarm()
417
- } finally {
418
- rmSync(TEMPORARY_ROOT, { recursive: true, force: true })
419
- }
420
- }
421
-
422
- function setup(): () => void {
423
- arm()
424
-
425
- return teardown
426
- }
427
-
428
- const test_network_guard = {
429
- BLOCKED_MESSAGE,
430
- GIT_BINARY,
431
- GIT_NETWORK_SUBCOMMANDS,
432
- GIT_SHIM_NAME,
433
- GIT_VALUE_OPTIONS,
434
- GUARDED_GIT_DIRECTORY,
435
- GUARD_DIRECTORY,
436
- GUARD_PREFIX,
437
- SHIM_NAME,
438
- UNREADABLE_LOG_HEADING,
439
- VIOLATION_HEADING,
440
- arm,
441
- calls_of,
442
- describe_violations,
443
- disarm,
444
- git_shim_script,
445
- install_shim,
446
- install_shim_guarding,
447
- log_in,
448
- resolve_binary,
449
- shim_script,
450
- TEMPORARY_ROOT,
451
- }
452
-
453
- export { setup, test_network_guard }