@hominis/fireforge 0.40.0 → 0.42.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 (256) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +16 -10
  3. package/dist/bin/fireforge.js +25 -4
  4. package/dist/build-info.json +9 -0
  5. package/dist/src/cli.js +20 -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 +13 -3
  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 +16 -7
  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 +12 -59
  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.js +4 -4
  45. package/dist/src/commands/manifest.js +1 -1
  46. package/dist/src/commands/package.js +9 -16
  47. package/dist/src/commands/patch/compact.js +5 -5
  48. package/dist/src/commands/patch/delete.js +5 -5
  49. package/dist/src/commands/patch/lint-ignore.js +3 -2
  50. package/dist/src/commands/patch/move-files-create.js +22 -4
  51. package/dist/src/commands/patch/move-files-into.js +14 -10
  52. package/dist/src/commands/patch/move-files.js +6 -5
  53. package/dist/src/commands/patch/rename.d.ts +2 -2
  54. package/dist/src/commands/patch/rename.js +151 -50
  55. package/dist/src/commands/patch/reorder.d.ts +9 -0
  56. package/dist/src/commands/patch/reorder.js +14 -10
  57. package/dist/src/commands/patch/split-plan.d.ts +13 -6
  58. package/dist/src/commands/patch/split-plan.js +22 -12
  59. package/dist/src/commands/patch/split.d.ts +1 -1
  60. package/dist/src/commands/patch/split.js +23 -9
  61. package/dist/src/commands/patch/staged-dependency-validate.d.ts +15 -0
  62. package/dist/src/commands/patch/staged-dependency-validate.js +49 -0
  63. package/dist/src/commands/patch/staged-dependency.js +10 -0
  64. package/dist/src/commands/re-export-adjacent.js +5 -6
  65. package/dist/src/commands/re-export-bulk-scan.d.ts +13 -0
  66. package/dist/src/commands/re-export-bulk-scan.js +142 -2
  67. package/dist/src/commands/re-export-drift.d.ts +74 -0
  68. package/dist/src/commands/re-export-drift.js +234 -0
  69. package/dist/src/commands/re-export-files.js +5 -3
  70. package/dist/src/commands/re-export-lint.d.ts +97 -0
  71. package/dist/src/commands/re-export-lint.js +210 -0
  72. package/dist/src/commands/re-export-options.js +8 -0
  73. package/dist/src/commands/re-export-register.d.ts +18 -0
  74. package/dist/src/commands/re-export-register.js +61 -0
  75. package/dist/src/commands/re-export-scan.js +13 -7
  76. package/dist/src/commands/re-export-single.d.ts +20 -0
  77. package/dist/src/commands/re-export-single.js +261 -0
  78. package/dist/src/commands/re-export.d.ts +0 -4
  79. package/dist/src/commands/re-export.js +113 -268
  80. package/dist/src/commands/rebase/abort.js +13 -4
  81. package/dist/src/commands/rebase/continue.js +11 -4
  82. package/dist/src/commands/rebase/index.js +19 -29
  83. package/dist/src/commands/rebase/patch-loop.js +12 -0
  84. package/dist/src/commands/reset.js +4 -23
  85. package/dist/src/commands/resolve.js +2 -9
  86. package/dist/src/commands/run.js +11 -21
  87. package/dist/src/commands/source.js +3 -9
  88. package/dist/src/commands/status-check.d.ts +13 -0
  89. package/dist/src/commands/status-check.js +31 -13
  90. package/dist/src/commands/status-json.d.ts +31 -0
  91. package/dist/src/commands/status-json.js +76 -0
  92. package/dist/src/commands/status-output.d.ts +1 -0
  93. package/dist/src/commands/status-output.js +10 -1
  94. package/dist/src/commands/status-ownership.d.ts +52 -0
  95. package/dist/src/commands/status-ownership.js +57 -0
  96. package/dist/src/commands/status.js +184 -175
  97. package/dist/src/commands/test-build-phase.d.ts +19 -0
  98. package/dist/src/commands/test-build-phase.js +168 -0
  99. package/dist/src/commands/test-diagnose.js +30 -2
  100. package/dist/src/commands/test-modes.d.ts +9 -1
  101. package/dist/src/commands/test-modes.js +46 -13
  102. package/dist/src/commands/test-register.js +59 -27
  103. package/dist/src/commands/test-run.d.ts +29 -3
  104. package/dist/src/commands/test-run.js +41 -4
  105. package/dist/src/commands/test-verdict.d.ts +32 -0
  106. package/dist/src/commands/test-verdict.js +48 -0
  107. package/dist/src/commands/test.d.ts +9 -0
  108. package/dist/src/commands/test.js +100 -123
  109. package/dist/src/commands/token-coverage.js +4 -9
  110. package/dist/src/commands/tree.d.ts +3 -0
  111. package/dist/src/commands/tree.js +171 -25
  112. package/dist/src/commands/verify.js +2 -2
  113. package/dist/src/commands/watch.js +22 -21
  114. package/dist/src/commands/wire.js +1 -1
  115. package/dist/src/core/ast-utils.d.ts +11 -0
  116. package/dist/src/core/ast-utils.js +11 -0
  117. package/dist/src/core/branding.d.ts +16 -0
  118. package/dist/src/core/branding.js +18 -2
  119. package/dist/src/core/build-audit-platform.js +6 -1
  120. package/dist/src/core/build-audit-registration.js +2 -0
  121. package/dist/src/core/build-audit-resolve.d.ts +13 -0
  122. package/dist/src/core/build-audit-resolve.js +34 -13
  123. package/dist/src/core/build-audit.js +4 -20
  124. package/dist/src/core/build-baseline-types.d.ts +29 -8
  125. package/dist/src/core/build-baseline-types.js +2 -1
  126. package/dist/src/core/build-baseline.d.ts +10 -2
  127. package/dist/src/core/build-baseline.js +39 -8
  128. package/dist/src/core/build-prepare.d.ts +4 -0
  129. package/dist/src/core/build-prepare.js +7 -1
  130. package/dist/src/core/config-paths.d.ts +1 -1
  131. package/dist/src/core/config-paths.js +2 -0
  132. package/dist/src/core/config-validate-test-toolchains.js +3 -2
  133. package/dist/src/core/config-validate.js +34 -36
  134. package/dist/src/core/config.js +16 -3
  135. package/dist/src/core/coverage-extend.d.ts +89 -0
  136. package/dist/src/core/coverage-extend.js +188 -0
  137. package/dist/src/core/destructive.js +3 -1
  138. package/dist/src/core/discard-baseline.js +18 -4
  139. package/dist/src/core/engine-precondition.d.ts +35 -0
  140. package/dist/src/core/engine-precondition.js +50 -0
  141. package/dist/src/core/engine-session-lock.d.ts +38 -1
  142. package/dist/src/core/engine-session-lock.js +78 -4
  143. package/dist/src/core/file-lock.d.ts +7 -0
  144. package/dist/src/core/file-lock.js +20 -30
  145. package/dist/src/core/firefox.js +5 -2
  146. package/dist/src/core/furnace-apply-helpers.d.ts +12 -1
  147. package/dist/src/core/furnace-apply-helpers.js +12 -3
  148. package/dist/src/core/furnace-apply-output.js +9 -0
  149. package/dist/src/core/furnace-apply-overwrite-warn.d.ts +63 -0
  150. package/dist/src/core/furnace-apply-overwrite-warn.js +123 -0
  151. package/dist/src/core/furnace-apply-undeploy-actions.d.ts +11 -0
  152. package/dist/src/core/furnace-apply-undeploy-actions.js +49 -0
  153. package/dist/src/core/furnace-apply.d.ts +16 -4
  154. package/dist/src/core/furnace-apply.js +39 -43
  155. package/dist/src/core/furnace-config.js +2 -0
  156. package/dist/src/core/furnace-css-fragments.js +2 -0
  157. package/dist/src/core/furnace-jsconfig.js +14 -7
  158. package/dist/src/core/furnace-registration-ast.js +8 -8
  159. package/dist/src/core/furnace-registration-remove.js +6 -3
  160. package/dist/src/core/furnace-registration.js +9 -12
  161. package/dist/src/core/furnace-rollback.js +1 -1
  162. package/dist/src/core/furnace-scanner.js +2 -1
  163. package/dist/src/core/furnace-stale-export.js +2 -1
  164. package/dist/src/core/furnace-state-persist.d.ts +4 -6
  165. package/dist/src/core/furnace-validate-compatibility.js +4 -7
  166. package/dist/src/core/furnace-validate-helpers.js +33 -1
  167. package/dist/src/core/furnace-validate-registration.js +16 -0
  168. package/dist/src/core/furnace-validate.js +4 -2
  169. package/dist/src/core/git-diff.js +37 -2
  170. package/dist/src/core/lint-cache.d.ts +1 -1
  171. package/dist/src/core/lint-cache.js +33 -10
  172. package/dist/src/core/mach-build-artifacts.d.ts +63 -1
  173. package/dist/src/core/mach-build-artifacts.js +108 -2
  174. package/dist/src/core/mach-mozconfig.js +9 -1
  175. package/dist/src/core/mach-resource-shim.js +1 -0
  176. package/dist/src/core/marionette-port.d.ts +16 -0
  177. package/dist/src/core/marionette-port.js +93 -8
  178. package/dist/src/core/marionette-preflight.js +6 -5
  179. package/dist/src/core/patch-apply.d.ts +20 -13
  180. package/dist/src/core/patch-apply.js +32 -33
  181. package/dist/src/core/patch-export.js +3 -5
  182. package/dist/src/core/patch-lint-checkjs.d.ts +25 -3
  183. package/dist/src/core/patch-lint-checkjs.js +78 -7
  184. package/dist/src/core/patch-lint-chrome-jsdoc.js +9 -0
  185. package/dist/src/core/patch-lint-creators.d.ts +30 -0
  186. package/dist/src/core/patch-lint-creators.js +45 -0
  187. package/dist/src/core/patch-lint-cross.d.ts +1 -17
  188. package/dist/src/core/patch-lint-cross.js +16 -32
  189. package/dist/src/core/patch-lint-jsdoc.d.ts +3 -1
  190. package/dist/src/core/patch-lint-jsdoc.js +15 -5
  191. package/dist/src/core/patch-lint-module-registration.d.ts +15 -0
  192. package/dist/src/core/patch-lint-module-registration.js +88 -0
  193. package/dist/src/core/patch-lint-mozbuild.js +99 -11
  194. package/dist/src/core/patch-lint-reexports.d.ts +1 -0
  195. package/dist/src/core/patch-lint-reexports.js +1 -0
  196. package/dist/src/core/patch-lint.d.ts +8 -0
  197. package/dist/src/core/patch-lint.js +84 -6
  198. package/dist/src/core/patch-lock.d.ts +12 -1
  199. package/dist/src/core/patch-lock.js +40 -4
  200. package/dist/src/core/patch-manifest-consistency.js +15 -3
  201. package/dist/src/core/patch-parse.d.ts +8 -0
  202. package/dist/src/core/patch-parse.js +10 -0
  203. package/dist/src/core/patch-policy.d.ts +7 -0
  204. package/dist/src/core/patch-policy.js +4 -1
  205. package/dist/src/core/patch-transform.d.ts +10 -0
  206. package/dist/src/core/patch-transform.js +13 -2
  207. package/dist/src/core/rebase-session.d.ts +44 -2
  208. package/dist/src/core/rebase-session.js +114 -17
  209. package/dist/src/core/register-browser-content.js +2 -1
  210. package/dist/src/core/register-shared-css.js +2 -1
  211. package/dist/src/core/smoke-patterns.d.ts +0 -8
  212. package/dist/src/core/smoke-patterns.js +3 -2
  213. package/dist/src/core/status-binary.d.ts +55 -0
  214. package/dist/src/core/status-binary.js +124 -0
  215. package/dist/src/core/status-classify.d.ts +6 -1
  216. package/dist/src/core/status-classify.js +101 -73
  217. package/dist/src/core/test-harness-crash.d.ts +32 -1
  218. package/dist/src/core/test-harness-crash.js +59 -4
  219. package/dist/src/core/test-path-scope.js +2 -0
  220. package/dist/src/core/test-stale-check.js +1 -20
  221. package/dist/src/core/toolchain-preflight.js +24 -26
  222. package/dist/src/core/tree-guard.d.ts +1 -1
  223. package/dist/src/core/tree-guard.js +67 -12
  224. package/dist/src/core/tree-store.d.ts +86 -10
  225. package/dist/src/core/tree-store.js +310 -72
  226. package/dist/src/core/typecheck.js +2 -1
  227. package/dist/src/core/wire-destroy.js +14 -3
  228. package/dist/src/core/wire-init.js +37 -40
  229. package/dist/src/core/wire-subscript.js +11 -3
  230. package/dist/src/core/wire-utils.js +4 -4
  231. package/dist/src/core/xpcshell-appdir.js +3 -0
  232. package/dist/src/errors/base.d.ts +13 -0
  233. package/dist/src/errors/base.js +13 -0
  234. package/dist/src/errors/rebase.d.ts +13 -0
  235. package/dist/src/errors/rebase.js +17 -0
  236. package/dist/src/types/commands/options.d.ts +86 -1
  237. package/dist/src/types/commands/patches.d.ts +8 -0
  238. package/dist/src/types/furnace.d.ts +7 -0
  239. package/dist/src/utils/build-info.d.ts +31 -0
  240. package/dist/src/utils/build-info.js +98 -0
  241. package/dist/src/utils/concurrency.d.ts +8 -0
  242. package/dist/src/utils/concurrency.js +21 -0
  243. package/dist/src/utils/errors.d.ts +39 -0
  244. package/dist/src/utils/errors.js +55 -0
  245. package/dist/src/utils/fs.js +5 -12
  246. package/dist/src/utils/logger.d.ts +14 -2
  247. package/dist/src/utils/logger.js +43 -13
  248. package/dist/src/utils/options.d.ts +21 -4
  249. package/dist/src/utils/options.js +26 -6
  250. package/dist/src/utils/package-root.js +7 -4
  251. package/dist/src/utils/process-group.js +13 -2
  252. package/dist/src/utils/stdio-drain.d.ts +28 -0
  253. package/dist/src/utils/stdio-drain.js +54 -0
  254. package/dist/src/utils/validation.d.ts +23 -2
  255. package/dist/src/utils/validation.js +29 -3
  256. package/package.json +9 -9
@@ -10,14 +10,6 @@
10
10
  * warning (e.g. `"pending JavaScript error cleanup"`) do not start with
11
11
  * the prefix and should not trip the scanner.
12
12
  */
13
- /**
14
- * Line prefixes that signal an actual runtime error in a Firefox chrome
15
- * process. A hit on any of these patterns is a smoke failure unless the
16
- * line also matches the caller-supplied allowlist.
17
- *
18
- * Additions here should be conservative — false positives turn every
19
- * smoke run into noise for operators and every CI run into flake.
20
- */
21
13
  export declare const SMOKE_ERROR_PATTERNS: readonly RegExp[];
22
14
  /**
23
15
  * Returns `true` when `line` matches any pattern in
@@ -19,6 +19,7 @@
19
19
  * Additions here should be conservative — false positives turn every
20
20
  * smoke run into noise for operators and every CI run into flake.
21
21
  */
22
+ import { toError } from '../utils/errors.js';
22
23
  export const SMOKE_ERROR_PATTERNS = [
23
24
  // Firefox chrome error lines — `JavaScript error: chrome://…, line N: TypeError: …`.
24
25
  /^\s*JavaScript error:/i,
@@ -79,7 +80,7 @@ export function compileAllowlistFromFile(body, sourcePath) {
79
80
  });
80
81
  }
81
82
  catch (error) {
82
- const message = error instanceof Error ? error.message : String(error);
83
+ const message = toError(error).message;
83
84
  throw new Error(`Invalid allowlist regex at ${sourcePath}:${String(index + 1)}: ${message}`, {
84
85
  cause: error,
85
86
  });
@@ -102,7 +103,7 @@ export function compileAllowlistFromStrings(sources) {
102
103
  });
103
104
  }
104
105
  catch (error) {
105
- const message = error instanceof Error ? error.message : String(error);
106
+ const message = toError(error).message;
106
107
  throw new Error(`Invalid --console-allow regex at position ${String(index + 1)} ("${source}"): ${message}`, { cause: error });
107
108
  }
108
109
  });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Binary-aware drift classification for patch-owned files (FORGE J3).
3
+ *
4
+ * The text classifier compares utf-8 decoded content against
5
+ * `computePatchedContent`, which cannot apply `GIT binary patch` bodies —
6
+ * so a binary file exported into a patch classified as `patch-owned-drift`
7
+ * forever, keeping the engine-clean gate permanently red with nothing
8
+ * actually un-durable. Binary sections DO carry the ground truth, though:
9
+ * `git diff --binary` always records full blob hashes on the section's
10
+ * `index <old>..<new>` line. Comparing the live file's `git hash-object`
11
+ * against the last owning section's new-side hash settles the
12
+ * classification exactly. Bodies without a usable hash (hand-written
13
+ * diffs, `Binary files … differ`) classify as `binary-unsupported` —
14
+ * an honest "cannot compare" instead of a false "not durable".
15
+ *
16
+ * Lives in its own module (not `status-classify.ts`) so the classifier
17
+ * stays within the per-file line budget and this module never has to
18
+ * import it back (dpdm-clean: the dependency edge points one way).
19
+ */
20
+ import type { PatchedContentContext } from './patch-apply.js';
21
+ /** Batched patch lookup, injected by callers that classify many files. */
22
+ export type AffectingPatchLookup = Pick<PatchedContentContext, 'getAffectingPatches' | 'readPatchBody'>;
23
+ /**
24
+ * Classification result for a binary comparison. Structurally assignable
25
+ * to `ClassifiedFile` without importing it (which would create a cycle
26
+ * with `status-classify.ts`).
27
+ */
28
+ export interface BinaryClassifiedFile {
29
+ status: string;
30
+ file: string;
31
+ classification: 'patch-backed' | 'furnace' | 'patch-owned-drift' | 'binary-unsupported';
32
+ owner: string;
33
+ }
34
+ /**
35
+ * Classifies a single-owner file whose comparison is binary. Returns
36
+ * `null` when the comparison is NOT binary — the live file is text and
37
+ * (for deletions) the owning section is text — so the caller falls
38
+ * through to the existing utf-8 content comparison unchanged.
39
+ *
40
+ * @param args.fileMissing - True when the porcelain status reports the
41
+ * file deleted (there are no live bytes to probe or hash).
42
+ */
43
+ export declare function classifyBinaryOwnedFile(args: {
44
+ entry: {
45
+ status: string;
46
+ file: string;
47
+ };
48
+ engineDir: string;
49
+ patchesDir: string;
50
+ matchClassification: 'patch-backed' | 'furnace';
51
+ owner: string;
52
+ fileMissing?: boolean;
53
+ /** Batched lookup; when absent, falls back to per-file manifest queries. */
54
+ lookup?: AffectingPatchLookup;
55
+ }): Promise<BinaryClassifiedFile | null>;
@@ -0,0 +1,124 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Binary-aware drift classification for patch-owned files (FORGE J3).
4
+ *
5
+ * The text classifier compares utf-8 decoded content against
6
+ * `computePatchedContent`, which cannot apply `GIT binary patch` bodies —
7
+ * so a binary file exported into a patch classified as `patch-owned-drift`
8
+ * forever, keeping the engine-clean gate permanently red with nothing
9
+ * actually un-durable. Binary sections DO carry the ground truth, though:
10
+ * `git diff --binary` always records full blob hashes on the section's
11
+ * `index <old>..<new>` line. Comparing the live file's `git hash-object`
12
+ * against the last owning section's new-side hash settles the
13
+ * classification exactly. Bodies without a usable hash (hand-written
14
+ * diffs, `Binary files … differ`) classify as `binary-unsupported` —
15
+ * an honest "cannot compare" instead of a false "not durable".
16
+ *
17
+ * Lives in its own module (not `status-classify.ts`) so the classifier
18
+ * stays within the per-file line budget and this module never has to
19
+ * import it back (dpdm-clean: the dependency edge points one way).
20
+ */
21
+ import { join } from 'node:path';
22
+ import { toError } from '../utils/errors.js';
23
+ import { readText } from '../utils/fs.js';
24
+ import { verbose } from '../utils/logger.js';
25
+ import { hashObjectBatch, isBinaryFile } from './git-file-ops.js';
26
+ import { findPatchesAffectingFile } from './patch-manifest.js';
27
+ import { parseDiffSections } from './patch-parse.js';
28
+ /** Finds the LAST diff section affecting `file` across the owning patches, in queue order. */
29
+ async function findLastAffectingSection(patchesDir, file, lookup) {
30
+ // The lookup shares one manifest load + memoized body reads across the
31
+ // whole classification batch; the fallback keeps single-file callers
32
+ // (and existing direct tests) working without a context.
33
+ const patches = lookup !== undefined
34
+ ? lookup.getAffectingPatches(file)
35
+ : (await findPatchesAffectingFile(patchesDir, file)).map(({ patch }) => patch);
36
+ let last;
37
+ for (const patch of patches) {
38
+ const body = lookup !== undefined ? await lookup.readPatchBody(patch) : await readText(patch.path);
39
+ for (const section of parseDiffSections(body)) {
40
+ if (section.targetPath === file || section.sourcePath === file) {
41
+ last = section;
42
+ }
43
+ }
44
+ }
45
+ return last;
46
+ }
47
+ /**
48
+ * Classifies a single-owner file whose comparison is binary. Returns
49
+ * `null` when the comparison is NOT binary — the live file is text and
50
+ * (for deletions) the owning section is text — so the caller falls
51
+ * through to the existing utf-8 content comparison unchanged.
52
+ *
53
+ * @param args.fileMissing - True when the porcelain status reports the
54
+ * file deleted (there are no live bytes to probe or hash).
55
+ */
56
+ export async function classifyBinaryOwnedFile(args) {
57
+ const { entry, engineDir, patchesDir, matchClassification, owner } = args;
58
+ const fileMissing = args.fileMissing === true;
59
+ const unsupported = () => ({
60
+ ...entry,
61
+ classification: 'binary-unsupported',
62
+ owner,
63
+ });
64
+ // Until the comparison is KNOWN to be binary, every failure defers to
65
+ // the existing text path (`return null`) — classifying a text file as
66
+ // `binary-unsupported` because a probe failed would hide real drift.
67
+ let section;
68
+ try {
69
+ if (!fileMissing) {
70
+ const liveBinary = await isBinaryFile(engineDir, entry.file);
71
+ if (!liveBinary)
72
+ return null;
73
+ }
74
+ section = await findLastAffectingSection(patchesDir, entry.file, args.lookup);
75
+ }
76
+ catch (error) {
77
+ if (fileMissing)
78
+ return null;
79
+ verbose(`Classifying ${entry.file} as binary-unsupported because the binary probe failed: ${toError(error).message}`);
80
+ return unsupported();
81
+ }
82
+ try {
83
+ if (section === undefined) {
84
+ // Claimed in filesAffected but no diff section touches the file.
85
+ // A missing file keeps the existing deletion logic; a live binary
86
+ // has nothing recorded to compare against.
87
+ return fileMissing ? null : unsupported();
88
+ }
89
+ if (!section.isBinary) {
90
+ // Text section: deletions settle fine through the text path; a
91
+ // live binary against a text body has no comparable recording.
92
+ return fileMissing ? null : unsupported();
93
+ }
94
+ if (section.isDeletedFile) {
95
+ return {
96
+ ...entry,
97
+ classification: fileMissing ? matchClassification : 'patch-owned-drift',
98
+ owner,
99
+ };
100
+ }
101
+ if (fileMissing) {
102
+ // The last binary section expects the file to exist.
103
+ return { ...entry, classification: 'patch-owned-drift', owner };
104
+ }
105
+ if (section.indexNewHash !== undefined) {
106
+ const fullPath = join(engineDir, entry.file);
107
+ const live = (await hashObjectBatch(engineDir, [fullPath])).get(fullPath);
108
+ if (live !== undefined) {
109
+ // Prefix-compare: recorded hashes may be abbreviated (≥7 chars).
110
+ const matches = live.startsWith(section.indexNewHash) || section.indexNewHash.startsWith(live);
111
+ return {
112
+ ...entry,
113
+ classification: matches ? matchClassification : 'patch-owned-drift',
114
+ owner,
115
+ };
116
+ }
117
+ }
118
+ return unsupported();
119
+ }
120
+ catch (error) {
121
+ verbose(`Classifying ${entry.file} as binary-unsupported because the binary comparison failed: ${toError(error).message}`);
122
+ return unsupported();
123
+ }
124
+ }
@@ -34,8 +34,13 @@
34
34
  * cross-patch conflicts silently rolled into the `unmanaged` bucket
35
35
  * in `--json`, which misled scripts built on top of the JSON view
36
36
  * into treating the file as routine local drift.
37
+ * - `binary-unsupported`: the file's comparison is binary and the owning
38
+ * patch body records no usable blob hash to compare against — an
39
+ * honest "binary — comparison unsupported" instead of a permanent
40
+ * false `patch-owned-drift`. Deliberately NOT in the default
41
+ * `--check` fail set; opt in with `--fail-on binary-unsupported`.
37
42
  */
38
- export type FileClassification = 'patch-backed' | 'patch-owned-drift' | 'unmanaged' | 'branding' | 'furnace' | 'conflict';
43
+ export type FileClassification = 'patch-backed' | 'patch-owned-drift' | 'unmanaged' | 'branding' | 'furnace' | 'conflict' | 'binary-unsupported';
39
44
  export interface StatusFile {
40
45
  status: string;
41
46
  file: string;
@@ -7,12 +7,13 @@
7
7
  * under the per-file line budget as the number of buckets grows.
8
8
  */
9
9
  import { join } from 'node:path';
10
+ import { mapWithConcurrency } from '../utils/concurrency.js';
10
11
  import { toError } from '../utils/errors.js';
11
12
  import { readText } from '../utils/fs.js';
12
13
  import { verbose } from '../utils/logger.js';
13
14
  import { isBrandingManagedPath } from './branding.js';
14
- import { computePatchedContent } from './patch-apply.js';
15
- import { loadPatchesManifest } from './patch-manifest.js';
15
+ import { createPatchedContentContext } from './patch-apply.js';
16
+ import { classifyBinaryOwnedFile } from './status-binary.js';
16
17
  /**
17
18
  * Builds the file → owning-patch-filenames multimap from manifest rows.
18
19
  * Single source of truth for cross-patch claim detection — status
@@ -71,10 +72,27 @@ export function isGeneratedBrandingPath(file, binaryName) {
71
72
  * deletion the patch does not expect, or a failed comparison) reports
72
73
  * `patch-owned-drift`.
73
74
  */
74
- async function classifySingleOwnerFile(entry, engineDir, patchesDir, matchClassification, owner) {
75
+ async function classifySingleOwnerFile(entry, engineDir, patchesDir, matchClassification, owner, ctx) {
76
+ // Binary comparisons cannot go through the utf-8 content path below —
77
+ // binary patch bodies parse to zero hunks, so the patched-content
78
+ // computation returned HEAD content unchanged and the comparison
79
+ // reported `patch-owned-drift` forever (FORGE J3). Settle by blob hash,
80
+ // or classify explicitly as `binary-unsupported` when no hash exists.
81
+ const binaryResult = await classifyBinaryOwnedFile({
82
+ entry,
83
+ engineDir,
84
+ patchesDir,
85
+ matchClassification,
86
+ owner,
87
+ fileMissing: getPrimaryStatusCode(entry.status) === 'D',
88
+ lookup: ctx,
89
+ });
90
+ if (binaryResult !== null) {
91
+ return binaryResult;
92
+ }
75
93
  if (getPrimaryStatusCode(entry.status) === 'D') {
76
94
  // Deleted file: content matches only if the patch expects deletion
77
- const expected = await computePatchedContent(patchesDir, engineDir, entry.file);
95
+ const expected = await ctx.computePatched(entry.file);
78
96
  return {
79
97
  ...entry,
80
98
  classification: expected === null ? matchClassification : 'patch-owned-drift',
@@ -84,7 +102,7 @@ async function classifySingleOwnerFile(entry, engineDir, patchesDir, matchClassi
84
102
  // File exists on disk — compare actual vs expected
85
103
  try {
86
104
  const [expected, actual] = await Promise.all([
87
- computePatchedContent(patchesDir, engineDir, entry.file),
105
+ ctx.computePatched(entry.file),
88
106
  readText(join(engineDir, entry.file)),
89
107
  ]);
90
108
  return {
@@ -112,7 +130,11 @@ async function classifySingleOwnerFile(entry, engineDir, patchesDir, matchClassi
112
130
  * machine consumers about the nature of the drift.
113
131
  */
114
132
  export async function classifyFiles(files, engineDir, patchesDir, binaryName, furnacePrefixes) {
115
- const manifest = await loadPatchesManifest(patchesDir);
133
+ // One manifest load + patch discovery + memoized body reads for the
134
+ // whole batch — the previous per-file computation re-ran all three for
135
+ // every dirty file (O(dirtyFiles × patches) redundant IO on a broad
136
+ // engine edit session, FORGE K1).
137
+ const ctx = await createPatchedContentContext(patchesDir, engineDir);
116
138
  // Build a multimap from file path → list of claiming patch
117
139
  // filenames so we can detect cross-patch ownership conflicts. The
118
140
  // previous `Set<string>` captured only whether a path was claimed,
@@ -120,75 +142,81 @@ export async function classifyFiles(files, engineDir, patchesDir, binaryName, fu
120
142
  // branch where the expected-vs-actual content comparison then routed
121
143
  // them into `unmanaged` when the content didn't match either owner's
122
144
  // expectation.
123
- const patchClaims = manifest ? buildPatchClaims(manifest.patches) : new Map();
124
- const results = [];
125
- for (const entry of files) {
126
- const owners = patchClaims.get(entry.file);
127
- const primaryCode = getPrimaryStatusCode(entry.status);
128
- // Branding paths are tool-managed for generated edits, but a brand-new
129
- // unowned branding asset must not disappear from `status --unmanaged`.
130
- // The Hominis Firefox 152 side-grade added Assets.car under the active
131
- // branding tree; classifying every branding path before checking
132
- // ownership hid that new patch candidate as "branding" even though no
133
- // patch claimed it yet.
134
- const isUnownedNewFile = owners === undefined && (primaryCode === '?' || primaryCode === 'A');
135
- if (isBrandingManagedPath(entry.file, binaryName) &&
136
- (!isUnownedNewFile || isGeneratedBrandingPath(entry.file, binaryName))) {
137
- results.push({ ...entry, classification: 'branding' });
138
- continue;
139
- }
140
- // Furnace-managed component paths
141
- if (furnacePrefixes.size > 0) {
142
- let isFurnace = false;
143
- for (const prefix of furnacePrefixes) {
144
- if (entry.file.startsWith(prefix)) {
145
- isFurnace = true;
146
- break;
147
- }
148
- }
149
- if (isFurnace) {
150
- // A furnace path claimed by exactly one patch gets the same
151
- // expected-vs-actual comparison as any other single-owner path:
152
- // after a `furnace deploy` of an edited component the deployed
153
- // copy has content the owning patch's body lacks, and the old
154
- // unconditional short-circuit silently bucketed that drift as
155
- // `furnace`. Multi-owner and unowned furnace paths keep the
156
- // short-circuit — the ownership table independently flags
157
- // filesAffected conflicts.
158
- if (owners && owners.length === 1 && owners[0] !== undefined) {
159
- results.push(await classifySingleOwnerFile(entry, engineDir, patchesDir, 'furnace', owners[0]));
160
- }
161
- else {
162
- results.push({ ...entry, classification: 'furnace' });
163
- }
164
- continue;
145
+ const patchClaims = buildPatchClaims(ctx.manifestPatches);
146
+ const deps = {
147
+ engineDir,
148
+ patchesDir,
149
+ binaryName,
150
+ furnacePrefixes,
151
+ patchClaims,
152
+ ctx,
153
+ };
154
+ // Bounded pool over per-file classification (each single-owner file
155
+ // spawns git); order preserved by the mapper. Per-file failures settle
156
+ // inside classifySingleOwnerFile's catch, so one bad file never rejects
157
+ // the batch.
158
+ return mapWithConcurrency(files, CLASSIFY_CONCURRENCY, (entry) => classifyEntry(entry, deps));
159
+ }
160
+ /** Concurrency bound for per-file classification (matches import's guard). */
161
+ const CLASSIFY_CONCURRENCY = 8;
162
+ /** Classifies one status entry; extracted so the pool worker stays flat. */
163
+ async function classifyEntry(entry, deps) {
164
+ const { engineDir, patchesDir, binaryName, furnacePrefixes, patchClaims, ctx } = deps;
165
+ const owners = patchClaims.get(entry.file);
166
+ const primaryCode = getPrimaryStatusCode(entry.status);
167
+ // Ownership is a structural invariant and takes precedence over the
168
+ // content-management bucket. Otherwise a branding/Furnace prefix hides a
169
+ // multi-patch claim from status --check/--json even though --ownership
170
+ // reports the same path as CONFLICT.
171
+ if (owners && owners.length >= 2) {
172
+ return {
173
+ ...entry,
174
+ classification: 'conflict',
175
+ claimedBy: [...owners],
176
+ };
177
+ }
178
+ // Branding paths are tool-managed for generated edits, but a brand-new
179
+ // unowned branding asset must not disappear from `status --unmanaged`.
180
+ // The Hominis Firefox 152 side-grade added Assets.car under the active
181
+ // branding tree; classifying every branding path before checking
182
+ // ownership hid that new patch candidate as "branding" even though no
183
+ // patch claimed it yet.
184
+ const isUnownedNewFile = owners === undefined && (primaryCode === '?' || primaryCode === 'A');
185
+ if (isBrandingManagedPath(entry.file, binaryName) &&
186
+ (!isUnownedNewFile || isGeneratedBrandingPath(entry.file, binaryName))) {
187
+ return { ...entry, classification: 'branding' };
188
+ }
189
+ // Furnace-managed component paths
190
+ if (furnacePrefixes.size > 0) {
191
+ let isFurnace = false;
192
+ for (const prefix of furnacePrefixes) {
193
+ if (entry.file.startsWith(prefix)) {
194
+ isFurnace = true;
195
+ break;
165
196
  }
166
197
  }
167
- // Multiple patches claim this file — surface the cross-patch
168
- // ownership conflict regardless of whether the current content
169
- // matches any single claim. `--ownership` reports the same state
170
- // as `CONFLICT`; `--json` must agree so machine consumers of the
171
- // two views see the same truth.
172
- if (owners && owners.length >= 2) {
173
- results.push({
174
- ...entry,
175
- classification: 'conflict',
176
- claimedBy: [...owners],
177
- });
178
- continue;
179
- }
180
- // Not in any patch → unmanaged
181
- if (!owners) {
182
- results.push({ ...entry, classification: 'unmanaged' });
183
- continue;
184
- }
185
- // File is claimed by exactly one patch — compare content.
186
- const owner = owners[0];
187
- if (owner === undefined) {
188
- results.push({ ...entry, classification: 'unmanaged' });
189
- continue;
198
+ if (isFurnace) {
199
+ // A furnace path claimed by exactly one patch gets the same
200
+ // expected-vs-actual comparison as any other single-owner path:
201
+ // after a `furnace deploy` of an edited component the deployed
202
+ // copy has content the owning patch's body lacks, and the old
203
+ // unconditional short-circuit silently bucketed that drift as
204
+ // `furnace`. Multi-owner and unowned furnace paths keep the
205
+ // short-circuit.
206
+ if (owners && owners.length === 1 && owners[0] !== undefined) {
207
+ return classifySingleOwnerFile(entry, engineDir, patchesDir, 'furnace', owners[0], ctx);
208
+ }
209
+ return { ...entry, classification: 'furnace' };
190
210
  }
191
- results.push(await classifySingleOwnerFile(entry, engineDir, patchesDir, 'patch-backed', owner));
192
211
  }
193
- return results;
212
+ // Not in any patch → unmanaged
213
+ if (!owners) {
214
+ return { ...entry, classification: 'unmanaged' };
215
+ }
216
+ // File is claimed by exactly one patch — compare content.
217
+ const owner = owners[0];
218
+ if (owner === undefined) {
219
+ return { ...entry, classification: 'unmanaged' };
220
+ }
221
+ return classifySingleOwnerFile(entry, engineDir, patchesDir, 'patch-backed', owner, ctx);
194
222
  }
@@ -28,8 +28,15 @@ export interface HarnessCrashSignature {
28
28
  reason: string;
29
29
  line: string;
30
30
  }
31
+ /** Numeric counts parsed from the harness's embedded result summary. */
32
+ export interface HarnessSummaryCounts {
33
+ /** Total checks the suite ran (`Ran N checks`), when the summary prints one. */
34
+ checks?: number;
35
+ /** Unexpected-result count (`Unexpected results: N`), when the summary prints one. */
36
+ unexpected?: number;
37
+ }
31
38
  /** Result of {@link classifyHarnessRun}. */
32
- export interface HarnessRunVerdict {
39
+ export interface HarnessRunVerdict extends HarnessSummaryCounts {
33
40
  kind: HarnessRunClassification;
34
41
  signature?: HarnessCrashSignature;
35
42
  /** First concrete test-failure evidence line, when available. */
@@ -146,6 +153,30 @@ export declare function detectHarnessCrashSignature(output: string): HarnessCras
146
153
  * (`greenSummaryRejected`) and fails.
147
154
  */
148
155
  export declare function classifyHarnessRun(exitCode: number, output: string, requestedPaths: readonly string[]): HarnessRunVerdict;
156
+ /**
157
+ * Parses the numeric counts from the harness's embedded result summary
158
+ * (`Ran N checks` / `Unexpected results: N`). The LAST occurrence of each
159
+ * wins — a multi-suite run prints one summary per suite and the final one
160
+ * covers the aggregate the operator sees. Absent counts are omitted (the
161
+ * `FIREFORGE-VERDICT:` contract: an absent key means "summary did not
162
+ * print it", never zero).
163
+ */
164
+ export declare function extractSummaryCounts(output: string): HarnessSummaryCounts;
165
+ /**
166
+ * Formats the machine-readable `FIREFORGE-VERDICT:` line — the stable,
167
+ * greppable last line every `fireforge test` run prints so harness-verdict
168
+ * consumers stop regexing mach internals. The status follows THIS
169
+ * classifier, never the raw exit code: a crash-classified run says
170
+ * `FAIL reason=crash` even at exit 0, and a green-summary-override pass
171
+ * says `PASS` despite a non-zero mach exit. Count keys are omitted when
172
+ * the embedded summary did not print them. Sharded aggregate lines carry
173
+ * a trailing `shards=<passed>/<total>` instead of counts (counts belong
174
+ * to a single embedded suite summary).
175
+ */
176
+ export declare function formatFireforgeVerdictLine(verdict: HarnessRunVerdict, shards?: {
177
+ passed: number;
178
+ total: number;
179
+ }): string;
149
180
  /**
150
181
  * Builds the operator-facing failure message for a green-looking summary
151
182
  * that was rejected on crash or truncation evidence (never trusted as a
@@ -447,20 +447,21 @@ export function detectHarnessCrashSignature(output) {
447
447
  * (`greenSummaryRejected`) and fails.
448
448
  */
449
449
  export function classifyHarnessRun(exitCode, output, requestedPaths) {
450
+ const counts = extractSummaryCounts(output);
450
451
  const realFailures = realUnexpectedFailureLines(output);
451
452
  const firstRealFailure = realFailures[0];
452
453
  const failureBlocks = collectUnexpectedFailureBlocks(output);
453
454
  const secondaryHarnessSignature = firstRealFailure !== undefined ? detectSecondaryHarnessNoise(output) : undefined;
454
455
  const signature = detectHarnessCrashSignature(output);
455
456
  if (signature) {
456
- return { kind: 'harness-crash', signature };
457
+ return { kind: 'harness-crash', signature, ...counts };
457
458
  }
458
459
  const ranTests = hasExecutionSignal(output);
459
460
  if (!ranTests && requestedPaths.length > 0) {
460
- return { kind: 'no-tests' };
461
+ return { kind: 'no-tests', ...counts };
461
462
  }
462
463
  if (exitCode === 0) {
463
- return { kind: 'tests-ran-ok' };
464
+ return { kind: 'tests-ran-ok', ...counts };
464
465
  }
465
466
  // Exit codes follow the corrected verdict: a run whose embedded summary
466
467
  // completed green is a pass even when the wrapper exit code went
@@ -483,6 +484,7 @@ export function classifyHarnessRun(exitCode, output, requestedPaths) {
483
484
  completeness.neverEnded.length > 0) {
484
485
  return {
485
486
  kind: 'test-failures',
487
+ ...counts,
486
488
  ...(firstRealFailure !== undefined ? { realFailureLine: firstRealFailure } : {}),
487
489
  ...(failureBlocks.length > 0 ? { realFailureBlocks: failureBlocks } : {}),
488
490
  ...(secondaryHarnessSignature !== undefined ? { secondaryHarnessSignature } : {}),
@@ -493,15 +495,68 @@ export function classifyHarnessRun(exitCode, output, requestedPaths) {
493
495
  },
494
496
  };
495
497
  }
496
- return { kind: 'tests-ran-ok', greenSummaryOverride: true };
498
+ return { kind: 'tests-ran-ok', greenSummaryOverride: true, ...counts };
497
499
  }
498
500
  return {
499
501
  kind: 'test-failures',
502
+ ...counts,
500
503
  ...(firstRealFailure !== undefined ? { realFailureLine: firstRealFailure } : {}),
501
504
  ...(failureBlocks.length > 0 ? { realFailureBlocks: failureBlocks } : {}),
502
505
  ...(secondaryHarnessSignature !== undefined ? { secondaryHarnessSignature } : {}),
503
506
  };
504
507
  }
508
+ /**
509
+ * Parses the numeric counts from the harness's embedded result summary
510
+ * (`Ran N checks` / `Unexpected results: N`). The LAST occurrence of each
511
+ * wins — a multi-suite run prints one summary per suite and the final one
512
+ * covers the aggregate the operator sees. Absent counts are omitted (the
513
+ * `FIREFORGE-VERDICT:` contract: an absent key means "summary did not
514
+ * print it", never zero).
515
+ */
516
+ export function extractSummaryCounts(output) {
517
+ const checks = lastCapturedCount(output, /\bRan (\d+) checks?\b/g);
518
+ const unexpected = lastCapturedCount(output, /\bUnexpected results:\s*(\d+)\b/g);
519
+ return {
520
+ ...(checks !== undefined ? { checks } : {}),
521
+ ...(unexpected !== undefined ? { unexpected } : {}),
522
+ };
523
+ }
524
+ /** Last captured integer for `pattern` in `output`, if any occurrence matches. */
525
+ function lastCapturedCount(output, pattern) {
526
+ let last;
527
+ for (const match of output.matchAll(pattern)) {
528
+ const captured = match[1];
529
+ if (captured !== undefined) {
530
+ last = Number.parseInt(captured, 10);
531
+ }
532
+ }
533
+ return last;
534
+ }
535
+ /**
536
+ * Formats the machine-readable `FIREFORGE-VERDICT:` line — the stable,
537
+ * greppable last line every `fireforge test` run prints so harness-verdict
538
+ * consumers stop regexing mach internals. The status follows THIS
539
+ * classifier, never the raw exit code: a crash-classified run says
540
+ * `FAIL reason=crash` even at exit 0, and a green-summary-override pass
541
+ * says `PASS` despite a non-zero mach exit. Count keys are omitted when
542
+ * the embedded summary did not print them. Sharded aggregate lines carry
543
+ * a trailing `shards=<passed>/<total>` instead of counts (counts belong
544
+ * to a single embedded suite summary).
545
+ */
546
+ export function formatFireforgeVerdictLine(verdict, shards) {
547
+ const counts = (verdict.checks !== undefined ? ` checks=${verdict.checks}` : '') +
548
+ (verdict.unexpected !== undefined ? ` unexpected=${verdict.unexpected}` : '');
549
+ const shardSuffix = shards ? ` shards=${shards.passed}/${shards.total}` : '';
550
+ if (verdict.kind === 'tests-ran-ok') {
551
+ return `FIREFORGE-VERDICT: PASS${counts}${shardSuffix}`;
552
+ }
553
+ const reason = verdict.kind === 'harness-crash'
554
+ ? 'crash'
555
+ : verdict.kind === 'no-tests'
556
+ ? 'no-tests'
557
+ : 'test-failures';
558
+ return `FIREFORGE-VERDICT: FAIL reason=${reason}${counts}${shardSuffix}`;
559
+ }
505
560
  /**
506
561
  * Builds the operator-facing failure message for a green-looking summary
507
562
  * that was rejected on crash or truncation evidence (never trusted as a
@@ -39,6 +39,7 @@ async function collectTestFiles(dir, relPrefix) {
39
39
  entries = await readdir(dir, { withFileTypes: true });
40
40
  }
41
41
  catch {
42
+ // An unreadable directory contributes no test files to the scope.
42
43
  return [];
43
44
  }
44
45
  for (const entry of entries) {
@@ -57,6 +58,7 @@ async function isDirectorySafe(path) {
57
58
  return (await stat(path)).isDirectory();
58
59
  }
59
60
  catch {
61
+ // A path that cannot be stat'd is not a directory for scoping purposes.
60
62
  return false;
61
63
  }
62
64
  }