@hominis/fireforge 0.50.0 → 0.52.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 (212) hide show
  1. package/CHANGELOG.md +67 -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/status-output.js +5 -1
  63. package/dist/src/commands/test-build-furnace.d.ts +3 -2
  64. package/dist/src/commands/test-build-furnace.js +8 -2
  65. package/dist/src/commands/test-build-phase.d.ts +2 -0
  66. package/dist/src/commands/test-build-phase.js +34 -17
  67. package/dist/src/commands/test-checkjs.js +3 -2
  68. package/dist/src/commands/test-dispatch.d.ts +39 -0
  69. package/dist/src/commands/test-dispatch.js +220 -0
  70. package/dist/src/commands/test-furnace-reach.d.ts +37 -0
  71. package/dist/src/commands/test-furnace-reach.js +132 -0
  72. package/dist/src/commands/test-orchestrate.d.ts +14 -0
  73. package/dist/src/commands/test-orchestrate.js +25 -4
  74. package/dist/src/commands/test-perf-scope.d.ts +21 -0
  75. package/dist/src/commands/test-perf-scope.js +55 -0
  76. package/dist/src/commands/test-prelock.js +100 -4
  77. package/dist/src/commands/test-register.js +18 -3
  78. package/dist/src/commands/test-results.d.ts +62 -0
  79. package/dist/src/commands/test-results.js +62 -0
  80. package/dist/src/commands/test-run.d.ts +13 -55
  81. package/dist/src/commands/test-run.js +73 -101
  82. package/dist/src/commands/test-shards.d.ts +110 -0
  83. package/dist/src/commands/test-shards.js +255 -0
  84. package/dist/src/commands/test-stale-gate.d.ts +5 -1
  85. package/dist/src/commands/test-stale-gate.js +11 -2
  86. package/dist/src/commands/test-verdict.d.ts +2 -1
  87. package/dist/src/commands/test.d.ts +1 -1
  88. package/dist/src/commands/test.js +68 -75
  89. package/dist/src/commands/token-lint.js +48 -8
  90. package/dist/src/commands/verify.js +9 -2
  91. package/dist/src/core/build-baseline-types.d.ts +13 -0
  92. package/dist/src/core/build-baseline-validate.js +9 -0
  93. package/dist/src/core/build-baseline.js +7 -0
  94. package/dist/src/core/build-prepare.d.ts +6 -0
  95. package/dist/src/core/build-prepare.js +29 -3
  96. package/dist/src/core/config-paths.d.ts +1 -1
  97. package/dist/src/core/config-paths.js +2 -0
  98. package/dist/src/core/config-validate-check.js +83 -7
  99. package/dist/src/core/config-validate-test-toolchains.js +14 -0
  100. package/dist/src/core/config-validate-token-scales.d.ts +3 -0
  101. package/dist/src/core/config-validate-token-scales.js +47 -5
  102. package/dist/src/core/config-validate.js +6 -0
  103. package/dist/src/core/coverage-extend.js +2 -2
  104. package/dist/src/core/coverage-records.d.ts +11 -0
  105. package/dist/src/core/coverage-records.js +34 -0
  106. package/dist/src/core/engine-session-lock.d.ts +6 -0
  107. package/dist/src/core/engine-session-lock.js +10 -2
  108. package/dist/src/core/file-lock-shared.d.ts +57 -0
  109. package/dist/src/core/file-lock-shared.js +117 -0
  110. package/dist/src/core/file-lock.d.ts +9 -0
  111. package/dist/src/core/file-lock.js +58 -2
  112. package/dist/src/core/furnace-apply.js +10 -10
  113. package/dist/src/core/furnace-config-validate-exceptions.d.ts +11 -3
  114. package/dist/src/core/furnace-config-validate-exceptions.js +59 -8
  115. package/dist/src/core/furnace-config-validate.js +7 -1
  116. package/dist/src/core/furnace-registration-jar-inplace.d.ts +12 -0
  117. package/dist/src/core/furnace-registration-jar-inplace.js +80 -0
  118. package/dist/src/core/furnace-validate-accessibility.d.ts +5 -3
  119. package/dist/src/core/furnace-validate-accessibility.js +75 -30
  120. package/dist/src/core/furnace-validate-exceptions.d.ts +13 -4
  121. package/dist/src/core/furnace-validate-exceptions.js +59 -4
  122. package/dist/src/core/furnace-validate-icon-button.js +30 -1
  123. package/dist/src/core/history-log-file.js +8 -1
  124. package/dist/src/core/line-prefixer.d.ts +18 -0
  125. package/dist/src/core/line-prefixer.js +34 -0
  126. package/dist/src/core/mach.d.ts +7 -0
  127. package/dist/src/core/mach.js +24 -9
  128. package/dist/src/core/module-resolution-preflight.d.ts +19 -3
  129. package/dist/src/core/module-resolution-preflight.js +24 -6
  130. package/dist/src/core/negative-control-compiled.d.ts +36 -0
  131. package/dist/src/core/negative-control-compiled.js +87 -0
  132. package/dist/src/core/negative-control-diff.d.ts +14 -2
  133. package/dist/src/core/negative-control-diff.js +49 -6
  134. package/dist/src/core/negative-control.d.ts +12 -0
  135. package/dist/src/core/negative-control.js +48 -5
  136. package/dist/src/core/park-store.d.ts +63 -0
  137. package/dist/src/core/park-store.js +216 -0
  138. package/dist/src/core/patch-export-update.d.ts +4 -0
  139. package/dist/src/core/patch-export-update.js +12 -3
  140. package/dist/src/core/patch-export.d.ts +3 -2
  141. package/dist/src/core/patch-export.js +21 -14
  142. package/dist/src/core/patch-files.js +26 -8
  143. package/dist/src/core/patch-lint-checkjs-any-import.d.ts +6 -4
  144. package/dist/src/core/patch-lint-checkjs-any-import.js +9 -7
  145. package/dist/src/core/patch-lint-checkjs-diagnostics.d.ts +17 -0
  146. package/dist/src/core/patch-lint-checkjs-diagnostics.js +32 -0
  147. package/dist/src/core/patch-lint-checkjs.d.ts +10 -1
  148. package/dist/src/core/patch-lint-checkjs.js +13 -3
  149. package/dist/src/core/patch-lint-cross.d.ts +3 -1
  150. package/dist/src/core/patch-lint-cross.js +5 -0
  151. package/dist/src/core/patch-lint-css.js +14 -10
  152. package/dist/src/core/patch-lint-forward-registration-extended.d.ts +5 -0
  153. package/dist/src/core/patch-lint-forward-registration-extended.js +1 -1
  154. package/dist/src/core/patch-lint-ignore.d.ts +56 -12
  155. package/dist/src/core/patch-lint-ignore.js +173 -43
  156. package/dist/src/core/patch-lint-ownership.d.ts +10 -0
  157. package/dist/src/core/patch-lint-ownership.js +26 -0
  158. package/dist/src/core/patch-lint-size.js +9 -1
  159. package/dist/src/core/patch-lint-token-scale.d.ts +26 -8
  160. package/dist/src/core/patch-lint-token-scale.js +206 -29
  161. package/dist/src/core/patch-lint.js +2 -2
  162. package/dist/src/core/patch-manifest-io.js +8 -1
  163. package/dist/src/core/patch-manifest-key-order.d.ts +24 -0
  164. package/dist/src/core/patch-manifest-key-order.js +61 -0
  165. package/dist/src/core/patch-manifest-stage.d.ts +46 -0
  166. package/dist/src/core/patch-manifest-stage.js +96 -0
  167. package/dist/src/core/patch-manifest-validate.js +27 -1
  168. package/dist/src/core/patch-order-legal.d.ts +2 -1
  169. package/dist/src/core/patch-order-legal.js +7 -2
  170. package/dist/src/core/patch-policy.d.ts +6 -2
  171. package/dist/src/core/patch-policy.js +14 -7
  172. package/dist/src/core/patch-retired.d.ts +26 -4
  173. package/dist/src/core/patch-retired.js +40 -2
  174. package/dist/src/core/run-log.d.ts +6 -0
  175. package/dist/src/core/run-log.js +17 -1
  176. package/dist/src/core/test-harness-crash.d.ts +2 -1
  177. package/dist/src/core/test-harness-crash.js +5 -8
  178. package/dist/src/core/test-harness-signature.d.ts +16 -0
  179. package/dist/src/core/test-harness-signature.js +19 -1
  180. package/dist/src/core/test-raw-log.d.ts +66 -0
  181. package/dist/src/core/test-raw-log.js +122 -0
  182. package/dist/src/core/test-run-completeness.d.ts +47 -7
  183. package/dist/src/core/test-run-completeness.js +70 -23
  184. package/dist/src/core/test-stale-check.d.ts +10 -1
  185. package/dist/src/core/test-stale-check.js +15 -1
  186. package/dist/src/core/test-xpcshell-retry.d.ts +2 -0
  187. package/dist/src/core/test-xpcshell-retry.js +2 -1
  188. package/dist/src/core/token-lint.d.ts +5 -2
  189. package/dist/src/core/token-lint.js +8 -3
  190. package/dist/src/core/token-sibling-sheets.d.ts +46 -0
  191. package/dist/src/core/token-sibling-sheets.js +119 -0
  192. package/dist/src/core/tree-guard.js +2 -0
  193. package/dist/src/core/typecheck-shim.d.ts +6 -1
  194. package/dist/src/core/typecheck-shim.js +139 -2
  195. package/dist/src/core/typecheck.js +3 -1
  196. package/dist/src/index.d.ts +9 -3
  197. package/dist/src/index.js +4 -1
  198. package/dist/src/types/commands/index.d.ts +2 -2
  199. package/dist/src/types/commands/options.d.ts +68 -7
  200. package/dist/src/types/commands/patches.d.ts +43 -0
  201. package/dist/src/types/config.d.ts +74 -2
  202. package/dist/src/types/furnace.d.ts +29 -0
  203. package/dist/src/types/index.d.ts +1 -1
  204. package/dist/src/utils/options.d.ts +3 -2
  205. package/dist/src/utils/options.js +9 -4
  206. package/docs/configuration.md +110 -9
  207. package/docs/furnace.md +61 -1
  208. package/docs/lifecycle-invariants.md +13 -11
  209. package/docs/machine-output.md +68 -18
  210. package/docs/patch-workflow.md +174 -13
  211. package/docs/testing.md +89 -5
  212. package/package.json +3 -3
@@ -0,0 +1,36 @@
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. That build
11
+ * is a full `mach build`: the pre-test build is otherwise `mach build
12
+ * faster`, which compiles nothing, so the control still missed.
13
+ */
14
+ /**
15
+ * The engine-relative targets that reach the runtime only through a
16
+ * compile: a compiled-source extension and no install-manifest entry that
17
+ * ships the file itself.
18
+ *
19
+ * @param engineDir - Absolute engine directory
20
+ * @param enginePaths - The control's engine-relative targets
21
+ */
22
+ export declare function findCompiledControlTargets(engineDir: string, enginePaths: readonly string[]): Promise<string[]>;
23
+ /**
24
+ * The notice that explains why a compiled control's pre-test build is a
25
+ * full `mach build` instead of `mach build faster`.
26
+ *
27
+ * @param compiled - Engine-relative compiled targets
28
+ */
29
+ export declare function formatCompiledControlBuildNotice(compiled: readonly string[]): string;
30
+ /**
31
+ * The refusal for a compiled control without `--build`, naming the manual
32
+ * procedure for a run that wants to keep its build out of FireForge.
33
+ *
34
+ * @param compiled - Engine-relative compiled targets
35
+ */
36
+ export declare function formatCompiledControlRefusal(compiled: readonly string[]): string;
@@ -0,0 +1,87 @@
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. That build
12
+ * is a full `mach build`: the pre-test build is otherwise `mach build
13
+ * faster`, which compiles nothing, so the control still missed.
14
+ */
15
+ import { extname } from 'node:path';
16
+ import { classifyRuntimeDelivery } from './objdir-install-manifests.js';
17
+ import { loadEngineInstallIndex } from './test-stale-exempt.js';
18
+ /** Extensions of sources the build compiles or code-generates into the binary. */
19
+ const COMPILED_EXTENSIONS = new Set([
20
+ '.c',
21
+ '.cc',
22
+ '.cpp',
23
+ '.cxx',
24
+ '.h',
25
+ '.hh',
26
+ '.hpp',
27
+ '.m',
28
+ '.mm',
29
+ '.rs',
30
+ '.idl',
31
+ '.webidl',
32
+ '.ipdl',
33
+ '.ipdlh',
34
+ '.yaml',
35
+ '.yml',
36
+ ]);
37
+ /**
38
+ * The engine-relative targets that reach the runtime only through a
39
+ * compile: a compiled-source extension and no install-manifest entry that
40
+ * ships the file itself.
41
+ *
42
+ * @param engineDir - Absolute engine directory
43
+ * @param enginePaths - The control's engine-relative targets
44
+ */
45
+ export async function findCompiledControlTargets(engineDir, enginePaths) {
46
+ const candidates = enginePaths.filter((path) => COMPILED_EXTENSIONS.has(extname(path)));
47
+ if (candidates.length === 0)
48
+ return [];
49
+ const index = await loadEngineInstallIndex(engineDir);
50
+ if (index === undefined)
51
+ return candidates;
52
+ const compiled = [];
53
+ for (const path of candidates) {
54
+ if ((await classifyRuntimeDelivery(index, engineDir, path)) === 'unlisted') {
55
+ compiled.push(path);
56
+ }
57
+ }
58
+ return compiled;
59
+ }
60
+ /**
61
+ * The notice that explains why a compiled control's pre-test build is a
62
+ * full `mach build` instead of `mach build faster`.
63
+ *
64
+ * @param compiled - Engine-relative compiled targets
65
+ */
66
+ export function formatCompiledControlBuildNotice(compiled) {
67
+ const files = compiled.map((path) => `engine/${path}`).join(', ');
68
+ return (`The negative control edits compiled source (${files}); escalating this pre-test build to ` +
69
+ 'a full mach build, since mach build faster compiles nothing and the binary under test ' +
70
+ 'would not carry the control.');
71
+ }
72
+ /**
73
+ * The refusal for a compiled control without `--build`, naming the manual
74
+ * procedure for a run that wants to keep its build out of FireForge.
75
+ *
76
+ * @param compiled - Engine-relative compiled targets
77
+ */
78
+ export function formatCompiledControlRefusal(compiled) {
79
+ const files = compiled.map((path) => `engine/${path}`).join(', ');
80
+ return (`This negative control edits compiled source (${files}). Applied right before dispatch, ` +
81
+ 'it would never be compiled: the run would test the unmutated binary and report ' +
82
+ 'control=missed. Add --build: the control is then applied before the pre-test build, so ' +
83
+ 'the binary under test carries it, and restored afterwards with a fresh mtime so the next ' +
84
+ 'build recompiles the original. To run it by hand instead: copy the mutated file in, ' +
85
+ '`touch` it, `fireforge build`, `fireforge test`, restore the file byte-exact, `touch` it ' +
86
+ 'again and rebuild.');
87
+ }
@@ -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 {};
@@ -0,0 +1,216 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Storage and merge logic behind `fireforge park`.
4
+ *
5
+ * Several sessions edit the same Hominis files on one engine, and a peer's
6
+ * `re-export --refuse-foreign-drift` refuses while another session's hunks
7
+ * sit in its patch's files. Setting those hunks aside meant reversing each
8
+ * one by hand. A park records, for every file of one patch that drifted:
9
+ *
10
+ * - `before`: the file as the session left it (what the park takes away),
11
+ * - `after`: what the park writes in its place (the queue-applied
12
+ * baseline, or the working tree minus the named hunks).
13
+ *
14
+ * Restoring is a three-way merge of the file as it is now (ours) with
15
+ * `before` (theirs) over `after` (base), so the owner's commit in between
16
+ * is kept and the parked hunks come back on top of it.
17
+ *
18
+ * A park lives under `.fireforge/park/<label>/`: `park.json` plus one copy
19
+ * of each side per file. Absent files are recorded as absent, never as
20
+ * empty.
21
+ */
22
+ import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
23
+ import { tmpdir } from 'node:os';
24
+ import { dirname, join } from 'node:path';
25
+ import { InvalidArgumentError } from '../errors/base.js';
26
+ import { getNodeErrorCode } from '../utils/errors.js';
27
+ import { ensureDir, pathExists, readJson, readText, removeFile, writeJson, writeText, } from '../utils/fs.js';
28
+ import { exec } from '../utils/process.js';
29
+ import { FIREFORGE_DIR } from './config-paths.js';
30
+ const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
31
+ /** The directory holding every park of a project. */
32
+ function parkRoot(projectRoot) {
33
+ return join(projectRoot, FIREFORGE_DIR, 'park');
34
+ }
35
+ /** Refuses a label that is not a plain file-name segment. */
36
+ function assertParkLabel(label) {
37
+ if (!LABEL_PATTERN.test(label)) {
38
+ throw new InvalidArgumentError(`Park label "${label}" must be letters, digits, ".", "_" or "-", starting with a letter or digit.`, '--label');
39
+ }
40
+ }
41
+ function sideDir(dir, side) {
42
+ return join(dir, side);
43
+ }
44
+ /** Writes a park; refuses to overwrite an existing label. */
45
+ export async function writePark(projectRoot, record) {
46
+ assertParkLabel(record.label);
47
+ const dir = join(parkRoot(projectRoot), record.label);
48
+ if (await pathExists(dir)) {
49
+ throw new InvalidArgumentError(`A park named "${record.label}" already exists. Restore or drop it first, or pass another --label.`, '--label');
50
+ }
51
+ for (const file of record.files) {
52
+ for (const side of ['before', 'after']) {
53
+ const content = file[side];
54
+ if (content === null)
55
+ continue;
56
+ const target = join(sideDir(dir, side), file.path);
57
+ await ensureDir(dirname(target));
58
+ await writeText(target, content);
59
+ }
60
+ }
61
+ const index = {
62
+ label: record.label,
63
+ patch: record.patch,
64
+ createdAt: record.createdAt,
65
+ mode: record.mode,
66
+ files: record.files.map((file) => ({
67
+ path: file.path,
68
+ before: file.before !== null,
69
+ after: file.after !== null,
70
+ })),
71
+ };
72
+ await writeJson(join(dir, 'park.json'), index);
73
+ return dir;
74
+ }
75
+ /** Reads a park back, contents included. */
76
+ export async function readPark(projectRoot, label) {
77
+ assertParkLabel(label);
78
+ const dir = join(parkRoot(projectRoot), label);
79
+ if (!(await pathExists(join(dir, 'park.json')))) {
80
+ throw new InvalidArgumentError(`No park named "${label}". List them with "fireforge park --list".`, label);
81
+ }
82
+ const index = await readJson(join(dir, 'park.json'));
83
+ const files = [];
84
+ for (const entry of index.files) {
85
+ files.push({
86
+ path: entry.path,
87
+ before: entry.before ? await readText(join(sideDir(dir, 'before'), entry.path)) : null,
88
+ after: entry.after ? await readText(join(sideDir(dir, 'after'), entry.path)) : null,
89
+ });
90
+ }
91
+ return { ...index, files };
92
+ }
93
+ /** Every park of the project, oldest first. */
94
+ export async function listParks(projectRoot) {
95
+ let names;
96
+ try {
97
+ names = (await readdir(parkRoot(projectRoot), { withFileTypes: true }))
98
+ .filter((entry) => entry.isDirectory())
99
+ .map((entry) => entry.name);
100
+ }
101
+ catch (error) {
102
+ if (getNodeErrorCode(error) === 'ENOENT')
103
+ return [];
104
+ throw error;
105
+ }
106
+ const parks = [];
107
+ for (const name of names) {
108
+ const path = join(parkRoot(projectRoot), name, 'park.json');
109
+ if (await pathExists(path))
110
+ parks.push(await readJson(path));
111
+ }
112
+ return parks.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
113
+ }
114
+ /** Deletes a park. */
115
+ export async function dropPark(projectRoot, label) {
116
+ assertParkLabel(label);
117
+ const dir = join(parkRoot(projectRoot), label);
118
+ if (!(await pathExists(dir))) {
119
+ throw new InvalidArgumentError(`No park named "${label}".`, label);
120
+ }
121
+ await rm(dir, { recursive: true, force: true });
122
+ }
123
+ /** Reads an engine file, null when absent. */
124
+ export async function readEngineFile(engineDir, path) {
125
+ const full = join(engineDir, path);
126
+ return (await pathExists(full)) ? await readText(full) : null;
127
+ }
128
+ /** Writes an engine file, removing it for null. */
129
+ export async function writeEngineFile(engineDir, path, content) {
130
+ const full = join(engineDir, path);
131
+ if (content === null) {
132
+ if (await pathExists(full))
133
+ await removeFile(full);
134
+ return;
135
+ }
136
+ await ensureDir(dirname(full));
137
+ await writeText(full, content);
138
+ }
139
+ /**
140
+ * Three-way merges `ours` and `theirs` over `base` with `git merge-file`.
141
+ * Conflicts come back as marker blocks.
142
+ */
143
+ export async function mergeThreeWay(ours, base, theirs, path) {
144
+ const scratch = await mkdtemp(join(tmpdir(), 'fireforge-park-'));
145
+ try {
146
+ const files = {
147
+ ours: join(scratch, 'ours'),
148
+ base: join(scratch, 'base'),
149
+ theirs: join(scratch, 'theirs'),
150
+ };
151
+ await writeFile(files.ours, ours);
152
+ await writeFile(files.base, base);
153
+ await writeFile(files.theirs, theirs);
154
+ const result = await exec('git', [
155
+ 'merge-file',
156
+ '-p',
157
+ '-L',
158
+ `${path} (now)`,
159
+ '-L',
160
+ `${path} (parked from)`,
161
+ '-L',
162
+ `${path} (parked)`,
163
+ files.ours,
164
+ files.base,
165
+ files.theirs,
166
+ ]);
167
+ if (result.exitCode < 0 || result.exitCode > 127) {
168
+ throw new Error(`git merge-file failed for ${path}: ${result.stderr.trim()}`);
169
+ }
170
+ return { content: result.stdout, conflicts: result.exitCode };
171
+ }
172
+ finally {
173
+ await rm(scratch, { recursive: true, force: true });
174
+ }
175
+ }
176
+ /**
177
+ * What restoring one parked file writes: `before` when the file is still
178
+ * as the park left it, nothing when it already matches `before`, and a
179
+ * three-way merge otherwise. Absence only merges with absence.
180
+ */
181
+ export async function planRestore(file, now) {
182
+ if (now === file.after)
183
+ return { outcome: 'restored', content: file.before };
184
+ if (now === file.before)
185
+ return { outcome: 'unchanged', content: now };
186
+ if (now === null || file.before === null || file.after === null) {
187
+ return { outcome: 'conflict', content: now };
188
+ }
189
+ const merged = await mergeThreeWay(now, file.after, file.before, file.path);
190
+ return { outcome: merged.conflicts > 0 ? 'conflict' : 'merged', content: merged.content };
191
+ }
192
+ /** Message ids a Fluent file's lines declare (`id = …`, `-term = …`). */
193
+ export function fluentIds(lines) {
194
+ const ids = [];
195
+ for (const line of lines) {
196
+ const match = /^(-?[A-Za-z][\w-]*)\s*=/.exec(line);
197
+ if (match?.[1] !== undefined)
198
+ ids.push(match[1]);
199
+ }
200
+ return ids;
201
+ }
202
+ /** Lines `before` has that `after` lacks, as a multiset difference. */
203
+ export function removedLines(before, after) {
204
+ const remaining = new Map();
205
+ for (const line of (after ?? '').split('\n'))
206
+ remaining.set(line, (remaining.get(line) ?? 0) + 1);
207
+ const removed = [];
208
+ for (const line of (before ?? '').split('\n')) {
209
+ const count = remaining.get(line) ?? 0;
210
+ if (count > 0)
211
+ remaining.set(line, count - 1);
212
+ else
213
+ removed.push(line);
214
+ }
215
+ return removed;
216
+ }
@@ -24,6 +24,8 @@ export interface UpdatePatchAndMetadataOptions {
24
24
  newContent: string;
25
25
  /** Manifest-row fields to merge into the patch's metadata. */
26
26
  updates: Partial<PatchMetadata>;
27
+ /** Manifest-row fields to delete, e.g. a staged block left empty. */
28
+ unset?: readonly (keyof PatchMetadata)[] | undefined;
27
29
  /** Hook run under the lock after the mutation succeeds. */
28
30
  onCommitted?: UpdatePatchCommittedHook | undefined;
29
31
  /** Policy gate run against the under-lock projected manifest. */
@@ -49,3 +51,5 @@ export interface UpdatePatchAndMetadataOptions {
49
51
  * already byte-identical to the new content.
50
52
  */
51
53
  export declare function updatePatchAndMetadata(options: UpdatePatchAndMetadataOptions): Promise<boolean>;
54
+ /** A copy of `row` without `fields`. */
55
+ export declare function withoutFields(row: PatchMetadata, fields: readonly (keyof PatchMetadata)[]): PatchMetadata;