@intentius/chant 0.124.31 → 0.124.32

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 (321) hide show
  1. package/bin/chant +7 -2
  2. package/dist/cli/commands/check-lexicon-plugin.d.ts +7 -4
  3. package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -1
  4. package/dist/cli/commands/check-lexicon-plugin.js +10 -7
  5. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  6. package/dist/cli/commands/check-lexicon.js +18 -12
  7. package/dist/cli/commands/import.d.ts +10 -0
  8. package/dist/cli/commands/import.d.ts.map +1 -1
  9. package/dist/cli/commands/import.js +37 -0
  10. package/dist/cli/commands/init-lexicon/templates/project.d.ts.map +1 -1
  11. package/dist/cli/commands/init-lexicon/templates/project.js +4 -3
  12. package/dist/cli/commands/onboard.d.ts.map +1 -1
  13. package/dist/cli/commands/onboard.js +8 -4
  14. package/dist/cli/commands/update.d.ts.map +1 -1
  15. package/dist/cli/commands/update.js +2 -1
  16. package/dist/cli/dependencies.d.ts +18 -0
  17. package/dist/cli/dependencies.d.ts.map +1 -0
  18. package/dist/cli/dependencies.js +120 -0
  19. package/dist/cli/handlers/build.d.ts.map +1 -1
  20. package/dist/cli/handlers/build.js +23 -4
  21. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  22. package/dist/cli/handlers/fan-out.js +12 -4
  23. package/dist/cli/handlers/graph.d.ts.map +1 -1
  24. package/dist/cli/handlers/graph.js +16 -6
  25. package/dist/cli/handlers/lint.d.ts.map +1 -1
  26. package/dist/cli/handlers/lint.js +6 -0
  27. package/dist/cli/handlers/operator.d.ts +2 -2
  28. package/dist/cli/handlers/operator.js +3 -3
  29. package/dist/cli/handlers/pr.d.ts +14 -0
  30. package/dist/cli/handlers/pr.d.ts.map +1 -1
  31. package/dist/cli/handlers/pr.js +488 -32
  32. package/dist/cli/handlers/promote.d.ts.map +1 -1
  33. package/dist/cli/handlers/promote.js +3 -1
  34. package/dist/cli/handlers/run.d.ts +13 -1
  35. package/dist/cli/handlers/run.d.ts.map +1 -1
  36. package/dist/cli/handlers/run.js +38 -15
  37. package/dist/cli/main.d.ts.map +1 -1
  38. package/dist/cli/main.js +44 -16
  39. package/dist/cli/parse-args.d.ts.map +1 -1
  40. package/dist/cli/parse-args.js +15 -0
  41. package/dist/cli/registry.d.ts +13 -4
  42. package/dist/cli/registry.d.ts.map +1 -1
  43. package/dist/cli/registry.js +6 -2
  44. package/dist/codegen/generate-post-synth-barrel.js +2 -2
  45. package/dist/codegen/lexicon-regen.d.ts +4 -0
  46. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  47. package/dist/codegen/lexicon-regen.js +1 -1
  48. package/dist/codegen/pinned-upgrade.js +2 -2
  49. package/dist/components/capability.d.ts +44 -3
  50. package/dist/components/capability.d.ts.map +1 -1
  51. package/dist/components/capability.js +21 -0
  52. package/dist/components/changed-files.d.ts +7 -1
  53. package/dist/components/changed-files.d.ts.map +1 -1
  54. package/dist/components/changed-files.js +9 -1
  55. package/dist/components/driver.d.ts +38 -0
  56. package/dist/components/driver.d.ts.map +1 -1
  57. package/dist/components/driver.js +47 -6
  58. package/dist/components/fan-out-record.d.ts +6 -1
  59. package/dist/components/fan-out-record.d.ts.map +1 -1
  60. package/dist/components/fan-out-record.js +24 -0
  61. package/dist/components/fan-out-run.d.ts +20 -0
  62. package/dist/components/fan-out-run.d.ts.map +1 -1
  63. package/dist/components/fan-out-run.js +21 -1
  64. package/dist/components/pr-pipeline.d.ts +65 -2
  65. package/dist/components/pr-pipeline.d.ts.map +1 -1
  66. package/dist/components/pr-pipeline.js +107 -15
  67. package/dist/components/pr-run.d.ts +127 -29
  68. package/dist/components/pr-run.d.ts.map +1 -1
  69. package/dist/components/pr-run.js +290 -70
  70. package/dist/components/wave-plan.d.ts +5 -2
  71. package/dist/components/wave-plan.d.ts.map +1 -1
  72. package/dist/components/wave-plan.js +12 -1
  73. package/dist/discovery/fold-import.d.ts.map +1 -1
  74. package/dist/discovery/fold-import.js +16 -6
  75. package/dist/discovery/sandbox/bundle.d.ts.map +1 -1
  76. package/dist/discovery/sandbox/bundle.js +6 -0
  77. package/dist/discovery/specifier-resolve.d.ts.map +1 -1
  78. package/dist/discovery/specifier-resolve.js +8 -2
  79. package/dist/fold/fold.d.ts.map +1 -1
  80. package/dist/fold/fold.js +68 -1
  81. package/dist/fold/subset.d.ts +37 -0
  82. package/dist/fold/subset.d.ts.map +1 -1
  83. package/dist/fold/subset.js +88 -0
  84. package/dist/lexicon-module.d.ts +10 -0
  85. package/dist/lexicon-module.d.ts.map +1 -1
  86. package/dist/lexicon-module.js +53 -8
  87. package/dist/lexicon-plugin-helpers.d.ts.map +1 -1
  88. package/dist/lexicon-plugin-helpers.js +6 -4
  89. package/dist/lexicon.d.ts +2 -0
  90. package/dist/lexicon.d.ts.map +1 -1
  91. package/dist/lifecycle/affected.d.ts.map +1 -1
  92. package/dist/lifecycle/affected.js +7 -1
  93. package/dist/lifecycle/release-ledger.d.ts +21 -0
  94. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  95. package/dist/lifecycle/release-ledger.js +24 -0
  96. package/dist/lifecycle/work-dir.d.ts +45 -0
  97. package/dist/lifecycle/work-dir.d.ts.map +1 -0
  98. package/dist/lifecycle/work-dir.js +78 -0
  99. package/dist/lint/discover.d.ts +17 -9
  100. package/dist/lint/discover.d.ts.map +1 -1
  101. package/dist/lint/discover.js +62 -54
  102. package/dist/module-source.d.ts +57 -0
  103. package/dist/module-source.d.ts.map +1 -0
  104. package/dist/module-source.js +104 -0
  105. package/dist/op/activities/factory.js +1 -1
  106. package/dist/op/discover.d.ts.map +1 -1
  107. package/dist/op/discover.js +13 -3
  108. package/dist/op/gate.d.ts.map +1 -1
  109. package/dist/op/gate.js +3 -0
  110. package/dist/op/op-waves-run.d.ts.map +1 -1
  111. package/dist/op/op-waves-run.js +20 -5
  112. package/dist/op/operator.d.ts +2 -2
  113. package/dist/op/operator.js +2 -2
  114. package/dist/op/steward.d.ts +6 -4
  115. package/dist/op/steward.d.ts.map +1 -1
  116. package/dist/op/steward.js +5 -3
  117. package/dist/op/work-lease-run.d.ts +20 -7
  118. package/dist/op/work-lease-run.d.ts.map +1 -1
  119. package/dist/op/work-lease-run.js +56 -12
  120. package/dist/pr-forge.d.ts +10 -0
  121. package/dist/pr-forge.d.ts.map +1 -1
  122. package/dist/pr-forge.js +39 -0
  123. package/dist/pr-loop.d.ts +141 -4
  124. package/dist/pr-loop.d.ts.map +1 -1
  125. package/dist/pr-loop.js +122 -6
  126. package/dist/pr-report-url.d.ts +2 -2
  127. package/dist/pr-report-url.d.ts.map +1 -1
  128. package/dist/pr-report-url.js +5 -5
  129. package/dist/ts-loader.d.ts +38 -4
  130. package/dist/ts-loader.d.ts.map +1 -1
  131. package/dist/ts-loader.js +190 -16
  132. package/dist/wave-runtime.mjs +208 -208
  133. package/dist/workspace/chant-migrations/fountain-retired-kinds.d.ts +34 -0
  134. package/dist/workspace/chant-migrations/fountain-retired-kinds.d.ts.map +1 -0
  135. package/dist/workspace/chant-migrations/fountain-retired-kinds.js +176 -0
  136. package/dist/workspace/chant-migrations.d.ts.map +1 -1
  137. package/dist/workspace/chant-migrations.js +2 -0
  138. package/dist/workspace/checks/boxes.d.ts +6 -6
  139. package/dist/workspace/checks/boxes.js +9 -9
  140. package/dist/workspace/ci-green-forge.d.ts +6 -4
  141. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  142. package/dist/workspace/ci-green.d.ts +28 -12
  143. package/dist/workspace/ci-green.d.ts.map +1 -1
  144. package/dist/workspace/ci-green.js +49 -21
  145. package/dist/workspace/ci-list.schema.json +12 -6
  146. package/dist/workspace/ci-report-url.d.ts +18 -0
  147. package/dist/workspace/ci-report-url.d.ts.map +1 -0
  148. package/dist/workspace/ci-report-url.js +33 -0
  149. package/dist/workspace/ci-run-index.d.ts +12 -0
  150. package/dist/workspace/ci-run-index.d.ts.map +1 -1
  151. package/dist/workspace/ci-run-index.js +6 -4
  152. package/dist/workspace/ci-run-index.schema.json +5 -3
  153. package/dist/workspace/ci-run.d.ts +75 -2
  154. package/dist/workspace/ci-run.d.ts.map +1 -1
  155. package/dist/workspace/ci-run.js +195 -53
  156. package/dist/workspace/ci-runner-key.d.ts +16 -3
  157. package/dist/workspace/ci-runner-key.d.ts.map +1 -1
  158. package/dist/workspace/ci-runner-key.js +30 -10
  159. package/dist/workspace/ci-serve.d.ts +54 -4
  160. package/dist/workspace/ci-serve.d.ts.map +1 -1
  161. package/dist/workspace/ci-serve.js +175 -26
  162. package/dist/workspace/declaration.d.ts +8 -0
  163. package/dist/workspace/declaration.d.ts.map +1 -1
  164. package/dist/workspace/declaration.js +11 -2
  165. package/dist/workspace/declaration.schema.json +7 -2
  166. package/dist/workspace/export.d.ts +1 -1
  167. package/dist/workspace/export.d.ts.map +1 -1
  168. package/dist/workspace/export.js +7 -3
  169. package/dist/workspace/ls.d.ts +6 -1
  170. package/dist/workspace/ls.d.ts.map +1 -1
  171. package/dist/workspace/ls.js +1 -0
  172. package/dist/workspace/ls.schema.json +7 -1
  173. package/dist/workspace/pin-integrity.d.ts.map +1 -1
  174. package/dist/workspace/pin-integrity.js +3 -2
  175. package/dist/workspace/pr-report.schema.json +74 -7
  176. package/dist/workspace/reason-codes.d.ts +1 -1
  177. package/dist/workspace/reason-codes.d.ts.map +1 -1
  178. package/dist/workspace/reason-codes.js +2 -2
  179. package/dist/workspace/run-event.schema.json +3 -1
  180. package/dist/workspace/status.schema.json +3 -1
  181. package/dist/workspace/write-scope.d.ts +9 -0
  182. package/dist/workspace/write-scope.d.ts.map +1 -1
  183. package/dist/workspace/write-scope.js +11 -1
  184. package/package.json +1 -1
  185. package/src/__fixtures__/pr-loop/pr-apply-wave-waiting.note.golden.md +48 -0
  186. package/src/cli/commands/__fixtures__/init-lexicon-output/package.json +3 -3
  187. package/src/cli/commands/audit.test.ts +2 -2
  188. package/src/cli/commands/check-lexicon-plugin.test.ts +9 -0
  189. package/src/cli/commands/check-lexicon-plugin.ts +11 -8
  190. package/src/cli/commands/check-lexicon.test.ts +1 -1
  191. package/src/cli/commands/check-lexicon.ts +16 -11
  192. package/src/cli/commands/import.ts +40 -0
  193. package/src/cli/commands/init-lexicon/templates/project.ts +4 -3
  194. package/src/cli/commands/init-lexicon.test.ts +8 -5
  195. package/src/cli/commands/onboard.test.ts +45 -0
  196. package/src/cli/commands/onboard.ts +8 -4
  197. package/src/cli/commands/update.test.ts +6 -0
  198. package/src/cli/commands/update.ts +4 -1
  199. package/src/cli/compiled-cli.e2e.test.ts +76 -11
  200. package/src/cli/dependencies.e2e.test.ts +101 -0
  201. package/src/cli/dependencies.test.ts +93 -0
  202. package/src/cli/dependencies.ts +122 -0
  203. package/src/cli/handlers/build.test.ts +69 -0
  204. package/src/cli/handlers/build.ts +20 -4
  205. package/src/cli/handlers/fan-out.test.ts +73 -0
  206. package/src/cli/handlers/fan-out.ts +11 -4
  207. package/src/cli/handlers/graph.test.ts +76 -0
  208. package/src/cli/handlers/graph.ts +19 -12
  209. package/src/cli/handlers/lint.test.ts +42 -0
  210. package/src/cli/handlers/lint.ts +6 -0
  211. package/src/cli/handlers/operator.ts +3 -3
  212. package/src/cli/handlers/pr-waves.test.ts +449 -0
  213. package/src/cli/handlers/pr.test.ts +134 -0
  214. package/src/cli/handlers/pr.ts +523 -31
  215. package/src/cli/handlers/promote.test.ts +26 -1
  216. package/src/cli/handlers/promote.ts +3 -2
  217. package/src/cli/handlers/run-components-exit.test.ts +99 -0
  218. package/src/cli/handlers/run-signer.test.ts +79 -0
  219. package/src/cli/handlers/run.ts +34 -16
  220. package/src/cli/main.test.ts +18 -0
  221. package/src/cli/main.ts +43 -16
  222. package/src/cli/parse-args.ts +12 -0
  223. package/src/cli/registry.ts +19 -6
  224. package/src/codegen/generate-post-synth-barrel.ts +2 -2
  225. package/src/codegen/lexicon-regen.ts +1 -1
  226. package/src/codegen/pinned-upgrade.ts +2 -2
  227. package/src/codegen/post-synth-barrel-guards.test.ts +6 -11
  228. package/src/components/capability.ts +51 -3
  229. package/src/components/changed-files.test.ts +16 -1
  230. package/src/components/changed-files.ts +9 -1
  231. package/src/components/driver.test.ts +36 -1
  232. package/src/components/driver.ts +76 -4
  233. package/src/components/fan-out-record.ts +29 -1
  234. package/src/components/fan-out-run.test.ts +35 -0
  235. package/src/components/fan-out-run.ts +40 -1
  236. package/src/components/pr-pipeline.test.ts +100 -0
  237. package/src/components/pr-pipeline.ts +132 -17
  238. package/src/components/pr-run.test.ts +323 -18
  239. package/src/components/pr-run.ts +354 -75
  240. package/src/components/wave-plan.ts +16 -4
  241. package/src/discovery/fold-host-method.test.ts +87 -0
  242. package/src/discovery/fold-import.ts +15 -5
  243. package/src/discovery/fold-package-composite.test.ts +27 -0
  244. package/src/discovery/fold-specifiers.test.ts +8 -0
  245. package/src/discovery/sandbox/bundle.ts +6 -0
  246. package/src/discovery/specifier-resolve.ts +8 -2
  247. package/src/fold/fold.test.ts +72 -0
  248. package/src/fold/fold.ts +71 -0
  249. package/src/fold/subset.test.ts +31 -0
  250. package/src/fold/subset.ts +99 -0
  251. package/src/lexicon-dist.e2e.test.ts +254 -0
  252. package/src/lexicon-module.test.ts +47 -0
  253. package/src/lexicon-module.ts +49 -7
  254. package/src/lexicon-plugin-helpers.ts +6 -4
  255. package/src/lexicon.ts +2 -0
  256. package/src/lifecycle/affected.test.ts +7 -0
  257. package/src/lifecycle/affected.ts +6 -1
  258. package/src/lifecycle/release-ledger.ts +34 -0
  259. package/src/lifecycle/work-dir.test.ts +77 -0
  260. package/src/lifecycle/work-dir.ts +82 -0
  261. package/src/lint/discover.test.ts +46 -2
  262. package/src/lint/discover.ts +55 -49
  263. package/src/module-source.test.ts +85 -0
  264. package/src/module-source.ts +112 -0
  265. package/src/op/activities/factory.ts +1 -1
  266. package/src/op/discover-root.test.ts +31 -2
  267. package/src/op/discover.ts +14 -4
  268. package/src/op/gate.test.ts +30 -0
  269. package/src/op/gate.ts +3 -0
  270. package/src/op/op-waves-run.ts +20 -5
  271. package/src/op/op-waves.test.ts +49 -0
  272. package/src/op/operator.ts +2 -2
  273. package/src/op/steward.ts +6 -4
  274. package/src/op/work-lease-run.test.ts +88 -2
  275. package/src/op/work-lease-run.ts +52 -12
  276. package/src/pr-forge-bundle.test.ts +2 -2
  277. package/src/pr-forge.test.ts +42 -0
  278. package/src/pr-forge.ts +40 -0
  279. package/src/pr-loop-bundle.test.ts +2 -1
  280. package/src/pr-loop.test.ts +117 -1
  281. package/src/pr-loop.ts +252 -10
  282. package/src/pr-report-url.ts +5 -5
  283. package/src/terraform/__fixtures__/advise.test.ts +21 -2
  284. package/src/ts-loader.test.ts +89 -1
  285. package/src/ts-loader.ts +180 -13
  286. package/src/workspace/changes.test.ts +1 -1
  287. package/src/workspace/chant-migrations/fountain-retired-kinds.test.ts +33 -0
  288. package/src/workspace/chant-migrations/fountain-retired-kinds.ts +190 -0
  289. package/src/workspace/chant-migrations.e2e.test.ts +5 -0
  290. package/src/workspace/chant-migrations.ts +2 -0
  291. package/src/workspace/checks/boxes.ts +9 -9
  292. package/src/workspace/ci-green-forge.ts +6 -4
  293. package/src/workspace/ci-green-runs.test.ts +53 -11
  294. package/src/workspace/ci-green.test.ts +1 -1
  295. package/src/workspace/ci-green.ts +50 -22
  296. package/src/workspace/ci-list.schema.json +12 -6
  297. package/src/workspace/ci-report-url.test.ts +220 -0
  298. package/src/workspace/ci-report-url.ts +33 -0
  299. package/src/workspace/ci-run-index.schema.json +5 -3
  300. package/src/workspace/ci-run-index.test.ts +42 -3
  301. package/src/workspace/ci-run-index.ts +18 -4
  302. package/src/workspace/ci-run.test.ts +185 -7
  303. package/src/workspace/ci-run.ts +224 -54
  304. package/src/workspace/ci-runner-key.test.ts +55 -17
  305. package/src/workspace/ci-runner-key.ts +36 -11
  306. package/src/workspace/ci-serve.test.ts +150 -6
  307. package/src/workspace/ci-serve.ts +169 -25
  308. package/src/workspace/declaration.schema.json +7 -2
  309. package/src/workspace/declaration.test.ts +23 -0
  310. package/src/workspace/declaration.ts +19 -3
  311. package/src/workspace/export.ts +6 -3
  312. package/src/workspace/ls.schema.json +7 -1
  313. package/src/workspace/ls.ts +7 -2
  314. package/src/workspace/pin-integrity.test.ts +5 -0
  315. package/src/workspace/pin-integrity.ts +3 -2
  316. package/src/workspace/pr-report.schema.json +74 -7
  317. package/src/workspace/reason-codes.ts +2 -2
  318. package/src/workspace/run-event.schema.json +3 -1
  319. package/src/workspace/status.schema.json +3 -1
  320. package/src/workspace/write-scope.test.ts +29 -1
  321. package/src/workspace/write-scope.ts +12 -1
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Where an Op that changes the checkout makes its work worktrees (#2893).
3
+ *
4
+ * A work item's worktree is `<checkout>/.chant/work/[<member>/]<item>`. It
5
+ * sits inside the checkout, so a coding agent whose project root is the
6
+ * checkout may write there, and `node_modules` above it still resolves. It
7
+ * used to be `<git-common-dir>/chant-work/...`, where Claude Code refuses
8
+ * every write. `/.chant/work/` goes in `<git-common-dir>/info/exclude`, so the
9
+ * checkout's `git status` never lists it, and the rest of `.chant/` (the
10
+ * workspace lock, the factory's check logs) stays tracked.
11
+ *
12
+ * This module imports nothing from the workspace or the Op runner, so the
13
+ * hand-written walkers can ask it which directory to skip.
14
+ */
15
+
16
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync } from "node:fs";
17
+ import { dirname, join, sep } from "node:path";
18
+
19
+ /** The work directory, relative to a checkout's top, `/`-separated. */
20
+ export const WORK_DIR = ".chant/work";
21
+
22
+ /** The line `<git-common-dir>/info/exclude` gets, anchored at each checkout's top. */
23
+ export const WORK_DIR_EXCLUDE = `/${WORK_DIR}/`;
24
+
25
+ /** The directory where earlier versions made work worktrees, under the git common directory. */
26
+ export const LEGACY_WORK_DIR = "chant-work";
27
+
28
+ /**
29
+ * Whether a directory, given as a `/`-separated path relative to the root a
30
+ * walker started from, is a work directory: `.chant/work` at that root or at
31
+ * any depth below it. A walker skips it, since each worktree in it is a whole
32
+ * copy of the checkout.
33
+ */
34
+ export function isWorkDir(rel: string): boolean {
35
+ return rel === WORK_DIR || rel.endsWith(`/${WORK_DIR}`);
36
+ }
37
+
38
+ /** The worktree for `id` under the checkout whose top is `top`. */
39
+ export function workWorktreePath(top: string, members: readonly string[], id: string): string {
40
+ return join(top, ...WORK_DIR.split("/"), ...members, id);
41
+ }
42
+
43
+ /** The worktree an earlier version made for `id`, under the git common directory. */
44
+ export function legacyWorkWorktreePath(commonDir: string, members: readonly string[], id: string): string {
45
+ return join(commonDir, LEGACY_WORK_DIR, ...members, id);
46
+ }
47
+
48
+ /**
49
+ * Add {@link WORK_DIR_EXCLUDE} to `<commonDir>/info/exclude` unless the file
50
+ * already has it, or `ignored` says git already ignores the work directory
51
+ * (a `.gitignore` of the project's, or the global excludes file). Returns
52
+ * whether it wrote the line.
53
+ */
54
+ export function excludeWorkDir(commonDir: string, ignored: boolean): boolean {
55
+ const file = join(commonDir, "info", "exclude");
56
+ const text = existsSync(file) ? readFileSync(file, "utf-8") : "";
57
+ if (text.split(/\r?\n/).some((l) => l.trim() === WORK_DIR_EXCLUDE)) return false;
58
+ if (ignored) return false;
59
+ mkdirSync(join(commonDir, "info"), { recursive: true });
60
+ appendFileSync(file, `${text === "" || text.endsWith("\n") ? "" : "\n"}${WORK_DIR_EXCLUDE}\n`);
61
+ return true;
62
+ }
63
+
64
+ /**
65
+ * After a work worktree is removed, remove the directories above it that are
66
+ * left empty, up to and including the checkout's `.chant`, so a run leaves
67
+ * nothing behind in a checkout that had no `.chant` before it.
68
+ */
69
+ export function removeEmptyWorkDirs(worktree: string): void {
70
+ const marker = `${sep}${WORK_DIR.split("/").join(sep)}${sep}`;
71
+ const at = worktree.lastIndexOf(marker);
72
+ if (at < 0) return;
73
+ const stop = worktree.slice(0, at);
74
+ for (let dir = dirname(worktree); dir.length > stop.length; dir = dirname(dir)) {
75
+ try {
76
+ if (readdirSync(dir).length > 0) return;
77
+ rmdirSync(dir);
78
+ } catch {
79
+ return;
80
+ }
81
+ }
82
+ }
@@ -1,7 +1,9 @@
1
- import { describe, test, expect } from "vitest";
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
2
  import { join, dirname } from "path";
3
3
  import { fileURLToPath } from "url";
4
- import { discoverLintRules, discoverPostSynthChecks } from "./discover";
4
+ import { mkdtempSync, rmSync, writeFileSync } from "fs";
5
+ import { tmpdir } from "os";
6
+ import { discoverLintRules, discoverPostSynthChecks, listRuleFiles } from "./discover";
5
7
 
6
8
  const thisDir = dirname(fileURLToPath(import.meta.url));
7
9
 
@@ -67,3 +69,45 @@ describe("discoverPostSynthChecks", () => {
67
69
  expect(checks.length).toBe(0);
68
70
  });
69
71
  });
72
+
73
+ describe("rule modules compiled to JS (#3981)", () => {
74
+ let dir: string;
75
+
76
+ beforeAll(() => {
77
+ // A compiled rules directory: ES modules in a "type": "module" package, each with its declarations beside it.
78
+ dir = mkdtempSync(join(tmpdir(), "chant-discover-js-"));
79
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ type: "module" }));
80
+ writeFileSync(join(dir, "alpha.js"), `export const alpha = { id: "JS002", severity: "warning", category: "style", check: () => [] };\n`);
81
+ writeFileSync(join(dir, "alpha.d.ts"), `export declare const alpha: unknown;\n`);
82
+ writeFileSync(join(dir, "beta.js"), `export const beta = { id: "JS001", severity: "error", category: "security", check: () => [] };\n`);
83
+ writeFileSync(join(dir, "beta.d.ts"), `export declare const beta: unknown;\n`);
84
+ writeFileSync(join(dir, "gamma.js"), `export const gamma = { id: "JSP001", description: "a post-synth check", check: () => [] };\n`);
85
+ writeFileSync(join(dir, "index.js"), `export * from "./alpha.js";\n`);
86
+ writeFileSync(join(dir, "rule-helpers.js"), `export const helper = 1;\n`);
87
+ writeFileSync(join(dir, "alpha.test.js"), `throw new Error("a test is not a rule");\n`);
88
+ });
89
+
90
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
91
+
92
+ test("listRuleFiles lists .js modules and leaves out declarations, tests, helpers and the index", () => {
93
+ expect(listRuleFiles(dir).sort()).toEqual(["alpha.js", "beta.js", "gamma.js"]);
94
+ });
95
+
96
+ test("a source module and its compiled twin list once, as the source", () => {
97
+ const both = mkdtempSync(join(tmpdir(), "chant-discover-both-"));
98
+ try {
99
+ for (const f of ["one.ts", "one.js", "one.d.ts", "two.js"]) writeFileSync(join(both, f), "");
100
+ expect(listRuleFiles(both).sort()).toEqual(["one.ts", "two.js"]);
101
+ } finally {
102
+ rmSync(both, { recursive: true, force: true });
103
+ }
104
+ });
105
+
106
+ test("discoverLintRules loads compiled rules", () => {
107
+ expect(discoverLintRules(dir, import.meta.url).map((r) => r.id)).toEqual(["JS001", "JS002"]);
108
+ });
109
+
110
+ test("discoverPostSynthChecks loads compiled checks", () => {
111
+ expect(discoverPostSynthChecks(dir, import.meta.url).map((c) => c.id)).toEqual(["JSP001"]);
112
+ });
113
+ });
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Auto-discovery for lint rules and post-synth checks.
3
3
  *
4
- * Scans directories for .ts files and loads exported rules/checks,
5
- * eliminating the need to manually list every rule in plugin.ts.
4
+ * Scans directories for rule modules and loads exported rules/checks,
5
+ * eliminating the need to manually list every rule in plugin.ts. A lexicon
6
+ * run from source has `.ts` rule modules; one run compiled has `.js` modules
7
+ * beside `.d.ts` declarations (#3981). Both are found, and tsx is registered
8
+ * only when a module found is TypeScript.
6
9
  */
7
10
 
8
11
  import { readdirSync } from "fs";
@@ -11,6 +14,7 @@ import { createRequire } from "module";
11
14
  import type { LintRule } from "./rule";
12
15
  import type { PostSynthCheck } from "./post-synth";
13
16
  import { ensureTypeScriptLoader } from "../ts-loader";
17
+ import { isTypeScriptModule } from "../module-source";
14
18
 
15
19
  let cjsTypeScript = false;
16
20
 
@@ -22,7 +26,8 @@ let cjsTypeScript = false;
22
26
  * From source this runs when the module loads, as it always has: vitest and
23
27
  * the source CLI rely on tsx's CommonJS hook being in place from then on. The
24
28
  * compiled CLI imports this module on every start and most commands read no
25
- * rule directory, so there it waits for the first one (#3878).
29
+ * rule directory, so there it waits for the first directory that holds a
30
+ * TypeScript rule module (#3878, #3981).
26
31
  */
27
32
  function registerTypeScriptRequire(): void {
28
33
  if (cjsTypeScript) return;
@@ -40,82 +45,83 @@ function registerTypeScriptRequire(): void {
40
45
  if (!import.meta.url.endsWith(".js")) registerTypeScriptRequire();
41
46
 
42
47
  /**
43
- * Discover lint rules from a directory.
44
- *
45
- * Scans the directory for .ts files (excluding tests, helpers, and index files),
46
- * loads each module, and collects any exported objects that look like a LintRule
47
- * (has `id`, `severity`, `category`, and `check` function).
48
- *
49
- * @param dir - Absolute path to the rules directory
50
- * @param importMetaUrl - The caller's import.meta.url (for createRequire)
48
+ * Load every rule module in `dir` and collect the exports `accept` takes.
49
+ * Registers tsx first only when one of the modules is TypeScript.
51
50
  */
52
- export function discoverLintRules(dir: string, importMetaUrl: string): LintRule[] {
53
- registerTypeScriptRequire();
51
+ function discoverExports<T>(dir: string, importMetaUrl: string, accept: (value: unknown) => value is T): T[] {
52
+ const files = listRuleFiles(dir);
53
+ if (files.some(isTypeScriptModule)) registerTypeScriptRequire();
54
54
  const require = createRequire(importMetaUrl);
55
- const rules: LintRule[] = [];
56
-
57
- for (const file of listRuleFiles(dir)) {
55
+ const found: T[] = [];
56
+ for (const file of files) {
58
57
  try {
59
- // Strip .ts extension — require() resolves without it
60
- const modPath = join(dir, file.replace(/\.ts$/, ""));
61
- const mod = require(modPath);
58
+ const mod = require(join(dir, file)) as Record<string, unknown>;
62
59
  for (const exp of Object.values(mod)) {
63
- if (isLintRule(exp)) rules.push(exp);
60
+ if (accept(exp)) found.push(exp);
64
61
  }
65
62
  } catch {
66
63
  // Skip files that fail to load
67
64
  }
68
65
  }
66
+ return found;
67
+ }
69
68
 
70
- return rules.sort((a, b) => a.id.localeCompare(b.id));
69
+ /**
70
+ * Discover lint rules from a directory.
71
+ *
72
+ * Scans the directory for rule modules (see {@link listRuleFiles}), loads
73
+ * each one, and collects any exported objects that look like a LintRule (has
74
+ * `id`, `severity`, `category`, and `check` function).
75
+ *
76
+ * @param dir - Absolute path to the rules directory
77
+ * @param importMetaUrl - The caller's import.meta.url (for createRequire)
78
+ */
79
+ export function discoverLintRules(dir: string, importMetaUrl: string): LintRule[] {
80
+ return discoverExports(dir, importMetaUrl, isLintRule).sort((a, b) => a.id.localeCompare(b.id));
71
81
  }
72
82
 
73
83
  /**
74
84
  * Discover post-synth checks from a directory.
75
85
  *
76
- * Scans the directory for .ts files (excluding tests, helpers, and index files),
77
- * loads each module, and collects any exported objects that look like a PostSynthCheck
78
- * (has `id`, `description`, and `check` function).
86
+ * Scans the directory for rule modules (see {@link listRuleFiles}), loads
87
+ * each one, and collects any exported objects that look like a
88
+ * PostSynthCheck (has `id`, `description`, and `check` function).
79
89
  *
80
90
  * @param dir - Absolute path to the post-synth rules directory
81
91
  * @param importMetaUrl - The caller's import.meta.url (for createRequire)
82
92
  */
83
93
  export function discoverPostSynthChecks(dir: string, importMetaUrl: string): PostSynthCheck[] {
84
- registerTypeScriptRequire();
85
- const require = createRequire(importMetaUrl);
86
- const checks: PostSynthCheck[] = [];
87
-
88
- for (const file of listRuleFiles(dir)) {
89
- try {
90
- // Strip .ts extension — require() resolves without it
91
- const modPath = join(dir, file.replace(/\.ts$/, ""));
92
- const mod = require(modPath);
93
- for (const exp of Object.values(mod)) {
94
- if (isPostSynthCheck(exp)) checks.push(exp);
95
- }
96
- } catch {
97
- // Skip files that fail to load
98
- }
99
- }
94
+ return discoverExports(dir, importMetaUrl, isPostSynthCheck).sort((a, b) => a.id.localeCompare(b.id));
95
+ }
100
96
 
101
- return checks.sort((a, b) => a.id.localeCompare(b.id));
97
+ /** A rule module's name without its extension: `foo` for `foo.ts` and `foo.js`. */
98
+ export function ruleModuleBase(file: string): string {
99
+ return file.replace(/\.(?:ts|js)$/, "");
102
100
  }
103
101
 
104
102
  /**
105
- * List rule files in a directory (excluding tests, helpers, index).
103
+ * List rule modules in a directory: `.ts` source or compiled `.js`, leaving
104
+ * out declarations (`.d.ts`), tests, helpers and the index. A directory
105
+ * holding both `foo.ts` and `foo.js` lists `foo.ts` alone, so a rule is
106
+ * never loaded twice.
106
107
  */
107
108
  export function listRuleFiles(dir: string): string[] {
109
+ let entries: string[];
108
110
  try {
109
- return readdirSync(dir).filter((f) => {
110
- if (!f.endsWith(".ts")) return false;
111
- if (f.endsWith(".test.ts")) return false;
112
- if (f === "index.ts") return false;
113
- if (f.includes("helper")) return false;
114
- return true;
115
- });
111
+ entries = readdirSync(dir);
116
112
  } catch {
117
113
  return [];
118
114
  }
115
+ const modules = entries.filter((f) => {
116
+ if (!f.endsWith(".ts") && !f.endsWith(".js")) return false;
117
+ if (f.endsWith(".d.ts")) return false;
118
+ if (/\.test\.[tj]s$/.test(f)) return false;
119
+ if (f === "index.ts" || f === "index.js") return false;
120
+ if (f.includes("helper")) return false;
121
+ return true;
122
+ });
123
+ const sources = new Set(modules.filter((f) => f.endsWith(".ts")).map(ruleModuleBase));
124
+ return modules.filter((f) => f.endsWith(".ts") || !sources.has(ruleModuleBase(f)));
119
125
  }
120
126
 
121
127
  function isLintRule(obj: unknown): obj is LintRule {
@@ -0,0 +1,85 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join, relative, resolve, sep } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { describe, expect, test } from "vitest";
5
+ import { isJavaScriptModule, isTypeScriptModule, moduleSourceDir, moduleSourceUrl, SOURCE_EXPORT_CONDITIONS } from "./module-source";
6
+
7
+ describe("module-source (#3981)", () => {
8
+ test("from source, chant's own resolution adds the development condition", () => {
9
+ expect(SOURCE_EXPORT_CONDITIONS).toEqual(["development"]);
10
+ });
11
+
12
+ const pkg = join(sep, "x", "node_modules", "@intentius", "chant-lexicon-aws");
13
+
14
+ test("moduleSourceDir maps a compiled module's directory to the source directory", () => {
15
+ expect(moduleSourceDir(pathToFileURL(join(pkg, "dist", "lint", "post-synth", "ext001.js")).href)).toBe(join(pkg, "src", "lint", "post-synth"));
16
+ expect(moduleSourceDir(pathToFileURL(join(pkg, "dist", "taggable.js")).href)).toBe(join(pkg, "src"));
17
+ });
18
+
19
+ test("moduleSourceDir leaves a source module's directory alone, even under a dist/ directory", () => {
20
+ expect(moduleSourceDir(pathToFileURL(join(pkg, "src", "taggable.ts")).href)).toBe(join(pkg, "src"));
21
+ const checkout = join(sep, "home", "dist", "chant", "lexicons", "aws", "src", "taggable.ts");
22
+ expect(moduleSourceDir(pathToFileURL(checkout).href)).toBe(join(sep, "home", "dist", "chant", "lexicons", "aws", "src"));
23
+ });
24
+
25
+ test("moduleSourceUrl is a URL in the source directory, for createRequire", () => {
26
+ const url = moduleSourceUrl(pathToFileURL(join(pkg, "dist", "lsp", "hover.js")).href);
27
+ expect(url).toBe(pathToFileURL(join(pkg, "src", "lsp", "hover.js")).href);
28
+ expect(new URL("../generated/lexicon-aws.json", url).href).toBe(pathToFileURL(join(pkg, "src", "generated", "lexicon-aws.json")).href);
29
+ });
30
+
31
+ test("isTypeScriptModule and isJavaScriptModule", () => {
32
+ expect(isTypeScriptModule("/p/src/index.ts")).toBe(true);
33
+ expect(isTypeScriptModule("file:///p/src/index.mts?x=1")).toBe(true);
34
+ expect(isTypeScriptModule("/p/dist/index.d.ts")).toBe(false);
35
+ expect(isTypeScriptModule("/p/dist/index.js")).toBe(false);
36
+ expect(isJavaScriptModule("file:///p/dist/index.js")).toBe(true);
37
+ expect(isJavaScriptModule("/p/dist/index.mjs")).toBe(true);
38
+ expect(isJavaScriptModule("/p/src/index.ts")).toBe(false);
39
+ // No extension: only tsx's resolver completes it, so it is not plain JavaScript.
40
+ expect(isJavaScriptModule("/p/lexicon/index")).toBe(false);
41
+ });
42
+ });
43
+
44
+ describe("package sources run under plain node ESM (#3981)", () => {
45
+ const repo = resolve(import.meta.dirname, "..", "..", "..");
46
+ const SKIP_DIRS = new Set(["node_modules", "generated", "testdata", "fixtures", "__fixtures__", "test-fixtures"]);
47
+
48
+ function sources(dir: string, out: string[] = []): string[] {
49
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
50
+ if (entry.isDirectory()) {
51
+ if (!SKIP_DIRS.has(entry.name)) sources(join(dir, entry.name), out);
52
+ } else if (/\.ts$/.test(entry.name) && !/\.(test|d)\.ts$/.test(entry.name)) {
53
+ out.push(join(dir, entry.name));
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ test("no lexicon or package source uses the CommonJS __dirname or __filename", () => {
60
+ // Both are undefined in an ES module under plain node, and under tsx too: a
61
+ // read built on one throws, and the catch around it hides the data.
62
+ // moduleSourceDir(import.meta.url) names the source directory from src/ and dist/ alike.
63
+ const roots = ["lexicons", "packages"].flatMap((top) =>
64
+ readdirSync(join(repo, top), { withFileTypes: true })
65
+ .filter((e) => e.isDirectory())
66
+ .map((e) => join(repo, top, e.name, "src")),
67
+ );
68
+ const offenders: string[] = [];
69
+ for (const root of roots) {
70
+ let files: string[];
71
+ try {
72
+ files = sources(root);
73
+ } catch {
74
+ continue;
75
+ }
76
+ for (const file of files) {
77
+ const code = readFileSync(file, "utf-8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
78
+ code.split("\n").forEach((line, i) => {
79
+ if (/\b__(?:dirname|filename)\b/.test(line)) offenders.push(`${relative(repo, file)}:${i + 1}: ${line.trim()}`);
80
+ });
81
+ }
82
+ }
83
+ expect(offenders).toEqual([]);
84
+ });
85
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * #3981 — paths for a module that may run from `src/*.ts` or from compiled
3
+ * `dist/*.js`.
4
+ *
5
+ * A package compiles `src/` to `dist/` with the same layout (`rootDir: src`,
6
+ * `outDir: dist`), so a compiled module sits at the same relative path under
7
+ * `dist/` as its source does under `src/`. tsc compiles only the modules: a
8
+ * file a module reads beside itself (generated JSON, skills markdown,
9
+ * snapshots) stays under `src/`, which every package publishes. A module that
10
+ * reads such a file names it from {@link moduleSourceDir}, not from its own
11
+ * directory, and finds it from either layout.
12
+ *
13
+ * String arithmetic only, no filesystem access, so it is safe in a sandboxed
14
+ * child and costs nothing at module scope.
15
+ */
16
+
17
+ import { dirname, sep } from "node:path";
18
+ import { fileURLToPath, pathToFileURL } from "node:url";
19
+
20
+ const DIST = `${sep}dist${sep}`;
21
+
22
+ /**
23
+ * The path a module's source has: `path` itself for a source module, and the
24
+ * matching path under `<package>/src` for a module under `<package>/dist`.
25
+ */
26
+ function sourcePathOf(path: string): string {
27
+ if (!/\.[cm]?js$/.test(path)) return path;
28
+ const at = path.lastIndexOf(DIST);
29
+ if (at < 0) return path;
30
+ return path.slice(0, at) + `${sep}src${sep}` + path.slice(at + DIST.length);
31
+ }
32
+
33
+ /**
34
+ * #3981 — the source file a compiled module was built from:
35
+ * `<package>/dist/x/y.js` is `<package>/src/x/y.ts` (`.mjs` from `.mts`, `.cjs`
36
+ * from `.cts`). `undefined` for a path that is not a compiled module under a
37
+ * `dist/`. The file is not checked for.
38
+ */
39
+ export function compiledModuleSource(path: string): string | undefined {
40
+ const ext = /\.([cm]?)js$/.exec(path);
41
+ if (!ext || path.lastIndexOf(DIST) < 0) return undefined;
42
+ return sourcePathOf(path).slice(0, -ext[0].length) + `.${ext[1]}ts`;
43
+ }
44
+
45
+ /**
46
+ * The directory a module's source sits in: `dirname` of `moduleUrl` when it
47
+ * runs from source, and the matching directory under `<package>/src` when it
48
+ * runs compiled from `<package>/dist`.
49
+ */
50
+ export function moduleSourceDir(moduleUrl: string): string {
51
+ return dirname(sourcePathOf(fileURLToPath(moduleUrl)));
52
+ }
53
+
54
+ /**
55
+ * A URL in the module's source directory, for `createRequire` to read a file
56
+ * that sits beside the source (`require("./generated/lexicon-aws.json")`). The
57
+ * file name keeps the compiled extension; only the directory matters to
58
+ * `createRequire`.
59
+ */
60
+ export function moduleSourceUrl(moduleUrl: string): string {
61
+ return pathToFileURL(sourcePathOf(fileURLToPath(moduleUrl))).href;
62
+ }
63
+
64
+ /**
65
+ * Whether `pathOrUrl` names a TypeScript module, which plain node cannot
66
+ * import without a loader: `.ts`, `.mts`, `.cts` or `.tsx`, and not a
67
+ * declaration file. A query or hash is ignored.
68
+ */
69
+ export function isTypeScriptModule(pathOrUrl: string): boolean {
70
+ const path = pathOrUrl.replace(/[?#].*$/, "");
71
+ return /\.(?:[cm]?ts|tsx)$/.test(path) && !/\.d\.[cm]?ts$/.test(path);
72
+ }
73
+
74
+ /**
75
+ * Whether plain node imports `pathOrUrl` with no loader: `.js`, `.mjs`,
76
+ * `.cjs` or `.json`. Anything else, a module with no extension included, is
77
+ * left to tsx, whose resolver completes an extensionless path.
78
+ */
79
+ export function isJavaScriptModule(pathOrUrl: string): boolean {
80
+ return /\.(?:[cm]?js|json)$/.test(pathOrUrl.replace(/[?#].*$/, ""));
81
+ }
82
+
83
+ /**
84
+ * #3981 — whether this process resolves chant's packages through their
85
+ * `development` export condition: always when chant runs from source (the
86
+ * CLI's `main()` installs a resolve hook that does so, and vitest's resolver
87
+ * adds the condition outside production mode), and otherwise when node runs
88
+ * with `--conditions=development` (or `-C development`, on the command line
89
+ * or in `NODE_OPTIONS`). Not from the environment: a compiled CLI that a test
90
+ * spawns inherits vitest's variables.
91
+ */
92
+ function resolvesDevelopment(): boolean {
93
+ if (isTypeScriptModule(import.meta.url)) return true;
94
+ if (typeof process === "undefined") return false;
95
+ const flags = [...process.execArgv, ...(process.env.NODE_OPTIONS ?? "").split(/\s+/)];
96
+ return flags.some(
97
+ (flag, i) =>
98
+ flag === "--conditions=development" ||
99
+ flag === "-C=development" ||
100
+ ((flag === "--conditions" || flag === "-C") && flags[i + 1] === "development"),
101
+ );
102
+ }
103
+
104
+ /**
105
+ * #3981 — the package export conditions chant adds to `import`, `node` and
106
+ * `default` when it resolves a package itself (fold's import resolution, the
107
+ * sandbox bundle), so it lands on the module this process's `import()` would.
108
+ * Each chant package maps `development` to `src/*.ts` and `default` to the
109
+ * compiled `dist/*.js` it publishes. `["development"]` when the process
110
+ * resolves with it ({@link resolvesDevelopment}), else empty.
111
+ */
112
+ export const SOURCE_EXPORT_CONDITIONS: readonly string[] = resolvesDevelopment() ? ["development"] : [];
@@ -357,7 +357,7 @@ function inWorktree(kindFile: string, checkout: string, worktree: string): strin
357
357
  }
358
358
  };
359
359
  const [file, top, tree] = [real(kindFile), real(checkout), real(worktree)];
360
- // Read from the worktree already (it sits under the checkout's .git), or outside the checkout: as it is.
360
+ // Read from the worktree already (it sits in the checkout's .chant/work, #2893), or outside the checkout: as it is.
361
361
  if (file.startsWith(`${tree}/`) || !file.startsWith(`${top}/`)) return file;
362
362
  return join(tree, file.slice(top.length + 1));
363
363
  }
@@ -15,13 +15,15 @@ import { discoverOps } from "./discover";
15
15
  * with whatever the current test set (`realpath`ed, since macOS's tmpdir is a
16
16
  * symlink and the walk compares resolved paths).
17
17
  */
18
- let fakeGitRoot = "";
18
+ let fakeGitRoot: string | undefined = "";
19
19
  vi.mock("../runtime-adapter", async (importOriginal) => ({
20
20
  ...(await importOriginal<typeof import("../runtime-adapter")>()),
21
21
  getRuntime: () => ({
22
22
  spawn: async (cmd: string[]) =>
23
23
  cmd[0] === "git" && cmd[1] === "rev-parse"
24
- ? { stdout: fakeGitRoot, stderr: "", exitCode: 0 }
24
+ ? fakeGitRoot === undefined
25
+ ? { stdout: "", stderr: "fatal: not a git repository", exitCode: 128 }
26
+ : { stdout: fakeGitRoot, stderr: "", exitCode: 0 }
25
27
  : { stdout: "", stderr: "", exitCode: 0 },
26
28
  }),
27
29
  }));
@@ -68,6 +70,33 @@ describe("discoverOps — the scan root is the project, not the checkout (#2058)
68
70
  });
69
71
  });
70
72
 
73
+ describe("discoverOps outside a git repository (#2504)", () => {
74
+ let dir: string;
75
+ beforeEach(() => {
76
+ dir = realpathSync(mkdtempSync(join(tmpdir(), "chant-op-nogit-")));
77
+ fakeGitRoot = undefined;
78
+ mkdirSync(join(dir, "demo", "ops"), { recursive: true });
79
+ writeFileSync(join(dir, "demo", "ops", "apply.op.ts"), OP_FILE("k3d-apply"));
80
+ });
81
+ afterEach(() => {
82
+ fakeGitRoot = "";
83
+ rmSync(dir, { recursive: true, force: true });
84
+ });
85
+
86
+ test("a chant.config names the project root, so no git root is needed", async () => {
87
+ writeFileSync(join(dir, "demo", "chant.config.json"), JSON.stringify({ lexicons: ["k8s"] }));
88
+ const { ops, errors } = await discoverOps({ cwd: join(dir, "demo", "ops") });
89
+ expect(errors).toEqual([]);
90
+ expect([...ops.keys()]).toEqual(["k3d-apply"]);
91
+ });
92
+
93
+ test("with neither a config nor a git root, discovery says both are missing", async () => {
94
+ await expect(discoverOps({ cwd: join(dir, "demo") })).rejects.toThrow(
95
+ /Not in a git repository, and no chant\.config/,
96
+ );
97
+ });
98
+ });
99
+
71
100
  /**
72
101
  * #2171. An Op may be exported by name, not only as the file's default.
73
102
  *
@@ -25,10 +25,11 @@ export interface OpDiscoveryResult {
25
25
  errors: string[];
26
26
  }
27
27
 
28
- async function findGitRoot(cwd?: string): Promise<string> {
28
+ /** The git root `cwd` is inside, or undefined when it is in no repository. */
29
+ async function findGitRoot(cwd?: string): Promise<string | undefined> {
29
30
  const rt = getRuntime();
30
31
  const result = await rt.spawn(["git", "rev-parse", "--show-toplevel"], { cwd });
31
- if (result.exitCode !== 0) throw new Error("Not in a git repository");
32
+ if (result.exitCode !== 0) return undefined;
32
33
  return result.stdout.trim();
33
34
  }
34
35
 
@@ -43,17 +44,26 @@ async function findGitRoot(cwd?: string): Promise<string> {
43
44
  * file is the project boundary, the same one entity discovery respects.
44
45
  * With no config anywhere up to the git root, the git root stands —
45
46
  * #1675's original scope, kept for configless layouts.
47
+ *
48
+ * Outside any git repository the walk goes to the filesystem root, and the
49
+ * config is all discovery needs (#2504): a demo copy, an unpacked tarball or
50
+ * a fresh `chant init` before `git init`. Only a directory with neither a
51
+ * config above it nor a git root has no Op root, and that is the error.
46
52
  */
47
53
  async function findDiscoveryRoot(cwd?: string): Promise<string> {
48
- const gitRoot = resolve(await findGitRoot(cwd));
54
+ const found = await findGitRoot(cwd);
55
+ const gitRoot = found === undefined ? undefined : resolve(found);
49
56
  let dir = resolve(cwd ?? process.cwd());
50
57
  for (;;) {
51
58
  if (existsSync(join(dir, "chant.config.ts")) || existsSync(join(dir, "chant.config.json"))) return dir;
52
59
  if (dir === gitRoot) break;
53
60
  const parent = dirname(dir);
54
- if (parent === dir) break; // filesystem root — cwd was outside the git root
61
+ if (parent === dir) break; // filesystem root — cwd was outside the git root, or there is none
55
62
  dir = parent;
56
63
  }
64
+ if (gitRoot === undefined) {
65
+ throw new Error("Not in a git repository, and no chant.config.ts or chant.config.json above the current directory");
66
+ }
57
67
  return gitRoot;
58
68
  }
59
69
 
@@ -613,6 +613,36 @@ describe("op/gate — a rejected ledger push is reported, not swallowed (#2310)"
613
613
  });
614
614
  });
615
615
 
616
+ describe("op/gate — an applied record is written on the remote's ledger (#3985)", () => {
617
+ test("appendApplied fetches first, so a remote another writer moved still takes the push", async () => {
618
+ const { remote, author } = await clonePair();
619
+ const other = tmp("other");
620
+ git(["clone", "-q", remote, other], tmpdir());
621
+ git(["config", "user.email", "other@chant.dev"], other);
622
+ git(["config", "user.name", "Other"], other);
623
+
624
+ const port = gitGateLedgerPort({ cwd: author });
625
+ const pending = { op: "migrations", gate: "migrations-wave-1", timestamp: "2026-10-10T00:00:00.000Z", expiresAt: "2026-10-11T00:00:00.000Z" };
626
+ expect((await port.appendPending(pending)).pushed).toBe(true);
627
+
628
+ // Another writer moves the remote past what `author` holds.
629
+ git(["fetch", "-q", "origin", "chant/lifecycle:chant/lifecycle"], other);
630
+ expect((await gitGateLedgerPort({ cwd: other }).appendPending({ ...pending, gate: "migrations-wave-2" })).pushed).toBe(true);
631
+
632
+ const line = {
633
+ version: 1 as const, kind: "applied" as const, op: "migrations", gate: "migrations-wave-1", planDigest: "sha256:aa",
634
+ approvedAt: "2026-10-10T01:00:00.000Z", approvedBy: "alice", timestamp: "2026-10-10T02:00:00.000Z",
635
+ };
636
+ expect(await port.appendApplied!(line)).toEqual({ pushed: true });
637
+
638
+ // The remote holds the applied line and the other writer's pending fact.
639
+ const reader = ciCheckout(remote);
640
+ const readerPort = gitGateLedgerPort({ cwd: reader });
641
+ expect(await readerPort.readApplied!("migrations")).toEqual([line]);
642
+ expect((await readerPort.read("migrations")).pending.map((p) => p.gate)).toEqual(["migrations-wave-1", "migrations-wave-2"]);
643
+ });
644
+ });
645
+
616
646
  describe("op/gate — the approve hint on a sealed gate (#3521)", () => {
617
647
  test("approveCommand adds --sign last, only when sealed", () => {
618
648
  expect(approveCommand("app", "ship")).toBe("chant approve app ship");
package/src/op/gate.ts CHANGED
@@ -176,6 +176,9 @@ export function gitGateLedgerPort(opts?: { cwd?: string }): GateLedgerPort {
176
176
  async appendApplied(record) {
177
177
  const { dir, file } = appliedGatePath(record.op);
178
178
  const line = JSON.stringify(record, sortedJsonReplacer);
179
+ // #3985: write on top of the remote's ledger, not a local copy another
180
+ // writer has since moved past, so the push that follows can land.
181
+ await requireLifecycleLedger(opts);
179
182
  let lastErr: unknown;
180
183
  // The same append-and-retry as a ledger line: another writer to the branch is the ordinary case.
181
184
  for (let attempt = 1; attempt <= 5; attempt++) {