@hominis/fireforge 0.44.0 → 0.44.2

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 (71) hide show
  1. package/CHANGELOG.md +65 -2
  2. package/README.md +20 -121
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/commands/build.js +17 -0
  5. package/dist/src/commands/doctor-check-core.d.ts +9 -0
  6. package/dist/src/commands/doctor-patch-manifest.d.ts +8 -0
  7. package/dist/src/commands/doctor-patch-manifest.js +155 -0
  8. package/dist/src/commands/doctor.d.ts +1 -1
  9. package/dist/src/commands/doctor.js +48 -62
  10. package/dist/src/commands/furnace/apply.js +2 -2
  11. package/dist/src/commands/furnace/deploy.js +2 -2
  12. package/dist/src/commands/furnace/diff.js +6 -1
  13. package/dist/src/commands/furnace/index.js +12 -6
  14. package/dist/src/commands/furnace/init.js +5 -2
  15. package/dist/src/commands/furnace/remove-mutations.js +5 -1
  16. package/dist/src/commands/furnace/sync.js +2 -2
  17. package/dist/src/commands/import.js +6 -2
  18. package/dist/src/commands/re-export-files.js +63 -23
  19. package/dist/src/commands/re-export-scan.js +18 -2
  20. package/dist/src/commands/re-export-single.js +19 -7
  21. package/dist/src/commands/test-register.js +5 -1
  22. package/dist/src/commands/test-run.d.ts +5 -0
  23. package/dist/src/commands/test-run.js +11 -4
  24. package/dist/src/commands/test-verdict.js +10 -1
  25. package/dist/src/commands/test.js +9 -0
  26. package/dist/src/commands/token.js +33 -11
  27. package/dist/src/core/build-audit-resolve.js +12 -3
  28. package/dist/src/core/build-audit.js +6 -2
  29. package/dist/src/core/config-mutate.js +20 -19
  30. package/dist/src/core/furnace-apply-ftl.js +4 -1
  31. package/dist/src/core/furnace-apply-helpers.js +11 -5
  32. package/dist/src/core/furnace-jsconfig.js +5 -3
  33. package/dist/src/core/furnace-operation.d.ts +25 -2
  34. package/dist/src/core/furnace-operation.js +27 -2
  35. package/dist/src/core/git-base.d.ts +14 -0
  36. package/dist/src/core/git-base.js +17 -0
  37. package/dist/src/core/git.js +3 -1
  38. package/dist/src/core/mach-known-noise-filter.d.ts +17 -0
  39. package/dist/src/core/mach-known-noise-filter.js +19 -0
  40. package/dist/src/core/mach.d.ts +8 -3
  41. package/dist/src/core/mach.js +47 -3
  42. package/dist/src/core/patch-lint-checkjs.js +25 -4
  43. package/dist/src/core/patch-manifest-consistency.d.ts +56 -6
  44. package/dist/src/core/patch-manifest-consistency.js +96 -23
  45. package/dist/src/core/patch-manifest-files-affected.d.ts +43 -0
  46. package/dist/src/core/patch-manifest-files-affected.js +77 -0
  47. package/dist/src/core/patch-manifest.d.ts +2 -1
  48. package/dist/src/core/patch-manifest.js +2 -1
  49. package/dist/src/core/run-log.d.ts +60 -0
  50. package/dist/src/core/run-log.js +183 -0
  51. package/dist/src/core/test-harness-output.d.ts +24 -1
  52. package/dist/src/core/test-harness-output.js +46 -15
  53. package/dist/src/core/test-xpcshell-retry.d.ts +2 -2
  54. package/dist/src/core/test-xpcshell-retry.js +6 -1
  55. package/dist/src/core/token-category.js +61 -7
  56. package/dist/src/core/token-dark-mode.js +20 -2
  57. package/dist/src/core/token-docs.js +1 -1
  58. package/dist/src/core/token-manager.d.ts +7 -2
  59. package/dist/src/core/token-manager.js +74 -20
  60. package/dist/src/core/token-variant.d.ts +34 -0
  61. package/dist/src/core/token-variant.js +98 -8
  62. package/dist/src/core/tree-store.js +5 -1
  63. package/dist/src/core/typecheck.js +7 -1
  64. package/dist/src/types/commands/options.d.ts +53 -1
  65. package/dist/src/utils/child-env.d.ts +30 -0
  66. package/dist/src/utils/child-env.js +30 -0
  67. package/dist/src/utils/options.d.ts +28 -0
  68. package/dist/src/utils/options.js +32 -1
  69. package/dist/src/utils/process.d.ts +22 -0
  70. package/dist/src/utils/process.js +8 -7
  71. package/package.json +2 -2
@@ -40,6 +40,11 @@ export interface TestRunContext {
40
40
  headless: boolean;
41
41
  /** Extra environment variables for the mach process. */
42
42
  env?: Record<string, string>;
43
+ /**
44
+ * Pass full mozbuild verbosity through for the TEST phase, by unsetting
45
+ * the coding-agent markers that quiet it. Off by default.
46
+ */
47
+ fullOutput?: boolean;
43
48
  }
44
49
  /** Outcome of one (possibly retried) harness invocation. */
45
50
  export interface TestRunOutcome {
@@ -60,10 +60,17 @@ async function runTestsWithRetriesInner(ctx, paths, env) {
60
60
  let verdict;
61
61
  for (;;) {
62
62
  attempts += 1;
63
- result = env
64
- ? await dispatch(ctx.engineDir, paths, extraArgs, env)
65
- : await dispatch(ctx.engineDir, paths, extraArgs);
66
- result = await retryAfterXpcshellSymlinkRepair(ctx.engineDir, ctx.objDir, result, ctx.classification, paths, extraArgs, env, dispatch);
63
+ // Arity is kept minimal when there is nothing extra to pass: the
64
+ // dispatchers' trailing parameters are optional, and appending explicit
65
+ // `undefined`s would only make every call site's shape depend on
66
+ // features the run is not using.
67
+ result =
68
+ ctx.fullOutput === true
69
+ ? await dispatch(ctx.engineDir, paths, extraArgs, env, true)
70
+ : env
71
+ ? await dispatch(ctx.engineDir, paths, extraArgs, env)
72
+ : await dispatch(ctx.engineDir, paths, extraArgs);
73
+ result = await retryAfterXpcshellSymlinkRepair(ctx.engineDir, ctx.objDir, result, ctx.classification, paths, extraArgs, env, dispatch, ctx.fullOutput);
67
74
  const combined = `${result.stdout}\n${result.stderr}`;
68
75
  verdict = classifyHarnessRun(result.exitCode, combined, paths);
69
76
  if (verdict.kind !== 'harness-crash' || attempts >= maxAttempts)
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ import { getActiveRunLogPath } from '../core/run-log.js';
1
3
  import { formatFireforgeVerdictLine } from '../core/test-harness-crash.js';
2
4
  import { setStdoutSealed } from '../utils/logger.js';
3
5
  let emitted = false;
@@ -38,7 +40,14 @@ function writeVerdictLine(line) {
38
40
  if (emitted)
39
41
  return;
40
42
  emitted = true;
41
- process.stdout.write(`${line}\n`);
43
+ // The run log's path rides the verdict line because the verdict must stay
44
+ // the run's LAST stdout write — a separate announcement after it would
45
+ // break that contract, and one before it is the first thing a `tail` cuts.
46
+ // Appended as an additive `key=value`, so consumers that tokenize the line
47
+ // are unaffected and a truncated tail still says where the full output is.
48
+ const logPath = getActiveRunLogPath();
49
+ const suffix = logPath === undefined ? '' : ` log=${logPath}`;
50
+ process.stdout.write(`${line}${suffix}\n`);
42
51
  setStdoutSealed(true);
43
52
  }
44
53
  /**
@@ -8,6 +8,7 @@ import { assertBuildArtifacts } from '../core/mach-build-artifacts.js';
8
8
  import { assertMarionettePortAvailable, ensureLaunchableBrowserNotRunning, ensureMarionettePortAvailable, extractForwardedMarionettePort, forwardedMachArgsIncludeMarionetteClient, shouldAutoForwardMarionettePortToMach, } from '../core/marionette-port.js';
9
9
  import { formatMarionettePreflightLine, reportMarionettePreflight, runMarionettePreflight, } from '../core/marionette-preflight.js';
10
10
  import { ensureMochitestServerPortAvailable } from '../core/mochitest-server-port.js';
11
+ import { closeActiveRunLog, openRunLog, setActiveRunLog } from '../core/run-log.js';
11
12
  import { createPostRebuildFailureContext } from '../core/test-harness-output.js';
12
13
  import { analyzeTestPathScopes, formatScopeNotice, } from '../core/test-path-scope.js';
13
14
  import { assertObjdirMatchesTreeMarker } from '../core/tree-store.js';
@@ -276,6 +277,10 @@ const TEST_BUILD_PREFLIGHT = {
276
277
  export async function testCommand(projectRoot, testPaths, options = {}) {
277
278
  intro('FireForge Test');
278
279
  resetVerdictEmission();
280
+ // Opened before ANY work so a preflight refusal is logged too — those are
281
+ // exactly the runs whose only output a `tail` throws away. Closed in
282
+ // `finally`, after the verdict line has already read the path.
283
+ setActiveRunLog(await openRunLog(projectRoot, 'test'));
279
284
  try {
280
285
  await runTestCommandBody(projectRoot, testPaths, options);
281
286
  }
@@ -284,6 +289,9 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
284
289
  emitFailVerdict('preflight');
285
290
  throw error;
286
291
  }
292
+ finally {
293
+ await closeActiveRunLog();
294
+ }
287
295
  }
288
296
  /**
289
297
  * Verifies `engine/` did not change under the run before any PASS verdict
@@ -422,6 +430,7 @@ async function runTestCommandBody(projectRoot, testPaths, options = {}) {
422
430
  baseExtraArgs: extraArgs,
423
431
  harnessRetries,
424
432
  headless: options.headless === true,
433
+ ...(options.fullOutput === true ? { fullOutput: true } : {}),
425
434
  ...(perfSampleEnv ? { env: perfSampleEnv } : {}),
426
435
  };
427
436
  const postRebuildContext = options.build
@@ -35,6 +35,27 @@ async function normalizeTokenNameForProject(projectRoot, rawTokenName) {
35
35
  }
36
36
  return normalizeTokenName(rawTokenName);
37
37
  }
38
+ /**
39
+ * Reports a no-op add, naming WHERE the existing declaration lives.
40
+ *
41
+ * A variant skip has no category to name — the declaration lives in a
42
+ * `:root<selector>` block — so the location reads as that block. The
43
+ * variant path used to report no location at all, so a re-run meant to
44
+ * CHANGE a value exited 0 having silently changed nothing.
45
+ *
46
+ * @param tokenName - Full token name including prefix
47
+ * @param result - Result carrying the skip location, when known
48
+ * @param options - The invocation's options
49
+ */
50
+ function reportSkippedToken(tokenName, result, options) {
51
+ const scope = options.variant !== undefined
52
+ ? `:root${options.variant}`
53
+ : `category "${result.skippedExisting?.category ?? options.category ?? ''}"`;
54
+ const where = result.skippedExisting
55
+ ? ` in ${scope} (line ${String(result.skippedExisting.line)}), unchanged`
56
+ : '';
57
+ info(`Token ${tokenName} already exists${where} (skipped)`);
58
+ }
38
59
  /**
39
60
  * Adds a design token to the CSS file and documentation.
40
61
  *
@@ -68,8 +89,8 @@ export async function tokenAddCommand(projectRoot, tokenName, value, options) {
68
89
  await validateTokenAdd(projectRoot, {
69
90
  tokenName,
70
91
  value,
71
- category: options.category,
72
92
  mode: options.mode,
93
+ ...(options.category !== undefined ? { category: options.category } : {}),
73
94
  ...(options.description !== undefined ? { description: options.description } : {}),
74
95
  ...(options.darkValue !== undefined ? { darkValue: options.darkValue } : {}),
75
96
  ...(options.createCategory === true ? { createCategory: true } : {}),
@@ -83,7 +104,7 @@ export async function tokenAddCommand(projectRoot, tokenName, value, options) {
83
104
  info(` Variant: :root${options.variant}`);
84
105
  }
85
106
  else {
86
- info(` Category: ${options.category}${options.createCategory === true ? ' (created if missing)' : ''}`);
107
+ info(` Category: ${options.category ?? '(none)'}${options.createCategory === true ? ' (created if missing)' : ''}`);
87
108
  }
88
109
  info(` Mode: ${options.mode}`);
89
110
  if (options.description)
@@ -96,24 +117,21 @@ export async function tokenAddCommand(projectRoot, tokenName, value, options) {
96
117
  const result = await addToken(projectRoot, {
97
118
  tokenName,
98
119
  value,
99
- category: options.category,
100
120
  mode: options.mode,
121
+ ...(options.category !== undefined ? { category: options.category } : {}),
101
122
  ...(options.description !== undefined ? { description: options.description } : {}),
102
123
  ...(options.darkValue !== undefined ? { darkValue: options.darkValue } : {}),
103
124
  ...(options.createCategory === true ? { createCategory: true } : {}),
104
125
  ...(options.variant !== undefined ? { variant: options.variant } : {}),
105
126
  });
106
127
  if (result.skipped) {
107
- const where = result.skippedExisting
108
- ? ` in category "${result.skippedExisting.category ?? options.category}" (line ${String(result.skippedExisting.line)})`
109
- : '';
110
- info(`Token ${tokenName} already exists${where} (skipped)`);
128
+ reportSkippedToken(tokenName, result, options);
111
129
  }
112
130
  else {
113
131
  const forgeConfig = await loadConfig(projectRoot);
114
132
  const tokensCssFile = getTokensCssPath(forgeConfig.binaryName).split('/').pop();
115
133
  if (result.categoryCreated)
116
- success(`Created category "${options.category}"`);
134
+ success(`Created category "${options.category ?? ''}"`);
117
135
  if (result.cssAdded)
118
136
  success(`Added ${tokenName} to ${tokensCssFile}`);
119
137
  if (result.docsAdded)
@@ -141,7 +159,9 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
141
159
  token
142
160
  .command('add <token-name> <value>')
143
161
  .description('Add a design token to CSS and documentation. The token name is a positional argument, but most tokens start with `--` (CSS custom property syntax), which Commander reads as an option flag. Use the standard `--` separator to mark the end of options before the token name, e.g. `fireforge token add --mode static --category Colors -- --my-token "#fff"`. Bare names without `--` are accepted directly and prefixed using the configured Furnace `tokenPrefix`.')
144
- .requiredOption('--category <cat>', 'Token category (e.g., "Colors — Canvas", "Spacing")')
162
+ .option('--category <cat>', 'Token category (e.g., "Colors — Canvas", "Spacing"). Required for a base declaration; ' +
163
+ 'not required with --variant, which routes into a :root<selector> block where no ' +
164
+ 'category applies')
145
165
  .addOption(
146
166
  // Use Commander's .choices() so invalid --mode values are rejected with
147
167
  // the built-in "argument must be one of …" message and --help lists the
@@ -161,14 +181,16 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
161
181
  .option('--dark-value <val>', 'Dark mode value (required if mode is "override")')
162
182
  .option('--variant <selector>', 'Attribute selector fragment routing the declaration into a `:root<selector>` block ' +
163
183
  "(e.g. '[data-skin=precision]' or '[data-private]'); creates or updates the block. " +
164
- 'CSS-only — cannot be combined with --mode override.')
184
+ 'CSS-only — cannot be combined with --mode override or --create-category. To author ' +
185
+ 'a new category and its variants, add the BASE token first (with --create-category), ' +
186
+ 'then add each variant with --variant.')
165
187
  .option('--create-category', 'Declare the category banner in the tokens CSS if it does not exist yet')
166
188
  .option('--dry-run', 'Show what would be changed without writing')
167
189
  .action(withErrorHandling(async (tokenName, value, options) => {
168
190
  await tokenAddCommand(getProjectRoot(), tokenName, value, {
169
- category: options.category,
170
191
  mode: options.mode,
171
192
  ...pickDefined({
193
+ category: options.category,
172
194
  description: options.description,
173
195
  darkValue: options.darkValue,
174
196
  dryRun: options.dryRun,
@@ -20,6 +20,7 @@
20
20
  import { readdir } from 'node:fs/promises';
21
21
  import { basename, join } from 'node:path';
22
22
  import { pathExists } from '../utils/fs.js';
23
+ import { normalizePathSlashes } from '../utils/paths.js';
23
24
  /** Maximum directory depth to traverse when scanning a tree root. */
24
25
  const MAX_SCAN_DEPTH = 12;
25
26
  /**
@@ -54,11 +55,19 @@ export function isTestPath(sourcePath) {
54
55
  return false;
55
56
  }
56
57
  /**
57
- * Splits a POSIX path into segments, dropping empties.
58
- * @param path POSIX-separated path
58
+ * Splits a path into segments, dropping empties.
59
+ *
60
+ * Separators are normalized first because the candidates come from `join()`
61
+ * over a `readdir` walk of the dist tree: on Windows those carry backslashes,
62
+ * which a `/`-only split collapses into a SINGLE segment. Every candidate then
63
+ * scores the same, so the trailing-overlap heuristic that disambiguates
64
+ * same-basename artifacts (`branding/…/aboutDialog.css` versus
65
+ * `browser/…/aboutDialog.css`) silently picks an arbitrary one.
66
+ *
67
+ * @param path Path in either separator style
59
68
  */
60
69
  function pathSegments(path) {
61
- return path.split('/').filter(Boolean);
70
+ return normalizePathSlashes(path).split('/').filter(Boolean);
62
71
  }
63
72
  /**
64
73
  * Counts how many trailing segments two paths share. Used to score
@@ -37,6 +37,7 @@ import { readdir, stat } from 'node:fs/promises';
37
37
  import { basename, join } from 'node:path';
38
38
  import { pathExists } from '../utils/fs.js';
39
39
  import { info, verbose, warn } from '../utils/logger.js';
40
+ import { normalizePathSlashes } from '../utils/paths.js';
40
41
  import { detectPlatformGate } from './build-audit-platform.js';
41
42
  import { collectSameBasenameCandidates, findRegisteredTarget, resolveArtifactByRegistration, } from './build-audit-registration.js';
42
43
  import { countTrailingSegmentMatches, GENERIC_PATH_SEGMENTS, isTestPath, resolveBestArtifact, } from './build-audit-resolve.js';
@@ -229,8 +230,11 @@ function isConfidentMatch(source, candidate) {
229
230
  if (countTrailingSegmentMatches(source, candidate) >= MIN_TRAILING_SEGMENT_OVERLAP) {
230
231
  return true;
231
232
  }
232
- const sourceSegs = source.split('/').filter(Boolean);
233
- const candSegs = candidate.split('/').filter(Boolean);
233
+ // The candidate is an absolute path built by `join` over a dist/ walk, so on
234
+ // Windows it arrives backslash-separated; a `/`-only split collapses it into
235
+ // one segment and the non-generic-segment bonus can never fire.
236
+ const sourceSegs = normalizePathSlashes(source).split('/').filter(Boolean);
237
+ const candSegs = normalizePathSlashes(candidate).split('/').filter(Boolean);
234
238
  const generic = GENERIC_PATH_SEGMENTS;
235
239
  // Skip the basename itself (which trail-matches by definition).
236
240
  for (let i = 0; i < sourceSegs.length - 1; i += 1) {
@@ -18,21 +18,16 @@ function cloneConfigDocument(config) {
18
18
  return cloned;
19
19
  }
20
20
  /**
21
- * Key segments that would walk into or rewrite the object prototype chain
22
- * if used as plain property names. Blocked up-front so the descent in
23
- * {@link mutateConfig} cannot be weaponized to mutate `Object.prototype`
24
- * process-wide — e.g. `fireforge config __proto__.polluted 1 --force`
25
- * would otherwise land in `getOrCreateChildRecord(raw, "__proto__")`.
21
+ * The error every sentinel-segment refusal raises. Only the MESSAGE lives
22
+ * here: the comparison itself is spelled inline at each write site, because
23
+ * that is what both a reader and CodeQL's `js/prototype-pollution-utility`
24
+ * barrier detection need — the latter is not interprocedural, so a check
25
+ * hidden behind a helper leaves the descent below looking unguarded.
26
26
  */
27
- const SENTINEL_KEY_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
28
- function assertNoSentinelSegments(key, parts) {
29
- for (const part of parts) {
30
- if (SENTINEL_KEY_SEGMENTS.has(part)) {
31
- throw new ConfigError(`Config key "${key}" contains a reserved segment "${part}". ` +
32
- 'Segments "__proto__", "constructor", and "prototype" are not permitted ' +
33
- 'because they would mutate the object prototype chain.');
34
- }
35
- }
27
+ function sentinelSegmentError(key, part) {
28
+ return new ConfigError(`Config key "${key}" contains a reserved segment "${part}". ` +
29
+ 'Segments "__proto__", "constructor", and "prototype" are not permitted ' +
30
+ 'because they would mutate the object prototype chain.');
36
31
  }
37
32
  function getOrCreateChildRecord(parent, key) {
38
33
  const existing = parent[key];
@@ -45,21 +40,27 @@ function getOrCreateChildRecord(parent, key) {
45
40
  }
46
41
  export function mutateConfig(config, key, value, skipValidation = false) {
47
42
  const parts = key.split('.');
48
- // Reject prototype-chain sentinel segments before any write so
49
- // `--force` cannot be used to mutate Object.prototype. This guard must
50
- // run against the original key parts, not any subset — the final leaf
51
- // assignment `current[lastPart] = value` would otherwise stay vulnerable.
52
- assertNoSentinelSegments(key, parts);
53
43
  const raw = cloneConfigDocument(config);
44
+ // Every segment is checked at the point it is USED as a property name —
45
+ // the descent below and the leaf assignment — rather than once up front,
46
+ // so `--force` cannot be used to mutate Object.prototype and neither write
47
+ // can drift out from behind the guard. `raw` is a private clone, so a
48
+ // refusal here has mutated nothing the caller can observe.
54
49
  let current = raw;
55
50
  for (let i = 0; i < parts.length - 1; i++) {
56
51
  const part = parts[i];
57
52
  if (part === undefined)
58
53
  continue;
54
+ if (part === '__proto__' || part === 'constructor' || part === 'prototype') {
55
+ throw sentinelSegmentError(key, part);
56
+ }
59
57
  current = getOrCreateChildRecord(current, part);
60
58
  }
61
59
  const lastPart = parts[parts.length - 1];
62
60
  if (lastPart !== undefined) {
61
+ if (lastPart === '__proto__' || lastPart === 'constructor' || lastPart === 'prototype') {
62
+ throw sentinelSegmentError(key, lastPart);
63
+ }
63
64
  current[lastPart] = value;
64
65
  }
65
66
  if (!skipValidation) {
@@ -14,6 +14,7 @@
14
14
  import { join, relative } from 'node:path';
15
15
  import { toError } from '../utils/errors.js';
16
16
  import { copyFile, pathExists, readText } from '../utils/fs.js';
17
+ import { normalizePathSlashes } from '../utils/paths.js';
17
18
  import { escapeRegex } from '../utils/regex.js';
18
19
  import { resolveFtlChromeSubPath, resolveFtlLocaleJarMnPath } from './furnace-constants.js';
19
20
  import { addLocaleFtlJarMnEntry, removeLocaleFtlJarMnEntry } from './furnace-registration.js';
@@ -133,7 +134,9 @@ export async function applyCustomFtlFile(ctx, affectedPaths, stepErrors, rollbac
133
134
  await snapshotFile(rollbackJournal, ftlDest);
134
135
  }
135
136
  await copyFile(ftlSrc, ftlDest);
136
- affectedPaths.push(relative(engineDir, ftlDest));
137
+ // Sibling pushes on this list are POSIX literals (`localeJarRel`); keep
138
+ // the computed one in the same spelling.
139
+ affectedPaths.push(normalizePathSlashes(relative(engineDir, ftlDest)));
137
140
  const chromeSubPath = resolveFtlChromeSubPath(ftlDir);
138
141
  const localeJarRel = resolveFtlLocaleJarMnPath(ftlDir);
139
142
  if (chromeSubPath === undefined || localeJarRel === undefined)
@@ -6,6 +6,7 @@ import { FurnaceError } from '../errors/furnace.js';
6
6
  import { toError } from '../utils/errors.js';
7
7
  import { copyFile, ensureDir, pathExists, readText, removeFile } from '../utils/fs.js';
8
8
  import { verbose } from '../utils/logger.js';
9
+ import { normalizePathSlashes } from '../utils/paths.js';
9
10
  import { buildCustomDryRunActions } from './furnace-apply-dry-run.js';
10
11
  import { applyCustomFtlFile, applySharedFtlPrune, removeCustomFtlJarMnEntry, } from './furnace-apply-ftl.js';
11
12
  import { normalizeForChecksum } from './furnace-checksum-utils.js';
@@ -70,7 +71,12 @@ export function getOverrideEngineTargetPath(engineDir, config, fileName, ftlDir)
70
71
  * caller should treat that as a successful no-op rather than an error.
71
72
  */
72
73
  export async function restoreOverrideFileToBaseline(engineDir, enginePath, journal) {
73
- const relPath = relative(engineDir, enginePath);
74
+ // Engine-relative paths are IDENTIFIERS here, not filesystem arguments:
75
+ // they are handed to git, recorded in furnace state, and compared against
76
+ // the POSIX paths furnace.json and jar.mn already carry. `relative()`
77
+ // returns backslashes on Windows, so without this the same file has two
78
+ // spellings and every cross-check misses.
79
+ const relPath = normalizePathSlashes(relative(engineDir, enginePath));
74
80
  // Snapshot before mutation so a later rollback can undo both restoration
75
81
  // (writes whatever content we removed back) and deletion (recreates the
76
82
  // file). Snapshotting a missing path records `{ existed: false }`, which
@@ -129,7 +135,7 @@ export async function undeployCustomFiles(engineDir, config, deletedFiles, ftlDi
129
135
  }
130
136
  if (await pathExists(enginePath)) {
131
137
  await removeFile(enginePath);
132
- removed.push(relative(engineDir, enginePath));
138
+ removed.push(normalizePathSlashes(relative(engineDir, enginePath)));
133
139
  }
134
140
  // When an `.ftl` is deleted from the workspace the corresponding locale
135
141
  // jar.mn entry must also be dropped — otherwise the chrome URI points at
@@ -173,7 +179,7 @@ export async function undeployOverrideFiles(engineDir, config, deletedFiles, ftl
173
179
  for (const fileName of deletedFiles) {
174
180
  const enginePath = getOverrideEngineTargetPath(engineDir, config, fileName, ftlDir);
175
181
  const action = await restoreOverrideFileToBaseline(engineDir, enginePath, rollbackJournal);
176
- const relPath = relative(engineDir, enginePath);
182
+ const relPath = normalizePathSlashes(relative(engineDir, enginePath));
177
183
  if (action === 'restored')
178
184
  restored.push(relPath);
179
185
  else if (action === 'removed')
@@ -332,7 +338,7 @@ export async function applyCustomComponent(ctx, config, dryRun = false, rollback
332
338
  const src = join(componentDir, entry.name);
333
339
  const dest = join(targetDir, entry.name);
334
340
  await deployFileWithFragments(src, dest, sharedDir);
335
- affectedPaths.push(relative(engineDir, dest));
341
+ affectedPaths.push(normalizePathSlashes(relative(engineDir, dest)));
336
342
  copiedFileNames.push(entry.name);
337
343
  }));
338
344
  // See buildCustomDryRunActions for the rationale: when `sharedFtl` is set
@@ -425,7 +431,7 @@ export async function applyOverrideComponent(ctx, config, dryRun = false, rollba
425
431
  const src = join(componentDir, entry.name);
426
432
  const dest = getOverrideEngineTargetPath(engineDir, config, entry.name, ftlDir);
427
433
  await copyFile(src, dest);
428
- affectedPaths.push(relative(engineDir, dest));
434
+ affectedPaths.push(normalizePathSlashes(relative(engineDir, dest)));
429
435
  }));
430
436
  if (affectedPaths.length === 0) {
431
437
  throw new FurnaceError(`No matching files found in override directory for "${name}"`, name);
@@ -26,7 +26,7 @@ import { FurnaceError } from '../errors/furnace.js';
26
26
  import { toError } from '../utils/errors.js';
27
27
  import { pathExists, readText, writeTextIfChanged } from '../utils/fs.js';
28
28
  import { info } from '../utils/logger.js';
29
- import { normalizePathSlashes } from '../utils/paths.js';
29
+ import { isPathInsideRoot, normalizePathSlashes } from '../utils/paths.js';
30
30
  import { getFurnacePaths } from './furnace-config.js';
31
31
  /** Chrome URL prefix under which registered custom-element files deploy. */
32
32
  const CHROME_ELEMENTS_URL_PREFIX = 'chrome://global/content/elements/';
@@ -84,8 +84,10 @@ function isManagedEntry(key, value, jsconfigDir, customDir) {
84
84
  return false;
85
85
  if (!Array.isArray(value) || value.length !== 1 || typeof value[0] !== 'string')
86
86
  return false;
87
- const target = resolve(jsconfigDir, value[0]);
88
- return target.startsWith(resolve(customDir) + '/') || target === resolve(customDir);
87
+ // Containment via `relative`, not a string prefix: a hard-coded `'/'`
88
+ // separator never matches the backslashes `resolve` emits on Windows, which
89
+ // demoted every managed entry to "unmanaged" there — no pruning, no updates.
90
+ return isPathInsideRoot(customDir, resolve(jsconfigDir, value[0]));
89
91
  }
90
92
  /**
91
93
  * Reconciles the managed `compilerOptions.paths` entries of the configured
@@ -56,8 +56,16 @@ export interface RunFurnaceMutationOptions {
56
56
  */
57
57
  dryRun?: boolean;
58
58
  /**
59
- * Override the default 30s lock timeout. The watch-mode caller may want a
60
- * shorter window so an interactive rebuild fails fast instead of stalling.
59
+ * Override the default 30 s furnace-lock timeout.
60
+ *
61
+ * Set from the caller's resolved `--wait-lock` budget, so ONE flag governs
62
+ * both locks a furnace mutation takes. Before that it was declared and
63
+ * never passed: `furnace deploy --wait-lock 1800` waited 1800 s for the
64
+ * engine session lock and then met `.fireforge/furnace.lock` with a fixed
65
+ * 30 s, which is the worst shape a budget can have — it pays the whole
66
+ * wait and still refuses. When absent, {@link sessionWaitLockMs} supplies
67
+ * the session-wide budget so the twelve furnace mutators that declare no
68
+ * flag are covered too.
61
69
  */
62
70
  lockTimeoutMs?: number;
63
71
  /**
@@ -67,6 +75,21 @@ export interface RunFurnaceMutationOptions {
67
75
  */
68
76
  skipPendingRepairCheck?: boolean;
69
77
  }
78
+ /**
79
+ * Converts a resolved `--wait-lock` budget in seconds into the
80
+ * {@link RunFurnaceMutationOptions} slice that applies it to the furnace
81
+ * lock. Undefined in, empty out — so the caller can spread it
82
+ * unconditionally without assigning `undefined` under
83
+ * `exactOptionalPropertyTypes`.
84
+ *
85
+ * A helper rather than three inline spreads: the seconds-to-milliseconds
86
+ * conversion is exactly the kind of thing that gets copied once correctly
87
+ * and once with the factor dropped.
88
+ *
89
+ * @param waitLockSeconds - Resolved budget in seconds, or undefined
90
+ * @returns `{ lockTimeoutMs }`, or `{}` when no budget applies
91
+ */
92
+ export declare function waitLockMutationOptions(waitLockSeconds: number | undefined): Pick<RunFurnaceMutationOptions, 'lockTimeoutMs'>;
70
93
  /**
71
94
  * Returns true while a signal-driven rollback is in progress. The bin entry
72
95
  * point uses this as a re-entrancy guard so a user mashing Ctrl+C cannot
@@ -4,12 +4,30 @@ import { FurnaceError } from '../errors/furnace.js';
4
4
  import { assert } from '../utils/assert.js';
5
5
  import { toError } from '../utils/errors.js';
6
6
  import { verbose, warn } from '../utils/logger.js';
7
+ import { sessionWaitLockMs } from '../utils/options.js';
7
8
  import { FIREFORGE_DIR } from './config-paths.js';
8
9
  import { readLockStatus, releaseLockIfOwned, withFileLock } from './file-lock.js';
9
10
  import { loadFurnaceState, updateFurnaceState } from './furnace-config.js';
10
11
  import { restoreRollbackJournal, restoreRollbackJournalOrThrow, } from './furnace-rollback.js';
11
12
  /** Sidecar lock filename used to serialize concurrent furnace mutations. */
12
13
  const FURNACE_LOCK_FILENAME = 'furnace.lock';
14
+ /**
15
+ * Converts a resolved `--wait-lock` budget in seconds into the
16
+ * {@link RunFurnaceMutationOptions} slice that applies it to the furnace
17
+ * lock. Undefined in, empty out — so the caller can spread it
18
+ * unconditionally without assigning `undefined` under
19
+ * `exactOptionalPropertyTypes`.
20
+ *
21
+ * A helper rather than three inline spreads: the seconds-to-milliseconds
22
+ * conversion is exactly the kind of thing that gets copied once correctly
23
+ * and once with the factor dropped.
24
+ *
25
+ * @param waitLockSeconds - Resolved budget in seconds, or undefined
26
+ * @returns `{ lockTimeoutMs }`, or `{}` when no budget applies
27
+ */
28
+ export function waitLockMutationOptions(waitLockSeconds) {
29
+ return waitLockSeconds === undefined ? {} : { lockTimeoutMs: waitLockSeconds * 1000 };
30
+ }
13
31
  /**
14
32
  * Claims the rollback for one operation, or reports that another path
15
33
  * already has it.
@@ -338,10 +356,17 @@ export async function runFurnaceMutation(root, kind, body, options = {}) {
338
356
  const token = nextOperationToken++;
339
357
  const operation = { root, kind, cleanups: [] };
340
358
  const lockPath = getFurnaceLockPath(root);
359
+ // An explicit budget wins; otherwise the session-wide one applies. Naming
360
+ // the lock in the refusal is load-bearing: a mutation takes two, and a
361
+ // message that says only "timed out" sends the operator to raise a budget
362
+ // that was already large enough for the OTHER lock.
363
+ const timeoutMs = options.lockTimeoutMs ?? sessionWaitLockMs();
341
364
  const lockOptions = {
342
- ...(options.lockTimeoutMs !== undefined ? { timeoutMs: options.lockTimeoutMs } : {}),
343
- onTimeoutMessage: `Timed out waiting for the furnace lock at ${lockPath}. ` +
365
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
366
+ onTimeoutMessage: `Timed out waiting for the FURNACE lock at ${lockPath} ` +
367
+ `(this is the second of the two locks a furnace mutation takes; the first is the engine session lock). ` +
344
368
  'Another fireforge furnace command may be running. ' +
369
+ 'Raise the budget with --wait-lock <seconds> or FIREFORGE_WAIT_LOCK, which governs both. ' +
345
370
  'If no other process is running, remove the stale lock directory and retry.',
346
371
  onStaleLockMessage: (ageMs) => `Removing stale furnace lock (age: ${Math.round(ageMs / 1000)}s). ` +
347
372
  'A previous fireforge process may have crashed.',
@@ -83,3 +83,17 @@ export declare function chunkPathspecs(paths: string[], budgetBytes?: number): s
83
83
  * repositories with hundreds of thousands of files.
84
84
  */
85
85
  export declare function configureGitPerformance(repoDir: string): Promise<void>;
86
+ /**
87
+ * Pins the engine repository's line-ending policy to "store and check out
88
+ * exactly what upstream shipped".
89
+ *
90
+ * Git's Windows installer defaults to a global `core.autocrlf=true`, which
91
+ * rewrites LF to CRLF on checkout and back on staging. FireForge cannot
92
+ * tolerate that: patch bodies are byte diffs of the working tree, and
93
+ * `hashObjectBatch` deliberately delegates to git so `.gitattributes` and
94
+ * `core.autocrlf` DO apply to the hashes `status` compares against. Under the
95
+ * global default the same tree hashes differently on Windows than everywhere
96
+ * else, so exported patches and drift detection disagree across machines.
97
+ * Setting it per-repository leaves the developer's global config alone.
98
+ */
99
+ export declare function configureGitLineEndings(repoDir: string): Promise<void>;
@@ -120,3 +120,20 @@ export async function configureGitPerformance(repoDir) {
120
120
  await git(['config', 'core.fsmonitor', 'false'], repoDir);
121
121
  await git(['config', 'feature.manyFiles', 'true'], repoDir);
122
122
  }
123
+ /**
124
+ * Pins the engine repository's line-ending policy to "store and check out
125
+ * exactly what upstream shipped".
126
+ *
127
+ * Git's Windows installer defaults to a global `core.autocrlf=true`, which
128
+ * rewrites LF to CRLF on checkout and back on staging. FireForge cannot
129
+ * tolerate that: patch bodies are byte diffs of the working tree, and
130
+ * `hashObjectBatch` deliberately delegates to git so `.gitattributes` and
131
+ * `core.autocrlf` DO apply to the hashes `status` compares against. Under the
132
+ * global default the same tree hashes differently on Windows than everywhere
133
+ * else, so exported patches and drift detection disagree across machines.
134
+ * Setting it per-repository leaves the developer's global config alone.
135
+ */
136
+ export async function configureGitLineEndings(repoDir) {
137
+ await git(['config', 'core.autocrlf', 'false'], repoDir);
138
+ await git(['config', 'core.eol', 'lf'], repoDir);
139
+ }
@@ -9,7 +9,7 @@ import { pathExists, pathExistsStrict, removeFile } from '../utils/fs.js';
9
9
  import { verbose } from '../utils/logger.js';
10
10
  import { exec } from '../utils/process.js';
11
11
  import { ensureFirefoxIgnorefileCompatibility } from './firefox-ignorefile.js';
12
- import { chunkPathspecs, configureGitPerformance, ensureGit, git, GIT_ADD_CHUNK_TIMEOUT_ENV_VAR, GIT_ADD_CHUNK_TIMEOUT_MS, GIT_ADD_TIMEOUT_MS, } from './git-base.js';
12
+ import { chunkPathspecs, configureGitLineEndings, configureGitPerformance, ensureGit, git, GIT_ADD_CHUNK_TIMEOUT_ENV_VAR, GIT_ADD_CHUNK_TIMEOUT_MS, GIT_ADD_TIMEOUT_MS, } from './git-base.js';
13
13
  import { getWorkingTreeStatus } from './git-status.js';
14
14
  // ── Functions that remain in this file ──
15
15
  /**
@@ -278,6 +278,7 @@ export async function initRepository(dir, branchName = 'main', options = {}) {
278
278
  // Enable performance settings for large trees
279
279
  reportProgress('Configuring git performance settings...');
280
280
  await configureGitPerformance(dir);
281
+ await configureGitLineEndings(dir);
281
282
  // Add a local-only origin remote so that Firefox's mach bootstrap and
282
283
  // build scripts (which shell out to `git remote get-url origin`) don't
283
284
  // fail. Nothing is ever fetched from or pushed to this remote.
@@ -318,6 +319,7 @@ export async function resumeRepository(dir, options = {}) {
318
319
  // Ensure performance settings are in place (may not have been set)
319
320
  reportProgress('Configuring git performance settings...');
320
321
  await configureGitPerformance(dir);
322
+ await configureGitLineEndings(dir);
321
323
  // Clean up any stale index lock left by the killed process
322
324
  await cleanupIndexLock(dir);
323
325
  // Ensure origin remote exists (may have been added before the interrupt)
@@ -47,6 +47,23 @@
47
47
  * @returns True when the recognized teardown traceback is present
48
48
  */
49
49
  export declare function hasKnownTeardownNoise(output: string): boolean;
50
+ /**
51
+ * True when a SINGLE line is the documented teardown family's
52
+ * `AttributeError`.
53
+ *
54
+ * The line-level half of {@link hasKnownTeardownNoise}: the traceback FRAME
55
+ * lives on a neighbouring line, so a per-line test cannot apply the
56
+ * two-signal rule by itself. Callers pair this with
57
+ * `hasKnownTeardownNoise(wholeOutput)` to recover the full recognition —
58
+ * "this capture carries the incident, and THIS line is its header" — which
59
+ * keeps the closed attribute allowlist (`stop_time`/`poll_interval`) doing
60
+ * the same work it does everywhere else. A novel attribute is not this
61
+ * incident and must stay a candidate failure.
62
+ *
63
+ * @param line - A single output line
64
+ * @returns True when the line is the recognized teardown AttributeError
65
+ */
66
+ export declare function isKnownTeardownNoiseLine(line: string): boolean;
50
67
  /**
51
68
  * Shared across the stdout and stderr filter instances of ONE mach run:
52
69
  * SUITE_END typically arrives on stdout while the teardown traceback lands
@@ -70,6 +70,25 @@ function isRecognizedTeardownNoise(block) {
70
70
  export function hasKnownTeardownNoise(output) {
71
71
  return isRecognizedTeardownNoise(output);
72
72
  }
73
+ /**
74
+ * True when a SINGLE line is the documented teardown family's
75
+ * `AttributeError`.
76
+ *
77
+ * The line-level half of {@link hasKnownTeardownNoise}: the traceback FRAME
78
+ * lives on a neighbouring line, so a per-line test cannot apply the
79
+ * two-signal rule by itself. Callers pair this with
80
+ * `hasKnownTeardownNoise(wholeOutput)` to recover the full recognition —
81
+ * "this capture carries the incident, and THIS line is its header" — which
82
+ * keeps the closed attribute allowlist (`stop_time`/`poll_interval`) doing
83
+ * the same work it does everywhere else. A novel attribute is not this
84
+ * incident and must stay a candidate failure.
85
+ *
86
+ * @param line - A single output line
87
+ * @returns True when the line is the recognized teardown AttributeError
88
+ */
89
+ export function isKnownTeardownNoiseLine(line) {
90
+ return KNOWN_TEARDOWN_ATTRIBUTE_ERROR_PATTERN.test(line);
91
+ }
73
92
  /** Fresh per-run context — hand the same instance to both stream filters. */
74
93
  export function createTeardownNoiseContext() {
75
94
  return { shutdownSeen: false };