@hominis/fireforge 0.44.1 → 0.44.3

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 (54) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +18 -110
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/commands/build.js +17 -0
  5. package/dist/src/commands/furnace/apply.js +2 -2
  6. package/dist/src/commands/furnace/deploy.js +2 -2
  7. package/dist/src/commands/furnace/index.js +12 -6
  8. package/dist/src/commands/furnace/init.js +5 -2
  9. package/dist/src/commands/furnace/sync.js +2 -2
  10. package/dist/src/commands/re-export-files.js +63 -23
  11. package/dist/src/commands/re-export-scan.js +18 -2
  12. package/dist/src/commands/re-export-single.js +19 -7
  13. package/dist/src/commands/test-register.js +5 -1
  14. package/dist/src/commands/test-run.d.ts +5 -0
  15. package/dist/src/commands/test-run.js +11 -4
  16. package/dist/src/commands/test-verdict.js +10 -1
  17. package/dist/src/commands/test.js +9 -0
  18. package/dist/src/commands/token.js +36 -12
  19. package/dist/src/core/build-audit.js +6 -2
  20. package/dist/src/core/furnace-jsconfig.js +5 -3
  21. package/dist/src/core/furnace-operation.d.ts +25 -2
  22. package/dist/src/core/furnace-operation.js +27 -2
  23. package/dist/src/core/mach-known-noise-filter.d.ts +17 -0
  24. package/dist/src/core/mach-known-noise-filter.js +19 -0
  25. package/dist/src/core/mach.d.ts +8 -3
  26. package/dist/src/core/mach.js +47 -3
  27. package/dist/src/core/run-log.d.ts +60 -0
  28. package/dist/src/core/run-log.js +183 -0
  29. package/dist/src/core/test-harness-output.d.ts +24 -1
  30. package/dist/src/core/test-harness-output.js +46 -15
  31. package/dist/src/core/test-xpcshell-retry.d.ts +2 -2
  32. package/dist/src/core/test-xpcshell-retry.js +6 -1
  33. package/dist/src/core/token-manager.d.ts +7 -2
  34. package/dist/src/core/token-manager.js +74 -20
  35. package/dist/src/core/token-variant.d.ts +46 -2
  36. package/dist/src/core/token-variant.js +190 -33
  37. package/dist/src/types/commands/options.d.ts +30 -1
  38. package/dist/src/utils/child-env.d.ts +30 -0
  39. package/dist/src/utils/child-env.js +30 -0
  40. package/dist/src/utils/options.d.ts +28 -0
  41. package/dist/src/utils/options.js +32 -1
  42. package/dist/src/utils/process.d.ts +22 -0
  43. package/dist/src/utils/process.js +8 -7
  44. package/docs/README.md +35 -0
  45. package/docs/configuration.md +116 -0
  46. package/docs/exit-codes.md +63 -0
  47. package/docs/furnace.md +57 -0
  48. package/docs/lifecycle-invariants.md +309 -0
  49. package/docs/machine-output.md +94 -0
  50. package/docs/patch-workflow.md +119 -0
  51. package/docs/run-logs.md +60 -0
  52. package/docs/testing.md +92 -0
  53. package/docs/verification-trees.md +48 -0
  54. package/package.json +2 -1
@@ -2,6 +2,7 @@
2
2
  import { dirname, join } from 'node:path';
3
3
  import { confirm } from '@clack/prompts';
4
4
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
5
+ import { listTrackedInHead } from '../core/git-file-ops.js';
5
6
  import { getModifiedFilesInDir, getUntrackedFilesInDir } from '../core/git-status.js';
6
7
  import { extractAffectedFiles } from '../core/patch-apply.js';
7
8
  import { buildModifiedFileAdditionsFromDiff, buildPatchQueueContext, detectNewFilesInDiff, lintPatchQueue, } from '../core/patch-lint.js';
@@ -85,7 +86,10 @@ async function scanPatchFilesTargeted(args) {
85
86
  const exists = await mapWithConcurrency(scanFiles, PATH_PROBE_CONCURRENCY, (file) => pathExists(join(engineDir, file)));
86
87
  for (const [index, file] of scanFiles.entries()) {
87
88
  if (exists[index] !== true) {
88
- throw new InvalidArgumentError(`--scan-file path not found in engine/: ${file}`, '--scan-file');
89
+ throw new InvalidArgumentError(`--scan-file path not found in engine/: ${file}. ` +
90
+ '--scan-file brings a path INTO patch ownership, which needs content to diff. ' +
91
+ 'If the path was deleted and the patch already owns it, a plain re-export now ' +
92
+ 'captures the deletion — no scan flag is needed.', '--scan-file');
89
93
  }
90
94
  if (!currentSet.has(file))
91
95
  added.push(file);
@@ -93,9 +97,21 @@ async function scanPatchFilesTargeted(args) {
93
97
  const removed = await findRemovedFiles(currentFilesAffected, engineDir);
94
98
  return reportScanResult(currentFilesAffected, patchFilename, isDryRun, added.sort(), removed);
95
99
  }
100
+ /**
101
+ * Paths that have genuinely left the patch's ownership: absent from disk AND
102
+ * untracked in engine HEAD.
103
+ *
104
+ * An absent path that IS tracked in HEAD is a DELETION, not a de-ownership.
105
+ * The diff carries it as a `deleted file mode` section, so it must stay in
106
+ * `filesAffected` — pruning it desynchronises the manifest from the patch
107
+ * body in the direction that hurts: the body says "delete this file", the
108
+ * file list says the patch has nothing to do with it.
109
+ */
96
110
  async function findRemovedFiles(files, engineDir) {
97
111
  const exists = await mapWithConcurrency(files, PATH_PROBE_CONCURRENCY, (file) => pathExists(join(engineDir, file)));
98
- return files.filter((_, index) => exists[index] !== true).sort();
112
+ const absent = files.filter((_, index) => exists[index] !== true);
113
+ const trackedAbsent = await listTrackedInHead(engineDir, absent);
114
+ return absent.filter((file) => !trackedAbsent.has(file)).sort();
99
115
  }
100
116
  function reportScanResult(currentFilesAffected, patchFilename, isDryRun, added, removed) {
101
117
  for (const f of added)
@@ -9,6 +9,7 @@ import { getProjectPaths } from '../core/config.js';
9
9
  import { stdioIsInteractive } from '../core/destructive.js';
10
10
  import { enforceFreshFurnaceSources } from '../core/furnace-stale-export.js';
11
11
  import { getDiffForFilesAgainstHead } from '../core/git-diff.js';
12
+ import { listTrackedInHead } from '../core/git-file-ops.js';
12
13
  import { updatePatchAndMetadata } from '../core/patch-export.js';
13
14
  import { buildProjectedManifest, enforcePatchPolicy } from '../core/patch-policy.js';
14
15
  import { isGitIndexLockConflict } from '../errors/git.js';
@@ -111,17 +112,28 @@ async function reExportSinglePatch(patch, paths, manifest, options, isDryRun, co
111
112
  // --allow-stale-furnace) before diffing. Runs in dry-run too so the failure
112
113
  // surfaces early.
113
114
  await enforceFreshFurnaceSources(paths.root, currentFilesAffected, options.allowStaleFurnace === true, 're-export');
114
- const missingFiles = await findMissingFiles(paths.engine, currentFilesAffected);
115
- if (missingFiles.length === currentFilesAffected.length) {
115
+ const absentFiles = await findMissingFiles(paths.engine, currentFilesAffected);
116
+ // A path absent from disk but tracked in engine HEAD is a DELETION, and
117
+ // `git diff HEAD` renders it as a real `deleted file mode` section. Only
118
+ // a never-tracked path has nothing to diff. Skipping both alike meant a
119
+ // retired file quietly left the patch body while staying in its file
120
+ // list — the patch then claimed a file it could not restore.
121
+ const trackedAbsent = await listTrackedInHead(paths.engine, absentFiles);
122
+ const deletedFiles = absentFiles.filter((f) => trackedAbsent.has(f));
123
+ const untrackedAbsent = absentFiles.filter((f) => !trackedAbsent.has(f));
124
+ if (untrackedAbsent.length === currentFilesAffected.length) {
116
125
  warn(`Skipped ${patch.filename}: all affected files missing`);
117
- warn(`Missing files: ${missingFiles.join(', ')}`);
126
+ warn(`Missing files: ${untrackedAbsent.join(', ')}`);
118
127
  return false;
119
128
  }
120
- if (missingFiles.length > 0) {
121
- warn(`${patch.filename}: missing files will be skipped: ${missingFiles.join(', ')}`);
129
+ if (untrackedAbsent.length > 0) {
130
+ warn(`${patch.filename}: missing files will be skipped: ${untrackedAbsent.join(', ')}`);
122
131
  }
123
- const missingSet = new Set(missingFiles);
124
- const existingFiles = currentFilesAffected.filter((f) => !missingSet.has(f));
132
+ if (deletedFiles.length > 0) {
133
+ info(`${patch.filename}: capturing deletion of ${deletedFiles.join(', ')}`);
134
+ }
135
+ const untrackedSet = new Set(untrackedAbsent);
136
+ const existingFiles = currentFilesAffected.filter((f) => !untrackedSet.has(f));
125
137
  const diffContent = await getDiffForFilesAgainstHead(paths.engine, existingFiles);
126
138
  assertScanFileAdditionsHaveDiffHunks({
127
139
  diffContent,
@@ -29,7 +29,10 @@ const TEST_HELP_TEXT = [
29
29
  '--doctor) ends with exactly one raw stdout line',
30
30
  ' FIREFORGE-VERDICT: PASS|FAIL [reason=crash|test-failures|',
31
31
  ' no-tests|preflight|inconclusive|lock-timeout] [checks=<n>]',
32
- ' [unexpected=<n>] [shards=<p>/<t>] [(<note>)]',
32
+ ' [unexpected=<n>] [shards=<p>/<t>] [(<note>)] [log=<path>]',
33
+ "log=<path> names this run's complete output, written to",
34
+ '.fireforge/logs/ as the run streams, so a piped or truncated run',
35
+ 'still leaves a re-readable artifact.',
33
36
  'The status follows the harness classifier, not the raw exit code:',
34
37
  'a crash-classified run says FAIL reason=crash even at exit 0, and',
35
38
  'a green-summary-override pass says PASS despite a non-zero mach',
@@ -55,6 +58,7 @@ export function registerTest(program, { getProjectRoot, withErrorHandling }) {
55
58
  .command('test [paths...]')
56
59
  .description('Run tests via mach test')
57
60
  .option('--headless', 'Run tests in headless mode')
61
+ .option('--full-output', "Pass mozbuild's full verbosity through for the TEST phase. mozbuild quiets terminal output to warnings and errors when it detects a coding agent, which removes TEST_START and console INFO — the lines a hang or stall diagnosis needs. This unsets that detection for test dispatches only; the build phase stays quiet.")
58
62
  .option('--build', 'Run incremental UI build before testing')
59
63
  .option('--build-only', 'Run the scoped pre-test build (packaging exactly [paths...], mixed harnesses allowed) and exit without dispatching tests — one build covers both harness halves, then run each half without --build')
60
64
  .option('--extend-coverage', 'With --build/--build-only and explicit paths, union those paths into the recorded test-packaging coverage instead of replacing it, so an earlier scoped build stays covered. Refused when the build anchor moved (engine HEAD, mozconfig, or a previously fingerprinted packageable file changed).')
@@ -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
@@ -160,15 +180,19 @@ export function registerToken(program, { getProjectRoot, withErrorHandling }) {
160
180
  .option('--description <desc>', 'Comment description for the CSS file')
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
- "(e.g. '[data-skin=precision]' or '[data-private]'); creates or updates the block. " +
164
- 'CSS-only — cannot be combined with --mode override.')
183
+ "(e.g. '[data-skin=precision]', '[data-private]', a run such as " +
184
+ "'[data-skin=precision][data-theme=dark]', or one with a pseudo-class tail such as " +
185
+ "'[data-skin=precision]:not([data-private])'); creates or updates the block. " +
186
+ 'CSS-only — cannot be combined with --mode override or --create-category. To author ' +
187
+ 'a new category and its variants, add the BASE token first (with --create-category), ' +
188
+ 'then add each variant with --variant.')
165
189
  .option('--create-category', 'Declare the category banner in the tokens CSS if it does not exist yet')
166
190
  .option('--dry-run', 'Show what would be changed without writing')
167
191
  .action(withErrorHandling(async (tokenName, value, options) => {
168
192
  await tokenAddCommand(getProjectRoot(), tokenName, value, {
169
- category: options.category,
170
193
  mode: options.mode,
171
194
  ...pickDefined({
195
+ category: options.category,
172
196
  description: options.description,
173
197
  darkValue: options.darkValue,
174
198
  dryRun: options.dryRun,
@@ -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) {
@@ -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.',
@@ -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 };
@@ -25,6 +25,11 @@ export interface MachOptions {
25
25
  * the test dispatchers only.
26
26
  */
27
27
  annotateKnownTeardownNoise?: boolean;
28
+ /**
29
+ * Environment variables to unset for this dispatch. Threaded to the exec
30
+ * layer's {@link ExecOptions.envUnset}.
31
+ */
32
+ envUnset?: readonly string[];
28
33
  }
29
34
  /**
30
35
  * Result of running a mach command while capturing streamed output.
@@ -243,7 +248,7 @@ export declare function test(engineDir: string, testPaths?: string[], args?: str
243
248
  * (merged over `process.env` by the exec layer). Used by
244
249
  * `fireforge test --perf-samples` to publish the artifact-path contract.
245
250
  */
246
- export declare function testWithOutput(engineDir: string, testPaths?: string[], args?: string[], env?: Record<string, string>): Promise<MachCommandResult>;
251
+ export declare function testWithOutput(engineDir: string, testPaths?: string[], args?: string[], env?: Record<string, string>, fullOutput?: boolean): Promise<MachCommandResult>;
247
252
  /**
248
253
  * Runs `mach xpcshell-test` (the suite-specific xpcshell command) while
249
254
  * capturing output. Unlike the generic `mach test`, the suite-specific
@@ -254,10 +259,10 @@ export declare function testWithOutput(engineDir: string, testPaths?: string[],
254
259
  * Signature mirrors {@link testWithOutput} so the two are interchangeable in
255
260
  * the dispatch path.
256
261
  */
257
- export declare function xpcshellTestWithOutput(engineDir: string, testPaths?: string[], args?: string[], env?: Record<string, string>): Promise<MachCommandResult>;
262
+ export declare function xpcshellTestWithOutput(engineDir: string, testPaths?: string[], args?: string[], env?: Record<string, string>, fullOutput?: boolean): Promise<MachCommandResult>;
258
263
  /**
259
264
  * Runs `mach mochitest` (covers browser-chrome / mochitest flavors) while
260
265
  * capturing output. The suite-specific counterpart to {@link testWithOutput}
261
266
  * for non-xpcshell single-suite runs — see {@link xpcshellTestWithOutput}.
262
267
  */
263
- export declare function mochitestWithOutput(engineDir: string, testPaths?: string[], args?: string[], env?: Record<string, string>): Promise<MachCommandResult>;
268
+ export declare function mochitestWithOutput(engineDir: string, testPaths?: string[], args?: string[], env?: Record<string, string>, fullOutput?: boolean): Promise<MachCommandResult>;