@hominis/fireforge 0.50.0 → 0.51.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 (205) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/build-info.json +4 -4
  3. package/dist/src/commands/check-report.d.ts +17 -4
  4. package/dist/src/commands/check-report.js +19 -6
  5. package/dist/src/commands/check-run.d.ts +5 -0
  6. package/dist/src/commands/check-run.js +9 -1
  7. package/dist/src/commands/check-steps.d.ts +8 -0
  8. package/dist/src/commands/check-steps.js +31 -6
  9. package/dist/src/commands/check.js +1 -1
  10. package/dist/src/commands/declarations-report.d.ts +43 -0
  11. package/dist/src/commands/declarations-report.js +76 -0
  12. package/dist/src/commands/export-placement-gate.js +8 -0
  13. package/dist/src/commands/furnace/rename-registrations.js +7 -2
  14. package/dist/src/commands/furnace/validate.js +26 -1
  15. package/dist/src/commands/lint-per-patch.js +6 -2
  16. package/dist/src/commands/lint-per-run-checkjs.js +2 -2
  17. package/dist/src/commands/lint-report.js +7 -2
  18. package/dist/src/commands/manifest.js +2 -0
  19. package/dist/src/commands/park.d.ts +22 -0
  20. package/dist/src/commands/park.js +232 -0
  21. package/dist/src/commands/patch/delete.js +11 -6
  22. package/dist/src/commands/patch/index.d.ts +3 -3
  23. package/dist/src/commands/patch/index.js +8 -4
  24. package/dist/src/commands/patch/lint-ignore-from.d.ts +44 -0
  25. package/dist/src/commands/patch/lint-ignore-from.js +142 -0
  26. package/dist/src/commands/patch/lint-ignore.js +39 -29
  27. package/dist/src/commands/patch/move-files-create.js +11 -4
  28. package/dist/src/commands/patch/move-files-into.js +10 -4
  29. package/dist/src/commands/patch/move-files.js +3 -1
  30. package/dist/src/commands/patch/removal-created-files.d.ts +48 -0
  31. package/dist/src/commands/patch/removal-created-files.js +83 -0
  32. package/dist/src/commands/patch/rename.js +15 -3
  33. package/dist/src/commands/patch/reserve.d.ts +33 -0
  34. package/dist/src/commands/patch/reserve.js +152 -0
  35. package/dist/src/commands/patch/retire.js +11 -6
  36. package/dist/src/commands/patch/split-plan.d.ts +10 -1
  37. package/dist/src/commands/patch/split-plan.js +16 -1
  38. package/dist/src/commands/patch/split.js +14 -5
  39. package/dist/src/commands/patch/stage.d.ts +34 -0
  40. package/dist/src/commands/patch/stage.js +153 -0
  41. package/dist/src/commands/patch/staged-dependency-remove.d.ts +31 -0
  42. package/dist/src/commands/patch/staged-dependency-remove.js +80 -0
  43. package/dist/src/commands/patch/staged-dependency.js +40 -98
  44. package/dist/src/commands/re-export-adjacent.d.ts +5 -0
  45. package/dist/src/commands/re-export-adjacent.js +4 -3
  46. package/dist/src/commands/re-export-bulk-scan.d.ts +2 -1
  47. package/dist/src/commands/re-export-bulk-scan.js +22 -7
  48. package/dist/src/commands/re-export-hunks.d.ts +35 -0
  49. package/dist/src/commands/re-export-hunks.js +220 -0
  50. package/dist/src/commands/re-export-options.js +29 -2
  51. package/dist/src/commands/re-export-purity-scope.d.ts +18 -0
  52. package/dist/src/commands/re-export-purity-scope.js +131 -0
  53. package/dist/src/commands/re-export-register.js +15 -1
  54. package/dist/src/commands/re-export-removed.d.ts +1 -1
  55. package/dist/src/commands/re-export-removed.js +17 -2
  56. package/dist/src/commands/re-export-scan.d.ts +2 -0
  57. package/dist/src/commands/re-export-scan.js +4 -1
  58. package/dist/src/commands/re-export-single.js +102 -42
  59. package/dist/src/commands/re-export-stale-registrations.d.ts +18 -0
  60. package/dist/src/commands/re-export-stale-registrations.js +62 -0
  61. package/dist/src/commands/re-export.js +35 -34
  62. package/dist/src/commands/test-build-furnace.d.ts +3 -2
  63. package/dist/src/commands/test-build-furnace.js +8 -2
  64. package/dist/src/commands/test-build-phase.js +24 -7
  65. package/dist/src/commands/test-checkjs.js +3 -2
  66. package/dist/src/commands/test-dispatch.d.ts +39 -0
  67. package/dist/src/commands/test-dispatch.js +211 -0
  68. package/dist/src/commands/test-furnace-reach.d.ts +37 -0
  69. package/dist/src/commands/test-furnace-reach.js +132 -0
  70. package/dist/src/commands/test-orchestrate.d.ts +9 -0
  71. package/dist/src/commands/test-orchestrate.js +22 -4
  72. package/dist/src/commands/test-perf-scope.d.ts +21 -0
  73. package/dist/src/commands/test-perf-scope.js +55 -0
  74. package/dist/src/commands/test-prelock.js +100 -4
  75. package/dist/src/commands/test-register.js +18 -3
  76. package/dist/src/commands/test-results.d.ts +62 -0
  77. package/dist/src/commands/test-results.js +62 -0
  78. package/dist/src/commands/test-run.d.ts +13 -55
  79. package/dist/src/commands/test-run.js +73 -101
  80. package/dist/src/commands/test-shards.d.ts +110 -0
  81. package/dist/src/commands/test-shards.js +255 -0
  82. package/dist/src/commands/test-stale-gate.d.ts +5 -1
  83. package/dist/src/commands/test-stale-gate.js +4 -2
  84. package/dist/src/commands/test-verdict.d.ts +2 -1
  85. package/dist/src/commands/test.d.ts +1 -1
  86. package/dist/src/commands/test.js +65 -75
  87. package/dist/src/commands/token-lint.js +48 -8
  88. package/dist/src/commands/verify.js +9 -2
  89. package/dist/src/core/build-prepare.d.ts +6 -0
  90. package/dist/src/core/build-prepare.js +29 -3
  91. package/dist/src/core/config-paths.d.ts +1 -1
  92. package/dist/src/core/config-paths.js +2 -0
  93. package/dist/src/core/config-validate-check.js +83 -7
  94. package/dist/src/core/config-validate-test-toolchains.js +14 -0
  95. package/dist/src/core/config-validate-token-scales.d.ts +3 -0
  96. package/dist/src/core/config-validate-token-scales.js +47 -5
  97. package/dist/src/core/config-validate.js +6 -0
  98. package/dist/src/core/coverage-extend.js +2 -2
  99. package/dist/src/core/coverage-records.d.ts +11 -0
  100. package/dist/src/core/coverage-records.js +34 -0
  101. package/dist/src/core/engine-session-lock.d.ts +6 -0
  102. package/dist/src/core/engine-session-lock.js +10 -2
  103. package/dist/src/core/file-lock-shared.d.ts +57 -0
  104. package/dist/src/core/file-lock-shared.js +117 -0
  105. package/dist/src/core/file-lock.d.ts +9 -0
  106. package/dist/src/core/file-lock.js +58 -2
  107. package/dist/src/core/furnace-apply.js +10 -10
  108. package/dist/src/core/furnace-config-validate-exceptions.d.ts +10 -3
  109. package/dist/src/core/furnace-config-validate-exceptions.js +39 -8
  110. package/dist/src/core/furnace-config-validate.js +7 -1
  111. package/dist/src/core/furnace-registration-jar-inplace.d.ts +12 -0
  112. package/dist/src/core/furnace-registration-jar-inplace.js +80 -0
  113. package/dist/src/core/furnace-validate-accessibility.d.ts +4 -2
  114. package/dist/src/core/furnace-validate-accessibility.js +61 -27
  115. package/dist/src/core/furnace-validate-exceptions.d.ts +12 -4
  116. package/dist/src/core/furnace-validate-exceptions.js +41 -4
  117. package/dist/src/core/furnace-validate-icon-button.js +30 -1
  118. package/dist/src/core/history-log-file.js +8 -1
  119. package/dist/src/core/line-prefixer.d.ts +18 -0
  120. package/dist/src/core/line-prefixer.js +34 -0
  121. package/dist/src/core/mach.d.ts +7 -0
  122. package/dist/src/core/mach.js +24 -9
  123. package/dist/src/core/module-resolution-preflight.d.ts +19 -3
  124. package/dist/src/core/module-resolution-preflight.js +24 -6
  125. package/dist/src/core/negative-control-compiled.d.ts +27 -0
  126. package/dist/src/core/negative-control-compiled.js +73 -0
  127. package/dist/src/core/negative-control-diff.d.ts +14 -2
  128. package/dist/src/core/negative-control-diff.js +49 -6
  129. package/dist/src/core/negative-control.d.ts +12 -0
  130. package/dist/src/core/negative-control.js +48 -5
  131. package/dist/src/core/park-store.d.ts +63 -0
  132. package/dist/src/core/park-store.js +216 -0
  133. package/dist/src/core/patch-export-update.d.ts +4 -0
  134. package/dist/src/core/patch-export-update.js +12 -3
  135. package/dist/src/core/patch-export.d.ts +3 -2
  136. package/dist/src/core/patch-export.js +21 -14
  137. package/dist/src/core/patch-files.js +26 -8
  138. package/dist/src/core/patch-lint-checkjs-any-import.d.ts +6 -4
  139. package/dist/src/core/patch-lint-checkjs-any-import.js +9 -7
  140. package/dist/src/core/patch-lint-checkjs-diagnostics.d.ts +17 -0
  141. package/dist/src/core/patch-lint-checkjs-diagnostics.js +32 -0
  142. package/dist/src/core/patch-lint-checkjs.d.ts +10 -1
  143. package/dist/src/core/patch-lint-checkjs.js +13 -3
  144. package/dist/src/core/patch-lint-cross.d.ts +3 -1
  145. package/dist/src/core/patch-lint-cross.js +5 -0
  146. package/dist/src/core/patch-lint-css.js +14 -10
  147. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +5 -0
  148. package/dist/src/core/patch-lint-forward-registration-extended.js +1 -1
  149. package/dist/src/core/patch-lint-ignore.d.ts +56 -12
  150. package/dist/src/core/patch-lint-ignore.js +173 -43
  151. package/dist/src/core/patch-lint-ownership.d.ts +10 -0
  152. package/dist/src/core/patch-lint-ownership.js +26 -0
  153. package/dist/src/core/patch-lint-size.js +9 -1
  154. package/dist/src/core/patch-lint-token-scale.d.ts +26 -8
  155. package/dist/src/core/patch-lint-token-scale.js +206 -29
  156. package/dist/src/core/patch-lint.js +2 -2
  157. package/dist/src/core/patch-manifest-io.js +8 -1
  158. package/dist/src/core/patch-manifest-key-order.d.ts +24 -0
  159. package/dist/src/core/patch-manifest-key-order.js +61 -0
  160. package/dist/src/core/patch-manifest-stage.d.ts +46 -0
  161. package/dist/src/core/patch-manifest-stage.js +96 -0
  162. package/dist/src/core/patch-manifest-validate.js +27 -1
  163. package/dist/src/core/patch-order-legal.d.ts +2 -1
  164. package/dist/src/core/patch-order-legal.js +7 -2
  165. package/dist/src/core/patch-policy.d.ts +6 -2
  166. package/dist/src/core/patch-policy.js +14 -7
  167. package/dist/src/core/patch-retired.d.ts +26 -4
  168. package/dist/src/core/patch-retired.js +40 -2
  169. package/dist/src/core/run-log.d.ts +6 -0
  170. package/dist/src/core/run-log.js +17 -1
  171. package/dist/src/core/test-harness-crash.d.ts +2 -1
  172. package/dist/src/core/test-harness-crash.js +5 -8
  173. package/dist/src/core/test-harness-signature.d.ts +16 -0
  174. package/dist/src/core/test-harness-signature.js +19 -1
  175. package/dist/src/core/test-raw-log.d.ts +66 -0
  176. package/dist/src/core/test-raw-log.js +122 -0
  177. package/dist/src/core/test-run-completeness.d.ts +36 -3
  178. package/dist/src/core/test-run-completeness.js +57 -17
  179. package/dist/src/core/test-xpcshell-retry.d.ts +2 -0
  180. package/dist/src/core/test-xpcshell-retry.js +2 -1
  181. package/dist/src/core/token-lint.d.ts +5 -2
  182. package/dist/src/core/token-lint.js +8 -3
  183. package/dist/src/core/token-sibling-sheets.d.ts +46 -0
  184. package/dist/src/core/token-sibling-sheets.js +119 -0
  185. package/dist/src/core/tree-guard.js +2 -0
  186. package/dist/src/core/typecheck-shim.d.ts +5 -1
  187. package/dist/src/core/typecheck-shim.js +115 -2
  188. package/dist/src/core/typecheck.js +3 -1
  189. package/dist/src/index.d.ts +9 -3
  190. package/dist/src/index.js +4 -1
  191. package/dist/src/types/commands/index.d.ts +2 -2
  192. package/dist/src/types/commands/options.d.ts +68 -7
  193. package/dist/src/types/commands/patches.d.ts +43 -0
  194. package/dist/src/types/config.d.ts +74 -2
  195. package/dist/src/types/furnace.d.ts +20 -0
  196. package/dist/src/types/index.d.ts +1 -1
  197. package/dist/src/utils/options.d.ts +3 -2
  198. package/dist/src/utils/options.js +9 -4
  199. package/docs/configuration.md +94 -9
  200. package/docs/furnace.md +43 -1
  201. package/docs/lifecycle-invariants.md +13 -11
  202. package/docs/machine-output.md +64 -18
  203. package/docs/patch-workflow.md +174 -13
  204. package/docs/testing.md +73 -4
  205. package/package.json +3 -3
@@ -7,6 +7,7 @@ import { exec, execInherit, execInheritCapture, execSmokeRun, execStream, } from
7
7
  import { withBuildRunRecord } from './build-run-record.js';
8
8
  import { createSiblingLockPath, withFileLock } from './file-lock.js';
9
9
  import { ensureFirefoxIgnorefileCompatibility } from './firefox-ignorefile.js';
10
+ import { createLinePrefixer } from './line-prefixer.js';
10
11
  import { explainMachError } from './mach-error-hints.js';
11
12
  import { createKnownTeardownNoiseFilter, createTeardownNoiseContext, hasKnownTeardownNoise, KNOWN_TEARDOWN_NOISE_BUILD_NOTE, } from './mach-known-noise-filter.js';
12
13
  import { getPython } from './mach-python.js';
@@ -89,6 +90,15 @@ export async function runMachCapture(args, engineDir, options = {}) {
89
90
  const noiseContext = options.annotateKnownTeardownNoise === true ? createTeardownNoiseContext() : undefined;
90
91
  const stdoutFilter = noiseContext && createKnownTeardownNoiseFilter(noiseContext);
91
92
  const stderrFilter = noiseContext && createKnownTeardownNoiseFilter(noiseContext);
93
+ const prefix = options.outputPrefix;
94
+ const prefixers = prefix === undefined
95
+ ? undefined
96
+ : {
97
+ stdoutLog: createLinePrefixer(prefix),
98
+ stdoutEcho: createLinePrefixer(prefix),
99
+ stderrLog: createLinePrefixer(prefix),
100
+ stderrEcho: createLinePrefixer(prefix),
101
+ };
92
102
  const exitCode = await execStream(python, [machPath, ...args], {
93
103
  cwd: engineDir,
94
104
  ...(options.env ? { env: options.env } : {}),
@@ -110,8 +120,9 @@ export async function runMachCapture(args, engineDir, options = {}) {
110
120
  // echo filter only exists to shorten what a human scrolls past.
111
121
  // Collapsing a traceback in the artifact would reintroduce the loss
112
122
  // this log exists to prevent.
113
- writeToActiveRunLog(data);
114
- process.stdout.write(stdoutFilter ? stdoutFilter.transform(data) : data);
123
+ writeToActiveRunLog(prefixers ? prefixers.stdoutLog.transform(data) : data);
124
+ const echo = stdoutFilter ? stdoutFilter.transform(data) : data;
125
+ process.stdout.write(prefixers ? prefixers.stdoutEcho.transform(echo) : echo);
115
126
  },
116
127
  onStderr: (data) => {
117
128
  options.onOutputChunk?.('stderr', data);
@@ -119,19 +130,22 @@ export async function runMachCapture(args, engineDir, options = {}) {
119
130
  if (stderr.length > CAPTURE_TAIL_LIMIT) {
120
131
  stderr = stderr.slice(-CAPTURE_TAIL_LIMIT);
121
132
  }
122
- writeToActiveRunLog(data);
123
- process.stderr.write(stderrFilter ? stderrFilter.transform(data) : data);
133
+ writeToActiveRunLog(prefixers ? prefixers.stderrLog.transform(data) : data);
134
+ const echo = stderrFilter ? stderrFilter.transform(data) : data;
135
+ process.stderr.write(prefixers ? prefixers.stderrEcho.transform(echo) : echo);
124
136
  },
125
137
  });
126
138
  if (stdoutFilter) {
127
139
  const residue = stdoutFilter.flush();
128
- if (residue.length > 0)
129
- process.stdout.write(residue);
140
+ if (residue.length > 0) {
141
+ process.stdout.write(prefixers ? prefixers.stdoutEcho.transform(residue) : residue);
142
+ }
130
143
  }
131
144
  if (stderrFilter) {
132
145
  const residue = stderrFilter.flush();
133
- if (residue.length > 0)
134
- process.stderr.write(residue);
146
+ if (residue.length > 0) {
147
+ process.stderr.write(prefixers ? prefixers.stderrEcho.transform(residue) : residue);
148
+ }
135
149
  }
136
150
  return { stdout, stderr, exitCode };
137
151
  }
@@ -418,13 +432,14 @@ function testVerbosityEnvUnset(fullOutput) {
418
432
  * @param options - Engine checkout, test paths, extra args, env, verbosity
419
433
  */
420
434
  export async function runMachTestSuite(kind, options) {
421
- const { engineDir, testPaths = [], args = [], env, fullOutput, teardown } = options;
435
+ const { engineDir, testPaths = [], args = [], env, fullOutput, teardown, outputPrefix } = options;
422
436
  const guard = await installMachResourceGuard(engineDir);
423
437
  const envUnset = testVerbosityEnvUnset(fullOutput);
424
438
  return runMachCapture([kind, ...testPaths, ...args], engineDir, {
425
439
  env: { ...guard.env, ...env },
426
440
  ...(envUnset ? { envUnset } : {}),
427
441
  ...teardown,
442
+ ...(outputPrefix === undefined ? {} : { outputPrefix }),
428
443
  annotateKnownTeardownNoise: true,
429
444
  });
430
445
  }
@@ -18,6 +18,12 @@
18
18
  * no owned file is an upstream Firefox module, which FireForge does not
19
19
  * police and cannot cheaply enumerate. Policing only what the fork owns is
20
20
  * both the actionable set and the one that produces real incidents.
21
+ *
22
+ * An unscoped `verify` widens that set with the engine worktree: changed
23
+ * files import, and untracked files are targets. A chrome script's lazy
24
+ * getter for a new, not yet exported `.sys.mjs` with no moz.build line
25
+ * SIGSEGVed every start of a shared objdir, and neither file was in the
26
+ * queue yet, so the queue-only scan could not see it.
21
27
  */
22
28
  /** Why a queue-owned module import does not resolve to a packaged module. */
23
29
  export type UnresolvedModuleReason = 'missing-file' | 'unregistered';
@@ -31,16 +37,26 @@ export interface UnresolvedModuleImport {
31
37
  importers: string[];
32
38
  reason: UnresolvedModuleReason;
33
39
  }
40
+ /** Engine worktree files the preflight also judges, beside the queue's. */
41
+ export interface WorktreeModuleFiles {
42
+ /** Changed or untracked files: scanned for imports. */
43
+ changed: readonly string[];
44
+ /** Untracked files: new modules the fork has not exported yet. */
45
+ untracked: readonly string[];
46
+ }
47
+ /** Lists the engine's changed and untracked files for the worktree scan. */
48
+ export declare function collectWorktreeModuleFiles(engineDir: string): Promise<WorktreeModuleFiles>;
34
49
  /**
35
50
  * Resolves every `resource://` system-module import made by the
36
- * queue-owned files and reports the ones a packaged browser could not
37
- * load.
51
+ * queue-owned files (and, given `worktree`, by changed engine files) and
52
+ * reports the ones a packaged browser could not load.
38
53
  *
39
54
  * @param engineDir - Path to the engine checkout
40
55
  * @param ownedFiles - Engine-relative paths the patch queue owns
56
+ * @param worktree - Changed and untracked engine files to judge as well
41
57
  * @returns Findings sorted by module path. Empty when everything resolves
42
58
  */
43
- export declare function findUnresolvedSystemModuleImports(engineDir: string, ownedFiles: readonly string[]): Promise<UnresolvedModuleImport[]>;
59
+ export declare function findUnresolvedSystemModuleImports(engineDir: string, ownedFiles: readonly string[], worktree?: WorktreeModuleFiles): Promise<UnresolvedModuleImport[]>;
44
60
  /**
45
61
  * Renders findings as operator-facing lines, naming the unregistered or
46
62
  * unresolvable module, so the failure is a sentence instead of a bare
@@ -19,12 +19,19 @@
19
19
  * no owned file is an upstream Firefox module, which FireForge does not
20
20
  * police and cannot cheaply enumerate. Policing only what the fork owns is
21
21
  * both the actionable set and the one that produces real incidents.
22
+ *
23
+ * An unscoped `verify` widens that set with the engine worktree: changed
24
+ * files import, and untracked files are targets. A chrome script's lazy
25
+ * getter for a new, not yet exported `.sys.mjs` with no moz.build line
26
+ * SIGSEGVed every start of a shared objdir, and neither file was in the
27
+ * queue yet, so the queue-only scan could not see it.
22
28
  */
23
29
  import { dirname, join } from 'node:path';
24
30
  import { toError } from '../utils/errors.js';
25
31
  import { pathExists, readText } from '../utils/fs.js';
26
32
  import { verbose } from '../utils/logger.js';
27
33
  import { escapeRegex } from '../utils/regex.js';
34
+ import { expandUntrackedDirectoryEntries, getWorkingTreeStatus } from './git-status.js';
28
35
  import { extractResourceModuleSpecifiers, moduleSourceSuffix, } from './patch-lint-module-registration.js';
29
36
  /** Extensions whose contents are scanned for resource-module imports. */
30
37
  const IMPORTABLE_EXTENSIONS = ['.mjs', '.sys.mjs', '.js', '.jsm'];
@@ -79,20 +86,31 @@ async function isRegisteredInAncestorMozBuild(engineDir, moduleRelPath, mozBuild
79
86
  dir = parent;
80
87
  }
81
88
  }
89
+ /** Lists the engine's changed and untracked files for the worktree scan. */
90
+ export async function collectWorktreeModuleFiles(engineDir) {
91
+ const entries = await expandUntrackedDirectoryEntries(engineDir, await getWorkingTreeStatus(engineDir));
92
+ const present = entries.filter((entry) => entry.worktreeStatus !== 'D');
93
+ return {
94
+ changed: present.map((entry) => entry.file),
95
+ untracked: present.filter((entry) => entry.status === '??').map((entry) => entry.file),
96
+ };
97
+ }
82
98
  /**
83
99
  * Resolves every `resource://` system-module import made by the
84
- * queue-owned files and reports the ones a packaged browser could not
85
- * load.
100
+ * queue-owned files (and, given `worktree`, by changed engine files) and
101
+ * reports the ones a packaged browser could not load.
86
102
  *
87
103
  * @param engineDir - Path to the engine checkout
88
104
  * @param ownedFiles - Engine-relative paths the patch queue owns
105
+ * @param worktree - Changed and untracked engine files to judge as well
89
106
  * @returns Findings sorted by module path. Empty when everything resolves
90
107
  */
91
- export async function findUnresolvedSystemModuleImports(engineDir, ownedFiles) {
92
- const owned = [...new Set(ownedFiles)];
93
- /** specifier → importing owned files. */
108
+ export async function findUnresolvedSystemModuleImports(engineDir, ownedFiles, worktree) {
109
+ const owned = [...new Set([...ownedFiles, ...(worktree?.untracked ?? [])])];
110
+ const importing = [...new Set([...owned, ...(worktree?.changed ?? [])])];
111
+ /** specifier → importing files. */
94
112
  const importersBySpecifier = new Map();
95
- for (const file of owned) {
113
+ for (const file of importing) {
96
114
  if (!IMPORTABLE_EXTENSIONS.some((extension) => file.endsWith(extension)))
97
115
  continue;
98
116
  const content = await readIfPresent(join(engineDir, file));
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Which negative-control targets only a build can deliver.
3
+ *
4
+ * A control is applied after the pre-test build and right before dispatch,
5
+ * which is right for a file the runtime reads from the source tree or the
6
+ * objdir links to. A C++ (or Rust, IDL, …) source edited at that point is
7
+ * never compiled: the run tests the unmutated binary and reports
8
+ * `control=missed`, which reads as a test that cannot fail. Such a target
9
+ * is now refused without `--build`, and with `--build` the control is
10
+ * applied before the build so the binary under test carries it.
11
+ */
12
+ /**
13
+ * The engine-relative targets that reach the runtime only through a
14
+ * compile: a compiled-source extension and no install-manifest entry that
15
+ * ships the file itself.
16
+ *
17
+ * @param engineDir - Absolute engine directory
18
+ * @param enginePaths - The control's engine-relative targets
19
+ */
20
+ export declare function findCompiledControlTargets(engineDir: string, enginePaths: readonly string[]): Promise<string[]>;
21
+ /**
22
+ * The refusal for a compiled control without `--build`, naming the manual
23
+ * procedure for a run that wants to keep its build out of FireForge.
24
+ *
25
+ * @param compiled - Engine-relative compiled targets
26
+ */
27
+ export declare function formatCompiledControlRefusal(compiled: readonly string[]): string;
@@ -0,0 +1,73 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Which negative-control targets only a build can deliver.
4
+ *
5
+ * A control is applied after the pre-test build and right before dispatch,
6
+ * which is right for a file the runtime reads from the source tree or the
7
+ * objdir links to. A C++ (or Rust, IDL, …) source edited at that point is
8
+ * never compiled: the run tests the unmutated binary and reports
9
+ * `control=missed`, which reads as a test that cannot fail. Such a target
10
+ * is now refused without `--build`, and with `--build` the control is
11
+ * applied before the build so the binary under test carries it.
12
+ */
13
+ import { extname } from 'node:path';
14
+ import { classifyRuntimeDelivery } from './objdir-install-manifests.js';
15
+ import { loadEngineInstallIndex } from './test-stale-exempt.js';
16
+ /** Extensions of sources the build compiles or code-generates into the binary. */
17
+ const COMPILED_EXTENSIONS = new Set([
18
+ '.c',
19
+ '.cc',
20
+ '.cpp',
21
+ '.cxx',
22
+ '.h',
23
+ '.hh',
24
+ '.hpp',
25
+ '.m',
26
+ '.mm',
27
+ '.rs',
28
+ '.idl',
29
+ '.webidl',
30
+ '.ipdl',
31
+ '.ipdlh',
32
+ '.yaml',
33
+ '.yml',
34
+ ]);
35
+ /**
36
+ * The engine-relative targets that reach the runtime only through a
37
+ * compile: a compiled-source extension and no install-manifest entry that
38
+ * ships the file itself.
39
+ *
40
+ * @param engineDir - Absolute engine directory
41
+ * @param enginePaths - The control's engine-relative targets
42
+ */
43
+ export async function findCompiledControlTargets(engineDir, enginePaths) {
44
+ const candidates = enginePaths.filter((path) => COMPILED_EXTENSIONS.has(extname(path)));
45
+ if (candidates.length === 0)
46
+ return [];
47
+ const index = await loadEngineInstallIndex(engineDir);
48
+ if (index === undefined)
49
+ return candidates;
50
+ const compiled = [];
51
+ for (const path of candidates) {
52
+ if ((await classifyRuntimeDelivery(index, engineDir, path)) === 'unlisted') {
53
+ compiled.push(path);
54
+ }
55
+ }
56
+ return compiled;
57
+ }
58
+ /**
59
+ * The refusal for a compiled control without `--build`, naming the manual
60
+ * procedure for a run that wants to keep its build out of FireForge.
61
+ *
62
+ * @param compiled - Engine-relative compiled targets
63
+ */
64
+ export function formatCompiledControlRefusal(compiled) {
65
+ const files = compiled.map((path) => `engine/${path}`).join(', ');
66
+ return (`This negative control edits compiled source (${files}). Applied right before dispatch, ` +
67
+ 'it would never be compiled: the run would test the unmutated binary and report ' +
68
+ 'control=missed. Add --build: the control is then applied before the pre-test build, so ' +
69
+ 'the binary under test carries it, and restored afterwards with a fresh mtime so the next ' +
70
+ 'build recompiles the original. To run it by hand instead: copy the mutated file in, ' +
71
+ '`touch` it, `fireforge build`, `fireforge test`, restore the file byte-exact, `touch` it ' +
72
+ 'again and rebuild.');
73
+ }
@@ -18,15 +18,27 @@ export interface ControlDiffFile {
18
18
  /** The hunks to apply, in file order. */
19
19
  hunks: ParsedHunk[];
20
20
  }
21
+ /** Options for {@link parseControlDiff}. */
22
+ export interface ParseControlDiffOptions {
23
+ /**
24
+ * Called for a section whose old and new paths differ. Returns the path
25
+ * the control targets (a `git diff --no-index <file> <edited copy>` names
26
+ * the copy on its new side), or undefined for a real rename, which is
27
+ * refused.
28
+ */
29
+ resolveRename?: (sourcePath: string, targetPath: string) => string | undefined;
30
+ }
21
31
  /**
22
32
  * Parses a control diff. Accepts `git diff` output (a `diff --git` header
23
33
  * per file), which is what `git -C engine diff -- <file>` or a project-root
24
- * `git diff` writes.
34
+ * `git diff` writes, a plain unified diff (`diff -u`), and, through
35
+ * `resolveRename`, a `git diff --no-index` against an edited copy.
25
36
  *
26
37
  * @param content - The diff file's text
38
+ * @param options - See {@link ParseControlDiffOptions}
27
39
  * @returns One entry per file, in diff order
28
40
  */
29
- export declare function parseControlDiff(content: string): ControlDiffFile[];
41
+ export declare function parseControlDiff(content: string, options?: ParseControlDiffOptions): ControlDiffFile[];
30
42
  /**
31
43
  * Applies `hunks` to `original`, strictly.
32
44
  *
@@ -17,30 +17,73 @@ import { parseDiffSections } from './patch-parse.js';
17
17
  function refuse(message) {
18
18
  throw new GeneralError(`Negative control diff refused: ${message}`);
19
19
  }
20
+ /** A `---`/`+++` file name, without a trailing timestamp or git's `a/`/`b/`. */
21
+ function plainDiffPath(raw) {
22
+ const name = raw.split('\t')[0]?.trim() ?? '';
23
+ return name.startsWith('a/') || name.startsWith('b/') ? name.slice(2) : name;
24
+ }
25
+ /**
26
+ * Gives a plain unified diff (`diff -u`, no `diff --git` lines) the git
27
+ * header the section parser keys on, one per `---`/`+++` pair. A diff that
28
+ * already carries `diff --git` headers is returned unchanged.
29
+ *
30
+ * @param content - The diff file's text
31
+ */
32
+ function withGitHeaders(content) {
33
+ const lines = content.split('\n');
34
+ if (lines.some((line) => line.startsWith('diff --git ')))
35
+ return content;
36
+ const out = [];
37
+ for (let index = 0; index < lines.length; index += 1) {
38
+ const line = lines[index] ?? '';
39
+ const next = lines[index + 1] ?? '';
40
+ if (line.startsWith('--- ') && next.startsWith('+++ ')) {
41
+ const oldPath = plainDiffPath(line.slice(4));
42
+ const newPath = plainDiffPath(next.slice(4));
43
+ index += 1;
44
+ if (oldPath === '/dev/null' || newPath === '/dev/null') {
45
+ // Kept as a create/delete, which the parser below refuses by name.
46
+ const path = oldPath === '/dev/null' ? newPath : oldPath;
47
+ out.push(`diff --git a/${path} b/${path}`, oldPath === '/dev/null' ? 'new file mode 100644' : 'deleted file mode 100644', oldPath === '/dev/null' ? '--- /dev/null' : `--- a/${path}`, newPath === '/dev/null' ? '+++ /dev/null' : `+++ b/${path}`);
48
+ continue;
49
+ }
50
+ out.push(`diff --git a/${oldPath} b/${newPath}`, `--- a/${oldPath}`, `+++ b/${newPath}`);
51
+ continue;
52
+ }
53
+ out.push(line);
54
+ }
55
+ return out.join('\n');
56
+ }
20
57
  /**
21
58
  * Parses a control diff. Accepts `git diff` output (a `diff --git` header
22
59
  * per file), which is what `git -C engine diff -- <file>` or a project-root
23
- * `git diff` writes.
60
+ * `git diff` writes, a plain unified diff (`diff -u`), and, through
61
+ * `resolveRename`, a `git diff --no-index` against an edited copy.
24
62
  *
25
63
  * @param content - The diff file's text
64
+ * @param options - See {@link ParseControlDiffOptions}
26
65
  * @returns One entry per file, in diff order
27
66
  */
28
- export function parseControlDiff(content) {
29
- const sections = parseDiffSections(content);
67
+ export function parseControlDiff(content, options = {}) {
68
+ const sections = parseDiffSections(withGitHeaders(content));
30
69
  if (sections.length === 0) {
31
- refuse('no `diff --git` sections found. Write the control with git, e.g. ' +
70
+ refuse('no diff sections found. Write the control with git, e.g. ' +
32
71
  '`git -C engine diff -- <file> > control.diff` after making the edit, then revert the edit.');
33
72
  }
34
73
  const seen = new Set();
35
74
  return sections.map((section) => {
36
- const path = normalizePathSlashes(section.targetPath);
75
+ let path = normalizePathSlashes(section.targetPath);
37
76
  if (section.isBinary)
38
77
  refuse(`${path} is a binary section.`);
39
78
  if (section.isNewFile || section.isDeletedFile) {
40
79
  refuse(`${path} creates or deletes a file; a control may only modify existing text files.`);
41
80
  }
42
81
  if (section.sourcePath !== section.targetPath) {
43
- refuse(`${section.sourcePath} -> ${path} is a rename; a control may only modify files.`);
82
+ const resolved = options.resolveRename?.(section.sourcePath, section.targetPath);
83
+ if (resolved === undefined) {
84
+ refuse(`${section.sourcePath} -> ${path} is a rename; a control may only modify files.`);
85
+ }
86
+ path = normalizePathSlashes(resolved);
44
87
  }
45
88
  if (!isContainedRelativePath(path)) {
46
89
  refuse(`${path} is not a contained relative path.`);
@@ -40,6 +40,12 @@ export interface NegativeControlPlan {
40
40
  /** The diff file, as the operator passed it. */
41
41
  diffFile: string;
42
42
  targets: NegativeControlTarget[];
43
+ /**
44
+ * Engine-relative targets only a build delivers (compiled sources). A
45
+ * control with any must be applied before the pre-test build, and is
46
+ * restored with a fresh mtime. See `negative-control-compiled.ts`.
47
+ */
48
+ compiled?: string[];
43
49
  }
44
50
  /** Locations a control works against. */
45
51
  export interface NegativeControlPaths {
@@ -55,6 +61,12 @@ interface JournalEntry {
55
61
  mode: number;
56
62
  atimeMs: number;
57
63
  mtimeMs: number;
64
+ /**
65
+ * Restore with the current time as mtime, not the original: a compiled
66
+ * source restored with its old mtime is skipped by the incremental build,
67
+ * which would leave the mutated code in the binary.
68
+ */
69
+ freshMtime?: boolean;
58
70
  }
59
71
  interface Journal {
60
72
  version: 1;
@@ -25,14 +25,15 @@
25
25
  * it releases the held locks.
26
26
  */
27
27
  import { lstat, mkdir, readFile, rm, utimes } from 'node:fs/promises';
28
- import { join, posix, resolve } from 'node:path';
28
+ import { isAbsolute, join, posix, relative, resolve } from 'node:path';
29
29
  import { GeneralError, PreflightRefusalError } from '../errors/base.js';
30
30
  import { getNodeErrorCode, toError } from '../utils/errors.js';
31
31
  import { writeFileAtomic } from '../utils/fs.js';
32
32
  import { sha256Hex } from '../utils/hash.js';
33
33
  import { notice } from '../utils/logger.js';
34
- import { normalizePathSlashes, stripEnginePrefix } from '../utils/paths.js';
34
+ import { isContainedRelativePath, isPathInsideRoot, normalizePathSlashes, stripEnginePrefix, } from '../utils/paths.js';
35
35
  import { loadFurnaceConfig } from './furnace-config.js';
36
+ import { findCompiledControlTargets } from './negative-control-compiled.js';
36
37
  import { applyControlHunks, parseControlDiff, } from './negative-control-diff.js';
37
38
  import { runInSignalCriticalSection } from './signal-critical.js';
38
39
  /** Journal directory, relative to `.fireforge/`. */
@@ -159,7 +160,9 @@ export async function planNegativeControl(projectRoot, engineDir, diffFile, opti
159
160
  }
160
161
  let files;
161
162
  try {
162
- files = parseControlDiff(content);
163
+ files = parseControlDiff(content, {
164
+ resolveRename: (source, target) => resolveEditedCopyRename(projectRoot, source, target),
165
+ });
163
166
  }
164
167
  catch (error) {
165
168
  refuse(toError(error).message);
@@ -170,7 +173,40 @@ export async function planNegativeControl(projectRoot, engineDir, diffFile, opti
170
173
  continue;
171
174
  await prepareTarget(engineDir, target);
172
175
  }
173
- return { diffFile, targets };
176
+ const compiled = await findCompiledControlTargets(engineDir, targets.filter((target) => !target.fromComponents).map((target) => target.enginePath));
177
+ return { diffFile, targets, ...(compiled.length > 0 ? { compiled } : {}) };
178
+ }
179
+ /**
180
+ * A diff path as a project-relative path: a contained relative path as
181
+ * is, and an absolute one (git strips the leading `/` into `a/Users/…`)
182
+ * when it lies inside the project. Undefined otherwise.
183
+ */
184
+ function projectRelative(projectRoot, path) {
185
+ const normalized = normalizePathSlashes(path);
186
+ for (const candidate of [`/${normalized}`, normalized]) {
187
+ if (!isAbsolute(candidate))
188
+ continue;
189
+ if (isPathInsideRoot(projectRoot, candidate)) {
190
+ return normalizePathSlashes(relative(projectRoot, candidate));
191
+ }
192
+ }
193
+ return isContainedRelativePath(normalized) ? normalized : undefined;
194
+ }
195
+ /**
196
+ * The control target of a `git diff --no-index <engine file> <edited copy>`
197
+ * section: its old side, when that names a project file and the new side
198
+ * names a copy outside `engine/` and `components/`. A section whose sides
199
+ * both live in those trees is a real rename and stays refused.
200
+ */
201
+ function resolveEditedCopyRename(projectRoot, source, target) {
202
+ const old = projectRelative(projectRoot, source);
203
+ if (old === undefined)
204
+ return undefined;
205
+ const edited = projectRelative(projectRoot, target);
206
+ const tracked = (path) => path.startsWith('engine/') || path.startsWith('components/');
207
+ if (edited !== undefined && tracked(edited))
208
+ return undefined;
209
+ return old;
174
210
  }
175
211
  async function writeJournal(paths, journal) {
176
212
  await writeFileAtomic(join(journalDir(paths), JOURNAL_FILE), `${JSON.stringify(journal, null, 2)}\n`);
@@ -203,6 +239,7 @@ export async function applyNegativeControl(paths, plan) {
203
239
  mode: item.mode,
204
240
  atimeMs: item.atimeMs,
205
241
  mtimeMs: item.mtimeMs,
242
+ ...(plan.compiled?.includes(item.target.enginePath) === true ? { freshMtime: true } : {}),
206
243
  });
207
244
  }
208
245
  const journal = {
@@ -248,7 +285,13 @@ async function restoreEntry(paths, entry) {
248
285
  `left the file as is. Inspect ${journalDir(paths)}.`);
249
286
  }
250
287
  await writeFileAtomic(absolutePath, original);
251
- await utimes(absolutePath, entry.atimeMs / 1000, entry.mtimeMs / 1000);
288
+ if (entry.freshMtime === true) {
289
+ const now = Date.now() / 1000;
290
+ await utimes(absolutePath, now, now);
291
+ }
292
+ else {
293
+ await utimes(absolutePath, entry.atimeMs / 1000, entry.mtimeMs / 1000);
294
+ }
252
295
  const readBack = await readFile(absolutePath);
253
296
  const stats = await lstat(absolutePath);
254
297
  if (!readBack.equals(original) || (stats.mode & 0o7777) !== (entry.mode & 0o7777)) {
@@ -0,0 +1,63 @@
1
+ /** One parked file: both sides, null meaning absent. */
2
+ export interface ParkedFile {
3
+ /** Engine-relative path. */
4
+ path: string;
5
+ /** The file as the session left it. */
6
+ before: string | null;
7
+ /** The file the park wrote in its place. */
8
+ after: string | null;
9
+ }
10
+ /** The record of one park. */
11
+ export interface ParkRecord {
12
+ label: string;
13
+ /** The patch whose files were parked. */
14
+ patch: string;
15
+ createdAt: string;
16
+ /** `baseline` parks every drifted hunk, `hunks` only those a diff named. */
17
+ mode: 'baseline' | 'hunks';
18
+ files: ParkedFile[];
19
+ }
20
+ /** What `park.json` holds: the record without file contents. */
21
+ interface ParkIndex extends Omit<ParkRecord, 'files'> {
22
+ files: {
23
+ path: string;
24
+ before: boolean;
25
+ after: boolean;
26
+ }[];
27
+ }
28
+ /** The outcome of restoring one file. */
29
+ export type RestoreOutcome = 'restored' | 'unchanged' | 'merged' | 'conflict';
30
+ /** Writes a park; refuses to overwrite an existing label. */
31
+ export declare function writePark(projectRoot: string, record: ParkRecord): Promise<string>;
32
+ /** Reads a park back, contents included. */
33
+ export declare function readPark(projectRoot: string, label: string): Promise<ParkRecord>;
34
+ /** Every park of the project, oldest first. */
35
+ export declare function listParks(projectRoot: string): Promise<ParkIndex[]>;
36
+ /** Deletes a park. */
37
+ export declare function dropPark(projectRoot: string, label: string): Promise<void>;
38
+ /** Reads an engine file, null when absent. */
39
+ export declare function readEngineFile(engineDir: string, path: string): Promise<string | null>;
40
+ /** Writes an engine file, removing it for null. */
41
+ export declare function writeEngineFile(engineDir: string, path: string, content: string | null): Promise<void>;
42
+ /**
43
+ * Three-way merges `ours` and `theirs` over `base` with `git merge-file`.
44
+ * Conflicts come back as marker blocks.
45
+ */
46
+ export declare function mergeThreeWay(ours: string, base: string, theirs: string, path: string): Promise<{
47
+ content: string;
48
+ conflicts: number;
49
+ }>;
50
+ /**
51
+ * What restoring one parked file writes: `before` when the file is still
52
+ * as the park left it, nothing when it already matches `before`, and a
53
+ * three-way merge otherwise. Absence only merges with absence.
54
+ */
55
+ export declare function planRestore(file: ParkedFile, now: string | null): Promise<{
56
+ outcome: RestoreOutcome;
57
+ content: string | null;
58
+ }>;
59
+ /** Message ids a Fluent file's lines declare (`id = …`, `-term = …`). */
60
+ export declare function fluentIds(lines: readonly string[]): string[];
61
+ /** Lines `before` has that `after` lacks, as a multiset difference. */
62
+ export declare function removedLines(before: string | null, after: string | null): string[];
63
+ export {};