@hominis/fireforge 0.39.0 → 0.41.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 (279) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +35 -5
  3. package/dist/bin/fireforge.js +25 -4
  4. package/dist/build-info.json +9 -0
  5. package/dist/src/cli.js +27 -3
  6. package/dist/src/commands/bootstrap.js +5 -1
  7. package/dist/src/commands/discard.js +2 -22
  8. package/dist/src/commands/doctor/post-rebase-audit.js +27 -4
  9. package/dist/src/commands/doctor-check-core.d.ts +20 -0
  10. package/dist/src/commands/doctor-check-core.js +28 -0
  11. package/dist/src/commands/doctor-external-toolchains.js +2 -0
  12. package/dist/src/commands/doctor-furnace-manifest-sync.js +144 -46
  13. package/dist/src/commands/doctor-furnace.js +13 -11
  14. package/dist/src/commands/doctor-orphaned-harness.js +2 -1
  15. package/dist/src/commands/doctor.js +2 -2
  16. package/dist/src/commands/export-all.js +9 -1
  17. package/dist/src/commands/export-flow.js +27 -9
  18. package/dist/src/commands/export-placement-conflicts.d.ts +17 -0
  19. package/dist/src/commands/export-placement-conflicts.js +74 -0
  20. package/dist/src/commands/export-placement-gate.js +1 -1
  21. package/dist/src/commands/export-shared.d.ts +8 -1
  22. package/dist/src/commands/export-shared.js +30 -10
  23. package/dist/src/commands/export.js +2 -2
  24. package/dist/src/commands/furnace/apply.js +4 -3
  25. package/dist/src/commands/furnace/chrome-doc-remove.js +13 -2
  26. package/dist/src/commands/furnace/chrome-doc.js +13 -2
  27. package/dist/src/commands/furnace/create-browser-test.d.ts +5 -0
  28. package/dist/src/commands/furnace/create-browser-test.js +5 -0
  29. package/dist/src/commands/furnace/create.js +2 -2
  30. package/dist/src/commands/furnace/diff.js +2 -1
  31. package/dist/src/commands/furnace/init.js +4 -6
  32. package/dist/src/commands/furnace/override.js +2 -2
  33. package/dist/src/commands/furnace/remove-mutations.d.ts +40 -0
  34. package/dist/src/commands/furnace/remove-mutations.js +171 -0
  35. package/dist/src/commands/furnace/remove.js +21 -128
  36. package/dist/src/commands/furnace/rename-browser-test.js +54 -5
  37. package/dist/src/commands/furnace/scan.js +27 -36
  38. package/dist/src/commands/furnace/status.js +30 -28
  39. package/dist/src/commands/furnace/validate.js +25 -18
  40. package/dist/src/commands/import.js +6 -20
  41. package/dist/src/commands/lint-per-patch.js +73 -51
  42. package/dist/src/commands/lint-per-run-checkjs.d.ts +64 -0
  43. package/dist/src/commands/lint-per-run-checkjs.js +102 -0
  44. package/dist/src/commands/lint-report.d.ts +21 -0
  45. package/dist/src/commands/lint-report.js +62 -0
  46. package/dist/src/commands/lint.js +26 -22
  47. package/dist/src/commands/manifest.js +3 -1
  48. package/dist/src/commands/package.js +9 -16
  49. package/dist/src/commands/patch/compact.js +5 -5
  50. package/dist/src/commands/patch/delete.js +5 -5
  51. package/dist/src/commands/patch/lint-ignore.js +3 -2
  52. package/dist/src/commands/patch/move-files-create.js +22 -4
  53. package/dist/src/commands/patch/move-files-into.js +14 -10
  54. package/dist/src/commands/patch/move-files.js +6 -5
  55. package/dist/src/commands/patch/rename.d.ts +2 -2
  56. package/dist/src/commands/patch/rename.js +152 -51
  57. package/dist/src/commands/patch/reorder.d.ts +9 -0
  58. package/dist/src/commands/patch/reorder.js +14 -10
  59. package/dist/src/commands/patch/split-plan.d.ts +13 -6
  60. package/dist/src/commands/patch/split-plan.js +22 -12
  61. package/dist/src/commands/patch/split.d.ts +1 -1
  62. package/dist/src/commands/patch/split.js +23 -9
  63. package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
  64. package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
  65. package/dist/src/commands/patch/staged-dependency.js +112 -14
  66. package/dist/src/commands/re-export-adjacent.d.ts +34 -0
  67. package/dist/src/commands/re-export-adjacent.js +105 -0
  68. package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
  69. package/dist/src/commands/re-export-bulk-scan.js +142 -2
  70. package/dist/src/commands/re-export-drift.d.ts +74 -0
  71. package/dist/src/commands/re-export-drift.js +234 -0
  72. package/dist/src/commands/re-export-files.js +5 -3
  73. package/dist/src/commands/re-export-lint.d.ts +97 -0
  74. package/dist/src/commands/re-export-lint.js +210 -0
  75. package/dist/src/commands/re-export-options.js +11 -0
  76. package/dist/src/commands/re-export-register.d.ts +18 -0
  77. package/dist/src/commands/re-export-register.js +61 -0
  78. package/dist/src/commands/re-export-scan.js +13 -7
  79. package/dist/src/commands/re-export-single.d.ts +20 -0
  80. package/dist/src/commands/re-export-single.js +261 -0
  81. package/dist/src/commands/re-export.d.ts +0 -4
  82. package/dist/src/commands/re-export.js +123 -289
  83. package/dist/src/commands/rebase/abort.js +13 -4
  84. package/dist/src/commands/rebase/continue.js +11 -4
  85. package/dist/src/commands/rebase/index.js +19 -29
  86. package/dist/src/commands/rebase/patch-loop.js +12 -0
  87. package/dist/src/commands/reset.js +4 -23
  88. package/dist/src/commands/resolve.js +2 -9
  89. package/dist/src/commands/run.js +29 -23
  90. package/dist/src/commands/source.js +3 -9
  91. package/dist/src/commands/status-check.d.ts +48 -0
  92. package/dist/src/commands/status-check.js +106 -0
  93. package/dist/src/commands/status-json.d.ts +31 -0
  94. package/dist/src/commands/status-json.js +76 -0
  95. package/dist/src/commands/status-output.d.ts +1 -0
  96. package/dist/src/commands/status-output.js +10 -1
  97. package/dist/src/commands/status-ownership.d.ts +52 -0
  98. package/dist/src/commands/status-ownership.js +57 -0
  99. package/dist/src/commands/status.js +190 -170
  100. package/dist/src/commands/test-build-phase.d.ts +19 -0
  101. package/dist/src/commands/test-build-phase.js +168 -0
  102. package/dist/src/commands/test-diagnose.js +30 -2
  103. package/dist/src/commands/test-modes.d.ts +9 -1
  104. package/dist/src/commands/test-modes.js +46 -13
  105. package/dist/src/commands/test-register.js +59 -27
  106. package/dist/src/commands/test-run.d.ts +29 -3
  107. package/dist/src/commands/test-run.js +59 -7
  108. package/dist/src/commands/test-verdict.d.ts +32 -0
  109. package/dist/src/commands/test-verdict.js +48 -0
  110. package/dist/src/commands/test.d.ts +9 -0
  111. package/dist/src/commands/test.js +100 -123
  112. package/dist/src/commands/token-coverage.js +4 -9
  113. package/dist/src/commands/token.js +4 -1
  114. package/dist/src/commands/tree.d.ts +19 -0
  115. package/dist/src/commands/tree.js +320 -0
  116. package/dist/src/commands/verify.js +2 -2
  117. package/dist/src/commands/watch.js +22 -21
  118. package/dist/src/commands/wire.js +1 -1
  119. package/dist/src/core/ast-utils.d.ts +11 -0
  120. package/dist/src/core/ast-utils.js +11 -0
  121. package/dist/src/core/build-audit-platform.js +6 -1
  122. package/dist/src/core/build-audit-registration.js +2 -0
  123. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  124. package/dist/src/core/build-audit-resolve.js +34 -13
  125. package/dist/src/core/build-audit.js +4 -20
  126. package/dist/src/core/build-baseline-types.d.ts +29 -8
  127. package/dist/src/core/build-baseline-types.js +2 -1
  128. package/dist/src/core/build-baseline.d.ts +10 -2
  129. package/dist/src/core/build-baseline.js +39 -8
  130. package/dist/src/core/build-prepare.d.ts +4 -0
  131. package/dist/src/core/build-prepare.js +7 -1
  132. package/dist/src/core/config-paths.d.ts +1 -1
  133. package/dist/src/core/config-paths.js +2 -0
  134. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  135. package/dist/src/core/config-validate.js +45 -30
  136. package/dist/src/core/config.js +16 -3
  137. package/dist/src/core/coverage-extend.d.ts +89 -0
  138. package/dist/src/core/coverage-extend.js +188 -0
  139. package/dist/src/core/destructive.js +3 -1
  140. package/dist/src/core/discard-baseline.js +18 -4
  141. package/dist/src/core/engine-precondition.d.ts +35 -0
  142. package/dist/src/core/engine-precondition.js +50 -0
  143. package/dist/src/core/engine-session-lock.d.ts +38 -1
  144. package/dist/src/core/engine-session-lock.js +78 -4
  145. package/dist/src/core/file-lock.d.ts +7 -0
  146. package/dist/src/core/file-lock.js +20 -30
  147. package/dist/src/core/firefox.js +5 -2
  148. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  149. package/dist/src/core/furnace-apply-helpers.js +12 -3
  150. package/dist/src/core/furnace-apply-output.js +9 -0
  151. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  152. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  153. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  154. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  155. package/dist/src/core/furnace-apply.d.ts +16 -4
  156. package/dist/src/core/furnace-apply.js +39 -43
  157. package/dist/src/core/furnace-config.js +2 -0
  158. package/dist/src/core/furnace-css-fragments.js +2 -0
  159. package/dist/src/core/furnace-jsconfig.js +14 -7
  160. package/dist/src/core/furnace-registration-ast.js +8 -8
  161. package/dist/src/core/furnace-registration-remove.js +6 -3
  162. package/dist/src/core/furnace-registration.js +9 -12
  163. package/dist/src/core/furnace-rollback.js +1 -1
  164. package/dist/src/core/furnace-scanner.js +2 -1
  165. package/dist/src/core/furnace-stale-export.js +2 -1
  166. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  167. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  168. package/dist/src/core/furnace-validate-helpers.js +33 -1
  169. package/dist/src/core/furnace-validate-registration.js +16 -0
  170. package/dist/src/core/furnace-validate.js +4 -2
  171. package/dist/src/core/git-diff.js +37 -2
  172. package/dist/src/core/lint-cache.d.ts +15 -5
  173. package/dist/src/core/lint-cache.js +49 -14
  174. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  175. package/dist/src/core/mach-build-artifacts.js +108 -2
  176. package/dist/src/core/mach-resource-shim.js +1 -0
  177. package/dist/src/core/marionette-port.d.ts +16 -0
  178. package/dist/src/core/marionette-port.js +93 -8
  179. package/dist/src/core/marionette-preflight.js +6 -5
  180. package/dist/src/core/patch-apply.d.ts +20 -13
  181. package/dist/src/core/patch-apply.js +32 -33
  182. package/dist/src/core/patch-export.d.ts +2 -2
  183. package/dist/src/core/patch-export.js +4 -6
  184. package/dist/src/core/patch-lint-checkjs.d.ts +42 -2
  185. package/dist/src/core/patch-lint-checkjs.js +132 -8
  186. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  187. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  188. package/dist/src/core/patch-lint-creators.js +45 -0
  189. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  190. package/dist/src/core/patch-lint-cross.js +16 -32
  191. package/dist/src/core/patch-lint-css.js +64 -16
  192. package/dist/src/core/patch-lint-diff.d.ts +8 -0
  193. package/dist/src/core/patch-lint-diff.js +31 -0
  194. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  195. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  196. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  197. package/dist/src/core/patch-lint-module-registration.js +88 -0
  198. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  199. package/dist/src/core/patch-lint-ownership.d.ts +21 -0
  200. package/dist/src/core/patch-lint-ownership.js +30 -0
  201. package/dist/src/core/patch-lint-reexports.d.ts +2 -1
  202. package/dist/src/core/patch-lint-reexports.js +1 -0
  203. package/dist/src/core/patch-lint.d.ts +31 -0
  204. package/dist/src/core/patch-lint.js +114 -8
  205. package/dist/src/core/patch-lock.d.ts +12 -1
  206. package/dist/src/core/patch-lock.js +40 -4
  207. package/dist/src/core/patch-manifest-consistency.js +15 -3
  208. package/dist/src/core/patch-manifest.d.ts +1 -1
  209. package/dist/src/core/patch-parse.d.ts +8 -0
  210. package/dist/src/core/patch-parse.js +10 -0
  211. package/dist/src/core/patch-policy.d.ts +7 -0
  212. package/dist/src/core/patch-policy.js +4 -1
  213. package/dist/src/core/patch-transform.d.ts +10 -0
  214. package/dist/src/core/patch-transform.js +13 -2
  215. package/dist/src/core/rebase-session.d.ts +44 -2
  216. package/dist/src/core/rebase-session.js +114 -17
  217. package/dist/src/core/register-browser-content.js +2 -1
  218. package/dist/src/core/register-shared-css.js +2 -1
  219. package/dist/src/core/smoke-patterns.d.ts +24 -16
  220. package/dist/src/core/smoke-patterns.js +26 -14
  221. package/dist/src/core/status-binary.d.ts +55 -0
  222. package/dist/src/core/status-binary.js +124 -0
  223. package/dist/src/core/status-classify.d.ts +22 -1
  224. package/dist/src/core/status-classify.js +112 -70
  225. package/dist/src/core/test-harness-crash.d.ts +32 -1
  226. package/dist/src/core/test-harness-crash.js +59 -4
  227. package/dist/src/core/test-path-scope.js +2 -0
  228. package/dist/src/core/test-stale-check.js +1 -20
  229. package/dist/src/core/token-category.d.ts +40 -0
  230. package/dist/src/core/token-category.js +264 -0
  231. package/dist/src/core/token-manager.d.ts +7 -0
  232. package/dist/src/core/token-manager.js +48 -196
  233. package/dist/src/core/toolchain-preflight.js +24 -26
  234. package/dist/src/core/tree-cow.d.ts +18 -0
  235. package/dist/src/core/tree-cow.js +71 -0
  236. package/dist/src/core/tree-guard.d.ts +35 -0
  237. package/dist/src/core/tree-guard.js +179 -0
  238. package/dist/src/core/tree-store.d.ts +129 -0
  239. package/dist/src/core/tree-store.js +452 -0
  240. package/dist/src/core/typecheck-shim.d.ts +14 -0
  241. package/dist/src/core/typecheck-shim.js +41 -2
  242. package/dist/src/core/typecheck.js +2 -1
  243. package/dist/src/core/wire-destroy.js +14 -3
  244. package/dist/src/core/wire-init.js +37 -40
  245. package/dist/src/core/wire-subscript.js +11 -3
  246. package/dist/src/core/wire-utils.js +4 -4
  247. package/dist/src/core/xpcshell-appdir.js +3 -0
  248. package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
  249. package/dist/src/core/xpcshell-profile-dir.js +45 -0
  250. package/dist/src/errors/base.d.ts +13 -0
  251. package/dist/src/errors/base.js +13 -0
  252. package/dist/src/errors/git.d.ts +8 -0
  253. package/dist/src/errors/git.js +17 -0
  254. package/dist/src/errors/rebase.d.ts +13 -0
  255. package/dist/src/errors/rebase.js +17 -0
  256. package/dist/src/index.d.ts +2 -0
  257. package/dist/src/index.js +1 -0
  258. package/dist/src/types/commands/options.d.ts +119 -3
  259. package/dist/src/types/commands/patches.d.ts +8 -0
  260. package/dist/src/types/config.d.ts +19 -0
  261. package/dist/src/types/furnace.d.ts +7 -0
  262. package/dist/src/utils/build-info.d.ts +31 -0
  263. package/dist/src/utils/build-info.js +98 -0
  264. package/dist/src/utils/concurrency.d.ts +8 -0
  265. package/dist/src/utils/concurrency.js +21 -0
  266. package/dist/src/utils/errors.d.ts +39 -0
  267. package/dist/src/utils/errors.js +55 -0
  268. package/dist/src/utils/fs.js +5 -12
  269. package/dist/src/utils/logger.d.ts +14 -2
  270. package/dist/src/utils/logger.js +43 -13
  271. package/dist/src/utils/options.d.ts +21 -4
  272. package/dist/src/utils/options.js +26 -6
  273. package/dist/src/utils/package-root.js +7 -4
  274. package/dist/src/utils/process-group.js +13 -2
  275. package/dist/src/utils/stdio-drain.d.ts +28 -0
  276. package/dist/src/utils/stdio-drain.js +54 -0
  277. package/dist/src/utils/validation.d.ts +33 -2
  278. package/dist/src/utils/validation.js +57 -2
  279. package/package.json +13 -13
@@ -0,0 +1,31 @@
1
+ /** Source identity of the running build. */
2
+ export interface BuildIdentity {
3
+ shortCommit: string;
4
+ dirty: boolean;
5
+ /**
6
+ * Content hash of the uncommitted diff, present only for a DIRTY
7
+ * installed build (FORGE L8). Two packs from the same HEAD with
8
+ * different uncommitted content share `shortCommit` and the `.dirty`
9
+ * marker, so without this they report byte-identical `--version`
10
+ * strings — which is what made a field report against
11
+ * `0.41.0+g<sha>.dirty` impossible to trace back to a specific pack.
12
+ * Absent in a git checkout: identity there comes from live git, and
13
+ * hashing the full diff on every `--version` call is not worth it.
14
+ */
15
+ dirtyHash?: string;
16
+ }
17
+ /** @internal Reads the stamped dist/build-info.json; null when absent/invalid. */
18
+ export declare function readBuildInfoFile(packageRoot: string): BuildIdentity | null;
19
+ type GitRunner = (args: string[]) => string;
20
+ /** @internal Live git identity of the checkout; null when git is unusable. */
21
+ export declare function readGitBuildIdentity(runGit?: GitRunner): BuildIdentity | null;
22
+ /**
23
+ * @internal Formats `<version>+g<sha>[.dirty[.<hash>]]`; plain version on
24
+ * null identity. The trailing content hash appears only for a dirty
25
+ * INSTALLED build, where `dist/build-info.json` already carries it — every
26
+ * segment stays valid semver build metadata (dot-separated alphanumerics).
27
+ */
28
+ export declare function formatVersionWithIdentity(version: string, identity: BuildIdentity | null): string;
29
+ /** The `--version` string: semver plus build identity when known. */
30
+ export declare function getCliVersion(): string;
31
+ export {};
@@ -0,0 +1,98 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Build identity for `--version` (FORGE K2/K3/L8). Three distinct builds
4
+ * shipped as plain "0.41.0" — consumers had to fingerprint tarballs by
5
+ * sha256 to know which build a claim was verified against. `--version`
6
+ * now reports `<semver>+g<short-sha>[.dirty[.<content-hash>]]`:
7
+ *
8
+ * - In a git checkout (dev runs via tsx), identity comes from live git —
9
+ * a stale `dist/build-info.json` from an old build must never win.
10
+ * - In an installed package, it comes from `dist/build-info.json`,
11
+ * stamped by scripts/generate-build-info.mjs at build/pack time.
12
+ * - When neither yields an identity, `--version` degrades to the plain
13
+ * semver. Reading identity must never throw.
14
+ *
15
+ * Deliberately NOT wired into the per-patch lint cache key: identity
16
+ * would churn the cache on every commit at the same semver for no
17
+ * correctness gain (`getPackageVersion` stays the cache input).
18
+ */
19
+ import { execFileSync } from 'node:child_process';
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { getPackageRoot, getPackageVersion } from './package-root.js';
23
+ import { isObject } from './validation.js';
24
+ /** @internal Reads the stamped dist/build-info.json; null when absent/invalid. */
25
+ export function readBuildInfoFile(packageRoot) {
26
+ try {
27
+ const raw = readFileSync(join(packageRoot, 'dist', 'build-info.json'), 'utf-8');
28
+ const data = JSON.parse(raw);
29
+ if (!isObject(data) || data['schemaVersion'] !== 1)
30
+ return null;
31
+ const shortCommit = data['shortCommit'];
32
+ const dirty = data['dirty'];
33
+ if (typeof shortCommit !== 'string' || shortCommit.length === 0)
34
+ return null;
35
+ const dirtyHash = data['dirtyHash'];
36
+ return {
37
+ shortCommit,
38
+ dirty: dirty === true,
39
+ ...(typeof dirtyHash === 'string' && dirtyHash.length > 0 ? { dirtyHash } : {}),
40
+ };
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ const defaultGitRunner = (args) => execFileSync('git', args, {
47
+ cwd: getPackageRoot(),
48
+ encoding: 'utf-8',
49
+ stdio: ['ignore', 'pipe', 'ignore'],
50
+ });
51
+ /** @internal Live git identity of the checkout; null when git is unusable. */
52
+ export function readGitBuildIdentity(runGit = defaultGitRunner) {
53
+ try {
54
+ const commit = runGit(['rev-parse', 'HEAD']).trim();
55
+ if (!/^[0-9a-f]{7,40}$/.test(commit))
56
+ return null;
57
+ const status = runGit(['status', '--porcelain']);
58
+ return { shortCommit: commit.slice(0, 12), dirty: status.length > 0 };
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ /**
65
+ * Resolves the running build's identity: live git in a checkout (covers
66
+ * worktrees, whose `.git` is a file), else the stamped build-info file,
67
+ * else null. Never throws.
68
+ */
69
+ function getBuildIdentity() {
70
+ try {
71
+ const packageRoot = getPackageRoot();
72
+ if (existsSync(join(packageRoot, '.git'))) {
73
+ return readGitBuildIdentity();
74
+ }
75
+ return readBuildInfoFile(packageRoot);
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ /**
82
+ * @internal Formats `<version>+g<sha>[.dirty[.<hash>]]`; plain version on
83
+ * null identity. The trailing content hash appears only for a dirty
84
+ * INSTALLED build, where `dist/build-info.json` already carries it — every
85
+ * segment stays valid semver build metadata (dot-separated alphanumerics).
86
+ */
87
+ export function formatVersionWithIdentity(version, identity) {
88
+ if (identity === null)
89
+ return version;
90
+ if (!identity.dirty)
91
+ return `${version}+g${identity.shortCommit}`;
92
+ const contentHash = identity.dirtyHash === undefined ? '' : `.${identity.dirtyHash}`;
93
+ return `${version}+g${identity.shortCommit}.dirty${contentHash}`;
94
+ }
95
+ /** The `--version` string: semver plus build identity when known. */
96
+ export function getCliVersion() {
97
+ return formatVersionWithIdentity(getPackageVersion(), getBuildIdentity());
98
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Bounded-concurrency helpers shared by commands that fan out per-file IO
3
+ * (status classification, import's unmanaged-dirty guard, re-export scans,
4
+ * the dry-run purity guard). Kept deliberately tiny: one order-preserving
5
+ * mapper, no queues, no cancellation.
6
+ */
7
+ /** Maps items with at most `limit` in-flight promises. Preserves order. */
8
+ export declare function mapWithConcurrency<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Bounded-concurrency helpers shared by commands that fan out per-file IO
3
+ * (status classification, import's unmanaged-dirty guard, re-export scans,
4
+ * the dry-run purity guard). Kept deliberately tiny: one order-preserving
5
+ * mapper, no queues, no cancellation.
6
+ */
7
+ /** Maps items with at most `limit` in-flight promises. Preserves order. */
8
+ export async function mapWithConcurrency(items, limit, fn) {
9
+ const results = new Array(items.length);
10
+ let next = 0;
11
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
12
+ for (;;) {
13
+ const index = next++;
14
+ if (index >= items.length)
15
+ return;
16
+ results[index] = await fn(items[index], index);
17
+ }
18
+ });
19
+ await Promise.all(workers);
20
+ return results;
21
+ }
@@ -1,2 +1,41 @@
1
+ /**
2
+ * Throwable normalisation primitives.
3
+ *
4
+ * **This module must import nothing.** It sits at the very bottom of the
5
+ * dependency graph — 81 modules across `core/`, `commands/`, `utils/` and
6
+ * `bin/` reach it — so it is the one place a shared helper can live and be
7
+ * provably cycle-immune. Adding an import here makes that whole subtree
8
+ * reachable from every util, and any future `src/errors/*` module wanting a
9
+ * util back (`errors/git.ts` already carries a mirrored index-lock heuristic
10
+ * that would like `getNodeErrorCode`) then closes the loop and fails the
11
+ * `dpdm --exit-code circular:1` release gate. A helper that needs an import
12
+ * belongs in a sibling leaf module, not here.
13
+ */
1
14
  /** Normalizes unknown throwables into an Error instance. */
2
15
  export declare function toError(error: unknown): Error;
16
+ /**
17
+ * Extracts a Node errno string (`ENOENT`, `EACCES`, `EPERM`, …) from an
18
+ * unknown throwable, or `undefined` when the value carries no string `code`.
19
+ *
20
+ * The check is deliberately structural rather than `error instanceof Error`:
21
+ * a plain object carrying `.code` — exactly the shape {@link toError} exists
22
+ * to normalise — reaches errno consumers through rejected promises and
23
+ * cross-realm throws. Nine hand-rolled copies of this predicate existed
24
+ * before 0.41.0, four of them (in `utils/fs.ts`) gated on `instanceof Error`
25
+ * and so misclassified that shape as "no code".
26
+ */
27
+ export declare function getNodeErrorCode(error: unknown): string | undefined;
28
+ /**
29
+ * Checks whether a process with the given PID is still running, using
30
+ * `kill(pid, 0)` — no signal is sent, only the existence check is performed.
31
+ *
32
+ * Only `ESRCH` ("no such process") means dead. **`EPERM` means ALIVE**: the
33
+ * process exists but is owned by another uid, which happens routinely with
34
+ * root-owned builds, `sudo`, shared CI runners and container UID mismatches.
35
+ * Two copies of this predicate (`tree-store.ts`, `doctor-furnace.ts`) read
36
+ * EPERM as dead before 0.41.0, and both gated a recursive delete — a live
37
+ * build's tree clone and a live furnace lock were removed out from under
38
+ * their owner. Any other errno is treated as "unknown, assume alive", which
39
+ * is the safe direction for every caller: it refuses rather than destroys.
40
+ */
41
+ export declare function isProcessAlive(pid: number): boolean;
@@ -1,4 +1,17 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Throwable normalisation primitives.
4
+ *
5
+ * **This module must import nothing.** It sits at the very bottom of the
6
+ * dependency graph — 81 modules across `core/`, `commands/`, `utils/` and
7
+ * `bin/` reach it — so it is the one place a shared helper can live and be
8
+ * provably cycle-immune. Adding an import here makes that whole subtree
9
+ * reachable from every util, and any future `src/errors/*` module wanting a
10
+ * util back (`errors/git.ts` already carries a mirrored index-lock heuristic
11
+ * that would like `getNodeErrorCode`) then closes the loop and fails the
12
+ * `dpdm --exit-code circular:1` release gate. A helper that needs an import
13
+ * belongs in a sibling leaf module, not here.
14
+ */
2
15
  /** Normalizes unknown throwables into an Error instance. */
3
16
  export function toError(error) {
4
17
  if (error instanceof Error) {
@@ -12,3 +25,45 @@ export function toError(error) {
12
25
  }
13
26
  return new Error(typeof error === 'string' ? error : String(error), { cause: error });
14
27
  }
28
+ /**
29
+ * Extracts a Node errno string (`ENOENT`, `EACCES`, `EPERM`, …) from an
30
+ * unknown throwable, or `undefined` when the value carries no string `code`.
31
+ *
32
+ * The check is deliberately structural rather than `error instanceof Error`:
33
+ * a plain object carrying `.code` — exactly the shape {@link toError} exists
34
+ * to normalise — reaches errno consumers through rejected promises and
35
+ * cross-realm throws. Nine hand-rolled copies of this predicate existed
36
+ * before 0.41.0, four of them (in `utils/fs.ts`) gated on `instanceof Error`
37
+ * and so misclassified that shape as "no code".
38
+ */
39
+ export function getNodeErrorCode(error) {
40
+ if (typeof error === 'object' &&
41
+ error !== null &&
42
+ 'code' in error &&
43
+ typeof error.code === 'string') {
44
+ return error.code;
45
+ }
46
+ return undefined;
47
+ }
48
+ /**
49
+ * Checks whether a process with the given PID is still running, using
50
+ * `kill(pid, 0)` — no signal is sent, only the existence check is performed.
51
+ *
52
+ * Only `ESRCH` ("no such process") means dead. **`EPERM` means ALIVE**: the
53
+ * process exists but is owned by another uid, which happens routinely with
54
+ * root-owned builds, `sudo`, shared CI runners and container UID mismatches.
55
+ * Two copies of this predicate (`tree-store.ts`, `doctor-furnace.ts`) read
56
+ * EPERM as dead before 0.41.0, and both gated a recursive delete — a live
57
+ * build's tree clone and a live furnace lock were removed out from under
58
+ * their owner. Any other errno is treated as "unknown, assume alive", which
59
+ * is the safe direction for every caller: it refuses rather than destroys.
60
+ */
61
+ export function isProcessAlive(pid) {
62
+ try {
63
+ process.kill(pid, 0);
64
+ return true;
65
+ }
66
+ catch (error) {
67
+ return getNodeErrorCode(error) !== 'ESRCH';
68
+ }
69
+ }
@@ -2,6 +2,7 @@
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { access, chmod, copyFile as fsCopyFile, lstat, mkdir, open, readdir, readFile, rename, rm, stat, statfs, } from 'node:fs/promises';
4
4
  import { basename, dirname, join } from 'node:path';
5
+ import { getNodeErrorCode } from './errors.js';
5
6
  const RETRIABLE_REMOVE_ERRORS = new Set(['ENOTEMPTY', 'EBUSY', 'EPERM']);
6
7
  const RETRIABLE_RENAME_ERRORS = new Set(['EPERM', 'EACCES', 'EBUSY']);
7
8
  function sleep(ms) {
@@ -33,9 +34,7 @@ export async function pathExistsStrict(path) {
33
34
  return true;
34
35
  }
35
36
  catch (error) {
36
- const code = error instanceof Error && 'code' in error && typeof error.code === 'string'
37
- ? error.code
38
- : undefined;
37
+ const code = getNodeErrorCode(error);
39
38
  if (code === 'ENOENT') {
40
39
  return false;
41
40
  }
@@ -68,9 +67,7 @@ export async function removeDir(path) {
68
67
  return;
69
68
  }
70
69
  catch (error) {
71
- const code = error instanceof Error && 'code' in error && typeof error.code === 'string'
72
- ? error.code
73
- : undefined;
70
+ const code = getNodeErrorCode(error);
74
71
  if (!code || !RETRIABLE_REMOVE_ERRORS.has(code) || attempt === 4) {
75
72
  throw error;
76
73
  }
@@ -177,9 +174,7 @@ export async function writeFileAtomic(path, content) {
177
174
  existingMode = (await stat(path)).mode;
178
175
  }
179
176
  catch (error) {
180
- const code = error instanceof Error && 'code' in error && typeof error.code === 'string'
181
- ? error.code
182
- : undefined;
177
+ const code = getNodeErrorCode(error);
183
178
  if (code !== 'ENOENT') {
184
179
  throw error;
185
180
  }
@@ -223,9 +218,7 @@ async function renameWithRetries(from, to) {
223
218
  return;
224
219
  }
225
220
  catch (error) {
226
- const code = error instanceof Error && 'code' in error && typeof error.code === 'string'
227
- ? error.code
228
- : undefined;
221
+ const code = getNodeErrorCode(error);
229
222
  if (!code || !RETRIABLE_RENAME_ERRORS.has(code) || attempt === 4) {
230
223
  throw error;
231
224
  }
@@ -10,6 +10,8 @@ export declare function setVerbose(enabled: boolean): void;
10
10
  export declare function setMachineOutputMode(enabled: boolean): void;
11
11
  /** True when machine-output mode is active. */
12
12
  export declare function isMachineOutputMode(): boolean;
13
+ /** Seals (or unseals) stdout after a machine-readable final line. */
14
+ export declare function setStdoutSealed(sealed: boolean): void;
13
15
  /**
14
16
  * Displays a verbose/debug message (only shown if verbose mode is enabled).
15
17
  * @param message - Message to display
@@ -54,7 +56,17 @@ export declare function formatErrorText(message: string): string;
54
56
  export declare function spinner(initialMessage: string): SpinnerHandle;
55
57
  /** Emits a cancellation message. */
56
58
  export declare function cancel(message: string): void;
57
- /** Checks whether a prompt result represents a user cancellation. */
58
- export declare function isCancel(value: unknown): boolean;
59
+ /**
60
+ * Checks whether a prompt result represents a user cancellation.
61
+ *
62
+ * Narrows to `symbol` — clack returns its cancel sentinel as a symbol and
63
+ * `p.isCancel` is itself a type predicate. This wrapper erased that narrowing
64
+ * by returning plain `boolean` until 0.41.0, which forced eleven `as string`
65
+ * / `as PatchCategory` / `as OverrideType` casts across six command modules
66
+ * on the *non*-cancelled branch, where the value is already known not to be
67
+ * the sentinel. Keep the predicate form: it is the single highest-leverage
68
+ * signature in the codebase for escape-hatch count.
69
+ */
70
+ export declare function isCancel(value: unknown): value is symbol;
59
71
  /** Displays a titled note block for follow-up details. */
60
72
  export declare function note(message: string, title?: string): void;
@@ -34,6 +34,26 @@ export function setMachineOutputMode(enabled) {
34
34
  export function isMachineOutputMode() {
35
35
  return machineOutputMode;
36
36
  }
37
+ /**
38
+ * Whether stdout is sealed behind a machine-readable final line.
39
+ *
40
+ * The `FIREFORGE-VERDICT:` contract promises that line as the run's LAST
41
+ * stdout write — but emit-then-throw failure paths rethrow into
42
+ * `withErrorHandling`, whose clack error/cancel rendering lands on stdout
43
+ * in non-machine mode, displacing the verdict from the final line. The
44
+ * verdict sink seals stdout the moment it writes; every later logger call
45
+ * routes to stderr through the same diagnostic channel machine mode uses.
46
+ * `withErrorHandling`'s finally clears the seal (alongside machine mode).
47
+ */
48
+ let stdoutSealed = false;
49
+ /** Seals (or unseals) stdout after a machine-readable final line. */
50
+ export function setStdoutSealed(sealed) {
51
+ stdoutSealed = sealed;
52
+ }
53
+ /** True when human output must avoid stdout. */
54
+ function routeToStderr() {
55
+ return machineOutputMode || stdoutSealed;
56
+ }
37
57
  /** Writes a plain diagnostic line to stderr (machine-mode side channel). */
38
58
  function writeDiagnostic(prefix, message) {
39
59
  process.stderr.write(`${prefix}${message}\n`);
@@ -52,7 +72,7 @@ function isVerbose() {
52
72
  export function verbose(message) {
53
73
  if (!isVerbose())
54
74
  return;
55
- if (machineOutputMode) {
75
+ if (routeToStderr()) {
56
76
  writeDiagnostic('[debug] ', message);
57
77
  return;
58
78
  }
@@ -63,19 +83,19 @@ function supportsInteractiveSpinner() {
63
83
  }
64
84
  /** Displays the top-level intro banner for a command. */
65
85
  export function intro(message) {
66
- if (machineOutputMode)
86
+ if (routeToStderr())
67
87
  return;
68
88
  p.intro(message);
69
89
  }
70
90
  /** Displays the closing outro banner for a command. */
71
91
  export function outro(message) {
72
- if (machineOutputMode)
92
+ if (routeToStderr())
73
93
  return;
74
94
  p.outro(message);
75
95
  }
76
96
  /** Logs an informational message. */
77
97
  export function info(message) {
78
- if (machineOutputMode) {
98
+ if (routeToStderr()) {
79
99
  writeDiagnostic('', message);
80
100
  return;
81
101
  }
@@ -83,7 +103,7 @@ export function info(message) {
83
103
  }
84
104
  /** Logs a success message. */
85
105
  export function success(message) {
86
- if (machineOutputMode) {
106
+ if (routeToStderr()) {
87
107
  writeDiagnostic('', message);
88
108
  return;
89
109
  }
@@ -91,7 +111,7 @@ export function success(message) {
91
111
  }
92
112
  /** Logs a warning message. */
93
113
  export function warn(message) {
94
- if (machineOutputMode) {
114
+ if (routeToStderr()) {
95
115
  writeDiagnostic('warning: ', message);
96
116
  return;
97
117
  }
@@ -99,7 +119,7 @@ export function warn(message) {
99
119
  }
100
120
  /** Logs an error message. */
101
121
  export function error(message) {
102
- if (machineOutputMode) {
122
+ if (routeToStderr()) {
103
123
  writeDiagnostic('error: ', message);
104
124
  return;
105
125
  }
@@ -107,7 +127,7 @@ export function error(message) {
107
127
  }
108
128
  /** Logs an in-progress step message. */
109
129
  export function step(message) {
110
- if (machineOutputMode) {
130
+ if (routeToStderr()) {
111
131
  writeDiagnostic('', message);
112
132
  return;
113
133
  }
@@ -115,7 +135,7 @@ export function step(message) {
115
135
  }
116
136
  /** Logs a plain message without a status prefix. */
117
137
  export function message(message) {
118
- if (machineOutputMode) {
138
+ if (routeToStderr()) {
119
139
  writeDiagnostic('', message);
120
140
  return;
121
141
  }
@@ -135,7 +155,7 @@ export function formatErrorText(message) {
135
155
  * @returns Spinner handle with message(), stop(), and error() methods
136
156
  */
137
157
  export function spinner(initialMessage) {
138
- if (machineOutputMode) {
158
+ if (routeToStderr()) {
139
159
  // Spinner progress is diagnostics; keep stdout clean for the payload.
140
160
  return {
141
161
  message: (msg) => {
@@ -182,19 +202,29 @@ export function spinner(initialMessage) {
182
202
  }
183
203
  /** Emits a cancellation message. */
184
204
  export function cancel(message) {
185
- if (machineOutputMode) {
205
+ if (routeToStderr()) {
186
206
  writeDiagnostic('cancelled: ', message);
187
207
  return;
188
208
  }
189
209
  p.cancel(message);
190
210
  }
191
- /** Checks whether a prompt result represents a user cancellation. */
211
+ /**
212
+ * Checks whether a prompt result represents a user cancellation.
213
+ *
214
+ * Narrows to `symbol` — clack returns its cancel sentinel as a symbol and
215
+ * `p.isCancel` is itself a type predicate. This wrapper erased that narrowing
216
+ * by returning plain `boolean` until 0.41.0, which forced eleven `as string`
217
+ * / `as PatchCategory` / `as OverrideType` casts across six command modules
218
+ * on the *non*-cancelled branch, where the value is already known not to be
219
+ * the sentinel. Keep the predicate form: it is the single highest-leverage
220
+ * signature in the codebase for escape-hatch count.
221
+ */
192
222
  export function isCancel(value) {
193
223
  return p.isCancel(value);
194
224
  }
195
225
  /** Displays a titled note block for follow-up details. */
196
226
  export function note(message, title) {
197
- if (machineOutputMode) {
227
+ if (routeToStderr()) {
198
228
  writeDiagnostic('', title ? `${title}: ${message}` : message);
199
229
  return;
200
230
  }
@@ -22,6 +22,21 @@ import type { Command } from 'commander';
22
22
  * helper (or throw commander's InvalidArgumentError directly).
23
23
  */
24
24
  export declare function commanderArgParser<T>(parse: (raw: string) => T): (raw: string) => T;
25
+ /**
26
+ * Commander plumbing for a repeatable string option, as a spreadable
27
+ * `[accumulator, default]` pair:
28
+ *
29
+ * ```ts
30
+ * .option('--mach-arg <arg>', 'Repeatable.', ...stringListOption())
31
+ * ```
32
+ *
33
+ * Seven call sites open-coded this, each with its own `[] as string[]`
34
+ * default — commander types that slot as `unknown`, so every one carried the
35
+ * cast. Two of the seven also mutated the accumulator in place rather than
36
+ * returning a new array. Returning a fresh array per call additionally means
37
+ * the default cannot be shared between two `.option()` registrations.
38
+ */
39
+ export declare function stringListOption(): [(value: string, previous: string[]) => string[], string[]];
25
40
  /**
26
41
  * Resolves the parsed `--wait-lock [seconds]` option value into a wait budget
27
42
  * in seconds for `withEngineSessionLock`:
@@ -35,9 +50,11 @@ export declare function commanderArgParser<T>(parse: (raw: string) => T): (raw:
35
50
  */
36
51
  export declare function resolveWaitLockSeconds(value: string | number | boolean | undefined): number | undefined;
37
52
  /**
38
- * Registers the shared `--wait-lock [seconds]` flag on an engine-mutating
39
- * command. The parsed option value is `true` for the bare flag or a validated
40
- * integer; feed it through {@link resolveWaitLockSeconds} at the call site to
41
- * obtain the `waitLockSeconds` for `withEngineSessionLock`.
53
+ * Registers the shared `--wait-lock [seconds]` flag on a lock-taking
54
+ * command (the engine session lock for engine-mutating commands, the patch
55
+ * directory lock for queue-mutating ones). The parsed option value is `true`
56
+ * for the bare flag or a validated integer; feed it through
57
+ * {@link resolveWaitLockSeconds} at the call site to obtain the
58
+ * `waitLockSeconds` for `withEngineSessionLock`/`withPatchDirectoryLock`.
42
59
  */
43
60
  export declare function addWaitLockOption(command: Command): Command;
@@ -15,6 +15,7 @@ export function pickDefined(obj) {
15
15
  }
16
16
  import { InvalidArgumentError as CommanderInvalidArgumentError } from 'commander';
17
17
  import { GeneralError } from '../errors/base.js';
18
+ import { toError } from './errors.js';
18
19
  /**
19
20
  * Wraps an option-argument parser so its failures surface through
20
21
  * commander's own invalid-argument channel.
@@ -35,10 +36,27 @@ export function commanderArgParser(parse) {
35
36
  return parse(raw);
36
37
  }
37
38
  catch (error) {
38
- throw new CommanderInvalidArgumentError(error instanceof Error ? error.message : String(error));
39
+ throw new CommanderInvalidArgumentError(toError(error).message);
39
40
  }
40
41
  };
41
42
  }
43
+ /**
44
+ * Commander plumbing for a repeatable string option, as a spreadable
45
+ * `[accumulator, default]` pair:
46
+ *
47
+ * ```ts
48
+ * .option('--mach-arg <arg>', 'Repeatable.', ...stringListOption())
49
+ * ```
50
+ *
51
+ * Seven call sites open-coded this, each with its own `[] as string[]`
52
+ * default — commander types that slot as `unknown`, so every one carried the
53
+ * cast. Two of the seven also mutated the accumulator in place rather than
54
+ * returning a new array. Returning a fresh array per call additionally means
55
+ * the default cannot be shared between two `.option()` registrations.
56
+ */
57
+ export function stringListOption() {
58
+ return [(value, previous) => [...previous, value], []];
59
+ }
42
60
  /** Wait budget applied when `--wait-lock` is passed without a value. */
43
61
  const DEFAULT_WAIT_LOCK_SECONDS = 60;
44
62
  /**
@@ -69,11 +87,13 @@ export function resolveWaitLockSeconds(value) {
69
87
  return n;
70
88
  }
71
89
  /**
72
- * Registers the shared `--wait-lock [seconds]` flag on an engine-mutating
73
- * command. The parsed option value is `true` for the bare flag or a validated
74
- * integer; feed it through {@link resolveWaitLockSeconds} at the call site to
75
- * obtain the `waitLockSeconds` for `withEngineSessionLock`.
90
+ * Registers the shared `--wait-lock [seconds]` flag on a lock-taking
91
+ * command (the engine session lock for engine-mutating commands, the patch
92
+ * directory lock for queue-mutating ones). The parsed option value is `true`
93
+ * for the bare flag or a validated integer; feed it through
94
+ * {@link resolveWaitLockSeconds} at the call site to obtain the
95
+ * `waitLockSeconds` for `withEngineSessionLock`/`withPatchDirectoryLock`.
76
96
  */
77
97
  export function addWaitLockOption(command) {
78
- return command.option('--wait-lock [seconds]', 'Wait up to this many seconds (default 60) for another FireForge engine-mutating command to release the engine lock, instead of failing immediately', commanderArgParser((raw) => resolveWaitLockSeconds(raw)));
98
+ return command.option('--wait-lock [seconds]', 'Wait up to this many seconds (default 60) for another FireForge command to release the contended lock, instead of failing on the default budget', commanderArgParser((raw) => resolveWaitLockSeconds(raw)));
79
99
  }
@@ -2,6 +2,8 @@
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { getNodeErrorCode, toError } from './errors.js';
6
+ import { isObject } from './validation.js';
5
7
  function validatePackageMetadata(data, filePath) {
6
8
  if (typeof data !== 'object' || data === null) {
7
9
  throw new Error(`Invalid package metadata in ${filePath}: expected an object`);
@@ -48,10 +50,11 @@ export function getPackageRoot() {
48
50
  // to a wrong ancestor package (any parent exposing bin.fireforge) or
49
51
  // end in the unhelpful generic "could not locate" error, hiding the
50
52
  // actual syntax problem.
51
- const code = error.code;
53
+ const code = getNodeErrorCode(error);
52
54
  if (code !== 'ENOENT' && code !== 'ENOTDIR') {
53
- throw new Error(`Found ${packagePath} but could not parse it: ` +
54
- (error instanceof Error ? error.message : String(error)), { cause: error });
55
+ throw new Error(`Found ${packagePath} but could not parse it: ` + toError(error).message, {
56
+ cause: error,
57
+ });
55
58
  }
56
59
  }
57
60
  const parent = dirname(current);
@@ -63,7 +66,7 @@ export function getPackageRoot() {
63
66
  }
64
67
  /** @internal */
65
68
  export function isFireForgePackageMetadata(pkg) {
66
- if (typeof pkg.bin !== 'object' || pkg.bin === null || Array.isArray(pkg.bin)) {
69
+ if (!isObject(pkg.bin)) {
67
70
  return false;
68
71
  }
69
72
  const bin = pkg.bin;
@@ -6,6 +6,7 @@
6
6
  * two modules do not import each other cyclically.
7
7
  */
8
8
  import { spawn } from 'node:child_process';
9
+ import { getNodeErrorCode } from './errors.js';
9
10
  import { verbose, warn } from './logger.js';
10
11
  /**
11
12
  * Sends `signal` to the child's whole tree: the process GROUP on POSIX
@@ -99,8 +100,14 @@ async function listGroupSurvivors(pgid) {
99
100
  process.kill(-pgid, 0);
100
101
  return [{ pid: -1, command: 'unknown (pgrep unavailable; group still has live members)' }];
101
102
  }
102
- catch {
103
- return [];
103
+ catch (error) {
104
+ // Same errno semantics as `isProcessAlive` (which cannot be called here —
105
+ // it probes a single positive pid): only ESRCH proves the group is gone.
106
+ // EPERM means it exists under another uid, and an unrecognized failure is
107
+ // not evidence of absence — report survivors in both cases.
108
+ if (getNodeErrorCode(error) === 'ESRCH')
109
+ return [];
110
+ return [{ pid: -1, command: 'unknown (pgrep unavailable; group liveness probe denied)' }];
104
111
  }
105
112
  }
106
113
  function describeSurvivors(list) {
@@ -133,6 +140,8 @@ export async function sweepProcessGroup(pgid, graceMs = SWEEP_GRACE_MS) {
133
140
  process.kill(-pgid, 'SIGTERM');
134
141
  }
135
142
  catch {
143
+ // The group vanished between the listing and the signal — nothing left to
144
+ // escalate against, so report the survivors collected so far.
136
145
  return { survivors };
137
146
  }
138
147
  await sweepDelay(graceMs);
@@ -142,6 +151,8 @@ export async function sweepProcessGroup(pgid, graceMs = SWEEP_GRACE_MS) {
142
151
  process.kill(-pgid, 'SIGKILL');
143
152
  }
144
153
  catch {
154
+ // The group died during the grace period, so the SIGKILL escalation has
155
+ // nothing to signal.
145
156
  return { survivors };
146
157
  }
147
158
  await sweepDelay(Math.min(200, graceMs));